Scripting Neovim with Lua: From init.lua to Keymaps and Autocmds
Neovim ships with a first-class Lua interpreter — embedded LuaJIT — and a rich API that makes it the editor of choice for developers who want programmable text editing without fighting their tool. Scripting Neovim with Lua means replacing Vimscript configuration with a real programming language that has modules, closures, and a standard library. This tutorial walks you through the transition, from your first init.lua to working with buffers, windows, autocmds, and keymaps.
Prerequisites
You need Neovim 0.7 or later installed on your machine — earlier versions support Lua but lack the modern API functions like vim.keymap.set and vim.api.nvim_create_autocmd that this tutorial uses. You should be comfortable editing text in Neovim at a basic level (opening files, switching modes, saving). No prior Lua experience is required, though familiarity with any scripting language will help you follow the examples.
Why script Neovim with Lua?
Vimscript served Vi well for decades, but Lua offers concrete advantages inside Neovim:
- Speed — LuaJIT executes Lua code much faster than Vimscript in most benchmarks
- Modules —
require()gives you a proper package system for organising code - Standard library — tables, coroutines, and string manipulation are native
- Interoperability — call Vimscript functions, set options, and interact with buffers all from Lua
If you’ve ever written a Vimscript function to automate a repetitive editing task, you’ll find the equivalent in Lua far more expressive and maintainable.
Your first init.lua
Vim’s traditional config file is init.vim. Neovim also looks for init.lua at the config path (typically ~/.config/nvim/init.lua). Create it now:
-- ~/.config/nvim/init.lua
print("Hello from Lua!")
Restart Neovim and you should see Hello from Lua! printed at startup. That confirms Lua is executing. This print call is using Lua’s standard output function — Neovim redirects it to the message area at the bottom of the screen, the same place where :echo output appears in Vimscript. If you don’t see the message, check that your init.lua is in the correct location by running :echo stdpath('config') inside Neovim to verify the config path.
Every Lua config you write runs in the context of Neovim’s embedded Lua environment, which provides the global vim object. This is your primary interface to everything Neovim — options, buffers, windows, autocmds, and more. The vim object is a Lua table that wraps the Neovim C API, and every function on it follows a consistent naming convention: nvim_* for the raw API, vim.api.nvim_* for the Lua binding, and vim.* shortcuts for common operations.
The vim global object
The vim global is the core of Neovim’s Lua API. It exposes several namespaces, each corresponding to a scope in Vimscript. Understanding which namespace to use for which task is essential: vim.o for global options that affect all buffers, vim.bo when you want buffer-only changes, and vim.wo for window-specific settings. Using the wrong scope creates subtle bugs — for example, setting expandtab on vim.o changes it globally, which may not be what you want.
vim.o -- global options (:set)
vim.go -- global options (:setglobal)
vim.bo -- buffer-local options (:setlocal on buffer)
vim.wo -- window-local options (:setlocal on window)
vim.g -- global variables (g:)
vim.b -- buffer variables (b:)
vim.w -- window variables (w:)
vim.t -- tabpage variables (t:)
vim.v -- predefined Vim variables (v:true, v:count, etc.)
vim.env -- environment variables
vim.fn -- call Vimscript functions
vim.api -- raw Neovim API functions
vim.cmd -- execute Ex commands
vim.keymap -- manage key mappings
Setting options
You can set options directly through vim.o. Each assignment mirrors a :set command from Vimscript, but using Lua’s natural assignment syntax. Boolean options take true or false rather than being toggled with !. String and number options work exactly as you would expect, and the result applies immediately — no need to source your config again after making these changes.
vim.o.number = true -- show line numbers
vim.o.expandtab = true -- use spaces, not tabs
vim.o.shiftwidth = 2 -- indent by 2 spaces
vim.o.wrap = false -- no line wrapping
For buffer and window-local options, use vim.bo and vim.wo respectively. These change settings only for the current buffer or window, leaving the global default untouched. A common pattern is to set vim.bo.expandtab = true inside an autocmd for specific file types, so that Python files use spaces while Makefiles keep their required tabs. The vim.opt accessor (Neovim 0.8+) provides object-style methods like :get(), :append(), and :remove() that make option manipulation more expressive than raw assignment.
vim.bo.expandtab = true -- buffer-local (current buffer only)
vim.wo.number = true -- window-local (current window only)
The vim.opt accessor (Neovim 0.8+) gives you convenient methods that behave like objects rather than raw assignments. Each option becomes a table-like wrapper with :get(), :append(), and :remove() methods, which makes option scripting safer — you can ask an option for its current value before changing it, append to string options, or remove sub-values without knowing the full state:
vim.opt.expandtab = true -- set option
vim.opt.shiftwidth:get() -- get current value
vim.opt.shiftwidth:append(4) -- :set sw+=4
vim.opt.shiftwidth:remove(2) -- :set sw-=2
vim.opt_global.expandtab = true -- :setglobal
vim.opt_local.expandtab = true -- :setlocal
Working with variables
Global, buffer, window, and tabpage variables map directly to the vim.g, vim.b, vim.w, and vim.t tables. These work like any Lua table — you can store strings, numbers, booleans, and even nested tables. This is how most Neovim plugins share configuration: the plugin reads from vim.g.plugin_name and the user sets it in their init.lua. Buffer-local variables are particularly useful for language server protocol (LSP) setup, where you might store per-buffer diagnostic counts or client IDs.
vim.g.my_plugin_config = { theme = "dark", debug = false }
vim.b.current_language = "lua"
-- Access predefined variables
print(vim.v.count) -- count register (from a count before an operator)
print(vim.v.true) -- always true — useful in expressions
print(vim.v.null) -- null value
Environment variables are accessed via vim.env. This table is a live view of the process environment — changes you make are visible to any child processes Neovim spawns. A common use case is setting $PATH or $XDG_CONFIG_HOME during plugin initialization, or reading $HOME to construct file paths that work across different machines. Each environment variable is a simple key on the vim.env table, and reading or writing them works exactly like any other Lua table assignment:
vim.env.HOME -- your home directory
vim.env.PATH -- the PATH variable
A common pattern is using vim.env to set the runtime path for plugins written in Lua. Setting XDG_CONFIG_HOME explicitly ensures Neovim and its plugins always find your configuration files, even when running in environments where the XDG variables are not set — common with sudo or containerized setups:
vim.env.XDG_CONFIG_HOME = vim.env.HOME .. "/.config"
Executing Vimscript
You don’t have to convert everything at once. vim.cmd executes Vimscript as a string, making it ideal for running Ex commands that don’t have a direct Lua equivalent yet, or for calling complex commands where the Lua API is unwieldy. You can pass multi-line strings using Lua’s double-bracket syntax [[]], which avoids escaping issues with quotes and backslashes. The first example below shows a pure Vimscript block for common text display settings:
vim.cmd([[
set wrap
set linebreak
]])
Or mix Lua and Vimscript in the same expression. This is useful for commands that need values computed at runtime, like resizing windows based on the current terminal width or toggling options dynamically:
vim.cmd("set " .. vim.o.columns .. " columns")
To call Vimscript functions and get their return values, use vim.fn. Every built-in function that exists in Vimscript — printf, line, getcwd, expand — is available here. The return values follow Lua conventions: Vimscript’s 1 and 0 for boolean operations become numbers, so you need explicit comparison (== 1) rather than relying on Lua’s truthiness rules.
local result = vim.fn.printf("Hello, %s!", "world")
local line_count = vim.fn.line("$")
local cwd = vim.fn.getcwd()
vim.fn is the equivalent of Vimscript’s built-in functions. You can also call autoload functions by using bracket notation — the function name becomes the string key, which lets you call functions whose names contain special characters like #:
vim.fn["myplugin#setup"]({ theme = "dark" })
Keymaps
The modern way to set keymaps is vim.keymap.set (Neovim 0.7+). This function replaces the old vim.api.nvim_set_keymap with a cleaner API that accepts a Lua function as the right-hand side, supports buffer-local mappings through the buffer option, and includes a desc field for human-readable descriptions that show up in which-key and telescope.
-- Normal mode keymap
vim.keymap.set('n', '<leader>ff', ':FzfLua files<CR>', { silent = true })
-- Insert mode keymap
vim.keymap.set('i', '<C-u>', '<C-o>u', { desc = 'Undo in insert mode' })
-- Lua function as the rhs
vim.keymap.set('n', '<leader>cc', function()
local line = vim.api.nvim_get_current_line()
print(line)
end, { desc = 'Print current line' })
-- Buffer-local keymap
vim.keymap.set('n', '<leader>d', vim.lsp.buf.definition, {
buffer = bufnr,
desc = 'Go to definition',
})
Options include silent (suppress command echoing), noremap (prevent recursive resolution), desc (human-readable description available in Neovim 0.10+), and buffer (scope the mapping to a specific buffer instead of globally). Understanding these options is important: a mapping without noremap can recursively trigger itself if the right-hand side contains the same key sequence, and omitting silent causes the mapped command string to flash in the command line on every invocation.
silent— don’t echo the commandnoremap— don’t recursively resolve the mappingdesc— human-readable description (Neovim 0.10+)buffer— make the mapping buffer-local
To delete a keymap, pass the mode and the left-hand side to vim.keymap.del. This is useful during plugin teardown or when dynamically reconfiguring mappings based on context:
vim.keymap.del('n', '<leader>ff')
Autocommands
Autocommands let you run code in response to events like buffer writes, window events, or file loads. The modern API (Neovim 0.7+) uses vim.api.nvim_create_autocmd. Compared to the old nvim_command('autocmd ...') approach, the new API is type-safe, supports Lua callbacks, and returns an autocmd ID that you can use to delete the autocmd later without string-matching.
vim.api.nvim_create_autocmd("BufWritePre", {
pattern = "*.lua",
callback = function(args)
-- args.buf is the buffer number that triggered the event
print("Saving buffer " .. args.buf)
end,
desc = "Called before writing a Lua file",
})
The example above creates a standalone autocmd, but every time you reload your config, another copy of the autocmd is registered. After sourcing your init.lua five times, the callback fires five times on every buffer write. Group your autocmds in an augroup for clean management. The clear = true option on nvim_create_augroup deletes any previously registered autocmds in the group before adding new ones, which prevents duplicate autocmds when you reload your config. Without augroups and clear = true, every time you source your init.lua, the autocmds would stack up, causing callbacks to fire multiple times for each event.
local mygroup = vim.api.nvim_create_augroup("my_config", { clear = true })
vim.api.nvim_create_autocmd("TextYankPost", {
group = mygroup,
callback = function()
vim.highlight.on_yank({ higroup = "IncSearch", timeout = 200 })
end,
desc = "Highlight yanked text briefly",
})
vim.api.nvim_create_autocmd("TermOpen", {
group = mygroup,
pattern = "term://*",
callback = function(args)
vim.api.nvim_buf_set_keymap(args.buf, "t", "<Esc>", "<C-\\><C-n>", {
noremap = true,
silent = true,
})
end,
desc = "Set terminal escape key",
})
Delete autocmds by ID or augroup by name. When writing plugins, clean up your autocmds during teardown to avoid leaving orphaned event handlers that reference freed resources:
vim.api.nvim_del_autocmd(autocmd_id)
vim.api.nvim_del_augroup("my_config")
Buffers
Every open file is a buffer. The API gives you fine-grained control over buffer creation, content manipulation, and metadata. Buffers are identified by integer handles (bufnr), which you obtain from nvim_get_current_buf for the active buffer or by iterating over nvim_list_bufs for all buffers. The line-based API uses 0-indexed row ranges, matching Lua’s convention rather than Vimscript’s 1-indexing.
local bufnr = vim.api.nvim_get_current_buf() -- get current buffer number
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) -- get all lines
local name = vim.api.nvim_buf_get_name(bufnr) -- get full path
local ft = vim.api.nvim_buf_get_filetype(bufnr) -- get filetype
-- Modify buffer content
vim.api.nvim_buf_set_lines(bufnr, 0, 3, false, {
"First line",
"Second line",
"Third line",
})
-- Set filetype
vim.api.nvim_buf_set_filetype(bufnr, "lua")
-- Create a new scratch buffer
local new_buf = vim.api.nvim_create_buf(false, true) -- listed=false, scratch=true
vim.api.nvim_buf_set_name(new_buf, "scratch")
vim.api.nvim_buf_set_lines(new_buf, 0, -1, false, { "Hello, scratch buffer!" })
Open the scratch buffer in a split. The nvim_command("split") call creates a new window, and then nvim_win_set_buf attaches the scratch buffer to that window. This two-step pattern — create buffer, then attach to window — is the standard way to display buffer content in Neovim’s Lua API:
vim.api.nvim_command("split")
local win = vim.api.nvim_get_current_win()
vim.api.nvim_win_set_buf(win, new_buf)
Delete a buffer (Neovim 0.9+). The force option controls whether unsaved changes prevent deletion — set it to false for safety if you want the operation to fail on modified buffers:
vim.api.nvim_buf_delete(bufnr, { force = false })
Windows
Windows are viewports onto buffers. A single buffer can be displayed in multiple windows simultaneously, and each window maintains its own cursor position, scroll offset, and display options. The API distinguishes between the window handle (a viewport) and the buffer handle (the content), and you will frequently convert between them when building UI plugins, file explorers, or LSP-related floating windows.
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_win_get_buf(win) -- buffer in this window
local cursor = vim.api.nvim_win_get_cursor(win) -- {row, col}, 0-indexed
-- Set cursor position (row is 1-indexed, col is 0-indexed in the API)
vim.api.nvim_win_set_cursor(win, { cursor[1], cursor[2] })
-- Get window dimensions
local height = vim.api.nvim_win_get_height(win)
local width = vim.api.nvim_win_get_width(win)
Create a floating window (great for UI elements like popups and notifications). Floating windows are drawn above the regular window grid with configurable borders, anchors, and z-index. The relative option sets the coordinate system — "win" positions the float relative to the current window’s top-left corner, while "editor" uses absolute screen coordinates:
local float_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(float_buf, 0, -1, false, {
"This is a floating window.",
"Press a key to close.",
})
local config = {
relative = "win",
width = 40,
height = 10,
row = 5,
col = 10,
anchor = "NW",
border = "rounded",
style = "minimal",
noautocmd = false,
}
local float_win = vim.api.nvim_open_win(float_buf, true, config)
Putting it together: a practical init.lua
Here’s a complete init.lua that demonstrates the concepts covered in this tutorial. This configuration sets sensible defaults, adds a highlight-on-yank effect, strips trailing whitespace on save, and defines a few quality-of-life keymaps. The augroup pattern with clear = true ensures that sourcing this file multiple times doesn’t duplicate the autocmds — a common gotcha when iterating on your config.
-- Create an augroup for our config
local mygroup = vim.api.nvim_create_augroup("my_config", { clear = true })
-- Set global options
vim.o.number = true
vim.o.relativenumber = true
vim.o.expandtab = true
vim.o.shiftwidth = 2
vim.o.tabstop = 2
vim.o.softtabstop = 2
vim.o.ignorecase = true
vim.o.smartcase = true
vim.o.termguicolors = true
-- Autocmd: highlight yanked text
vim.api.nvim_create_autocmd("TextYankPost", {
group = mygroup,
callback = function()
vim.highlight.on_yank({ higroup = "IncSearch", timeout = 150 })
end,
desc = "Brief highlight on yank",
})
-- Autocmd: strip trailing whitespace on save
vim.api.nvim_create_autocmd("BufWritePre", {
group = mygroup,
pattern = "*",
callback = function(args)
local buf = args.buf
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
for i, line in ipairs(lines) do
local stripped = line:gsub("%s+$", "")
if stripped ~= line then
lines[i] = stripped
end
end
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
end,
desc = "Strip trailing whitespace before save",
})
-- Keymap: save with Ctrl-s
vim.keymap.set("n", "<C-s>", ":w<CR>", { silent = true, desc = "Save file" })
-- Keymap: quit with Ctrl-q
vim.keymap.set("n", "<C-q>", ":q<CR>", { silent = true, desc = "Quit" })
-- Keymap: open a terminal in a vertical split
vim.keymap.set("n", "<leader>tv", function()
vim.api.nvim_command("vsplit | terminal")
end, { silent = true, desc = "Open terminal in vertical split" })
Common pitfalls
require() Caching
Lua’s require() caches loaded modules. Editing a module and calling require() again returns the cached version. To reload during development, clear the entry from package.loaded before re-requiring:
package.loaded["my_module"] = nil
require("my_module")
Boolean Confusion
Vimscript returns 1 and 0 for true and false, not Lua booleans. Both are truthy in Lua, so a naive if vim.fn.has("nvim") always evaluates to true — even when the function returns 0. Always compare explicitly against the expected numeric value when calling Vimscript functions that return boolean-like results:
-- WRONG: vim.fn.has() always returns truthy
if vim.fn.has("nvim") then print("always runs") end
-- RIGHT: explicit comparison
if vim.fn.has("nvim") == 1 then print("runs only in Neovim") end
Indexing: 0 vs 1
The Lua API uses 0-indexed rows in nvim_buf_get_lines and cursor positions, but Vimscript is 1-indexed. Always double-check the indexing convention when mixing APIs. For example, nvim_win_get_cursor returns a {row, col} pair where row is 1-indexed but col is 0-indexed — a genuine inconsistency that trips up even experienced plugin authors.
Keycodes in Strings
<CR> and <Esc> are not automatically interpreted in Lua strings passed to nvim_set_keymap. Use vim.keymap.set with a Lua function, or call vim.api.nvim_replace_termcodes manually to expand angle-bracket notation into the actual control characters that Neovim expects.
Global by Default
Lua variables are global unless declared local. Always use local:
local my_config = {}
my_config.value = 10 -- correct
Omitting local creates a global that persists and can shadow other variables. In a long-running Neovim session, accidental globals accumulate in the Lua VM and can cause hard-to-debug interactions between plugins.
Summary
Neovim’s Lua integration gives you a real programming language for configuring and extending your editor:
vim.o,vim.opt— set and get optionsvim.g,vim.b,vim.w— work with variables at different scopesvim.keymap.set— create key mappings (Neovim 0.7+)vim.api.create_autocmd— respond to events (Neovim 0.7+)vim.apibuffer and window functions — manipulate the editor programmaticallyvim.cmd,vim.fn— bridge to Vimscript when needed
Start small: replace one Vimscript block in your config with a Lua equivalent. As you grow comfortable with the vim global, you’ll find yourself reaching for Lua first — not just for plugins, but for everything from quick one-liners to complex custom workflows.
Next steps
You can now write init.lua files, set options, create keymaps, add autocmds, and manipulate buffers and windows programmatically. The next step is to apply these skills to a real configuration: try replacing one Vimscript block in your existing config with a Lua equivalent, then gradually convert the rest. For plugin development, explore the vim.lsp and vim.treesitter modules, which use the same API patterns you have already learned.
See also
- Neovim Lua plugin development — build a complete Neovim plugin using the Lua API
- Installing Lua — set up the Lua runtime environment on your system
- Neovim official Lua guide — the comprehensive reference for Neovim’s Lua API