env.dev

Neovim Lua: Complete Guide to vim.fn, vim.api, and Lua Scripting

Master Neovim Lua scripting: the vim.* namespace, vim.opt options, vim.fn.jobstart with environment variables, vim.system, autocommands, keymaps, user commands, vim.uv async I/O, and practical recipes for your init.lua config.

Last updated:

Neovim Lua is the first-class scripting layer that replaced Vimscript as the recommended way to configure Neovim. Since version 0.5, every Neovim API is callable from Lua through the vim.* namespace — options via vim.opt, Vimscript functions via vim.fn, the C API via vim.api, and environment variables via vim.env. Lua config loads 10–50× faster than equivalent Vimscript on startup and gives you a real programming language with tables, closures, and proper error handling.

Why Lua Instead of Vimscript?

Vimscript was designed for editor commands, not general programming. Lua brings proper data structures (tables, metatables), a real module system (require()), LuaJIT performance, and access to the full libuv event loop through vim.uv. The migration path is smooth — you can call any Vimscript function from Lua via vim.fn and run any Ex command via vim.cmd().

FeatureVimscriptLua
Startup speed~50ms for 200-line config~5ms equivalent config
Data structuresLists, dictsTables, metatables, closures
Async I/OLimited (jobs)Full libuv via vim.uv
Module systemautoload/require() with caching
Error handlingtry/catch (string-based)pcall/xpcall (structured)
EcosystemLegacy pluginsMost new plugins are Lua-first

How Is init.lua Structured?

Neovim looks for ~/.config/nvim/init.lua on startup. You can put everything in one file or split into modules under a lua/ directory and load them with require().

text
~/.config/nvim/
├── init.lua              -- Entry point
├── lua/
│   ├── options.lua       -- vim.opt settings
│   ├── keymaps.lua       -- vim.keymap.set() bindings
│   ├── autocmds.lua      -- vim.api.nvim_create_autocmd()
│   └── plugins/
│       ├── lsp.lua       -- LSP configuration
│       ├── treesitter.lua
│       └── telescope.lua
lua
-- init.lua
vim.g.mapleader = ' '
require('options')      -- loads lua/options.lua
require('keymaps')      -- loads lua/keymaps.lua
require('autocmds')     -- loads lua/autocmds.lua

Lua modules are cached after the first require() call. During development, use :source % to reload the current file or restart Neovim to pick up structural changes.

The vim.* Namespace at a Glance

The vim global is the gateway to every Neovim API. Here are the namespaces you will use daily:

vim.opt

Set editor options with Lua-friendly syntax

vim.opt.tabstop = 2

vim.fn

Call any Vimscript function

vim.fn.expand('%:p')

vim.api

Call the Neovim C API directly

vim.api.nvim_buf_line_count(0)

vim.keymap

Create and delete key mappings

vim.keymap.set('n', '<leader>w', ':w<CR>')

vim.cmd

Run Ex commands from Lua

vim.cmd('colorscheme habamax')

vim.env

Read/write environment variables

vim.env.HOME

vim.uv

libuv bindings (timers, fs, networking)

vim.uv.hrtime()

vim.g / vim.b / vim.w

Global, buffer, and window variables

vim.g.mapleader

How Do You Set Options with vim.opt?

Neovim provides two option interfaces: vim.opt (Lua-style, supports :append(), :prepend(), :remove()) and vim.o (raw value access). Use vim.opt for most cases.

lua
-- Boolean options
vim.opt.number         = true
vim.opt.relativenumber = true
vim.opt.wrap           = false
vim.opt.signcolumn     = 'yes'

-- Numeric options
vim.opt.tabstop    = 2
vim.opt.shiftwidth = 2
vim.opt.scrolloff  = 8

-- String options
vim.opt.clipboard  = 'unnamedplus'

-- List options (comma-separated internally)
vim.opt.wildignore:append({ '*.o', '*.pyc', 'node_modules' })
vim.opt.shortmess:append('I')  -- skip intro screen

-- Map options
vim.opt.listchars = { tab = '» ', trail = '·', nbsp = '␣' }

-- Reading an option value
local tw = vim.opt.tabstop:get()  -- returns Lua number
local wc = vim.o.wildignore       -- returns raw string

How Do You Create Keymaps?

vim.keymap.set() replaces the old vim.api.nvim_set_keymap(). It accepts a Lua function as the right-hand side and is noremap by default.

lua
-- Basic mappings
vim.keymap.set('n', '<leader>w', ':w<CR>', { desc = 'Save file' })
vim.keymap.set('n', '<leader>q', ':q<CR>', { desc = 'Quit' })

-- Lua function callback
vim.keymap.set('n', '<leader>l', function()
  vim.diagnostic.setloclist()
end, { desc = 'Diagnostics to loclist' })

-- Multiple modes at once
vim.keymap.set({ 'n', 'v' }, '<leader>y', '"+y', { desc = 'Yank to clipboard' })

-- Buffer-local mapping (e.g. in an LspAttach autocmd)
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, { buffer = bufnr, desc = 'Go to definition' })

-- Expression mapping
vim.keymap.set('n', 'k', "v:count == 0 ? 'gk' : 'k'", { expr = true, desc = 'Move by visual line' })

-- Delete a mapping
vim.keymap.del('n', '<leader>q')

How Do Autocommands Work in Lua?

Autocommands let you run code when specific events fire. The Lua API uses vim.api.nvim_create_autocmd() and vim.api.nvim_create_augroup().

lua
-- Create a group (clears existing autocmds in the group on reload)
local augroup = vim.api.nvim_create_augroup('MyGroup', { clear = true })

-- Highlight on yank
vim.api.nvim_create_autocmd('TextYankPost', {
  group = augroup,
  callback = function()
    vim.hl.on_yank({ timeout = 200 })
  end,
  desc = 'Briefly highlight yanked text',
})

-- Format on save
vim.api.nvim_create_autocmd('BufWritePre', {
  group = augroup,
  pattern = { '*.lua', '*.ts', '*.tsx' },
  callback = function()
    vim.lsp.buf.format({ async = false })
  end,
  desc = 'LSP format before save',
})

-- Set options for specific file types
vim.api.nvim_create_autocmd('FileType', {
  group = augroup,
  pattern = 'markdown',
  callback = function()
    vim.opt_local.wrap = true
    vim.opt_local.spell = true
  end,
})

How Do You Create User Commands?

Define custom :Commands with vim.api.nvim_create_user_command(). The callback receives a table with args, fargs, bang, and range.

lua
-- Simple command
vim.api.nvim_create_user_command('Hello', function()
  print('Hello from Lua!')
end, { desc = 'Print a greeting' })

-- Command with arguments and completion
vim.api.nvim_create_user_command('Grep', function(opts)
  vim.cmd('silent grep! ' .. opts.args)
  vim.cmd('copen')
end, { nargs = '+', desc = 'Grep and open quickfix' })

-- Buffer-local command
vim.api.nvim_buf_create_user_command(0, 'Format', function()
  vim.lsp.buf.format()
end, { desc = 'Format current buffer' })

How Does vim.fn Work?

vim.fn is a meta-table that lets you call any Vimscript built-in or user-defined function from Lua, with automatic type conversion between Lua tables and Vim lists/dicts. Functions containing special characters use bracket notation.

lua
-- Built-in functions
local home = vim.fn.expand('$HOME')        -- expand env vars and special chars
local cwd  = vim.fn.getcwd()               -- current working directory
local ext  = vim.fn.fnamemodify('a.lua', ':e')  -- 'lua'

-- Check if a file exists
if vim.fn.filereadable('/etc/hosts') == 1 then
  print('File exists')
end

-- System commands (synchronous)
local result = vim.fn.system('git rev-parse --short HEAD')
local output = vim.fn.systemlist('ls -la')  -- returns a table of lines

-- Autoload functions (use bracket notation for # characters)
vim.fn['plug#begin']()

-- Float/popup windows
local buf = vim.api.nvim_create_buf(false, true)
vim.fn.setbufline(buf, 1, { 'Line 1', 'Line 2' })

How Do You Run Async Jobs with vim.fn.jobstart?

vim.fn.jobstart() spawns an external process asynchronously. It accepts a command (string or list) and an options table with callbacks for on_stdout, on_stderr, and on_exit. The options table also supports env to set environment variables, cwd to set the working directory, and pty for pseudo-terminal mode.

lua
-- Basic async job
local job_id = vim.fn.jobstart('make build', {
  on_stdout = function(_, data, _)
    -- data is a list of lines (last element may be '')
    for _, line in ipairs(data) do
      if line ~= '' then
        print('stdout: ' .. line)
      end
    end
  end,
  on_stderr = function(_, data, _)
    for _, line in ipairs(data) do
      if line ~= '' then
        vim.notify(line, vim.log.levels.ERROR)
      end
    end
  end,
  on_exit = function(_, exit_code, _)
    if exit_code == 0 then
      vim.notify('Build succeeded', vim.log.levels.INFO)
    else
      vim.notify('Build failed (exit ' .. exit_code .. ')', vim.log.levels.ERROR)
    end
  end,
})

Setting Environment Variables for Jobs

The env option in jobstart() accepts a dictionary of environment variables. This is one of the most searched-for features — it lets you pass custom environment variables to the child process without modifying the Neovim session environment.

lua
-- Pass custom environment variables to a job
vim.fn.jobstart({ 'node', 'server.js' }, {
  env = {
    NODE_ENV = 'development',
    PORT = '3000',
    DATABASE_URL = 'postgresql://localhost/mydb',
  },
  cwd = vim.fn.expand('~/projects/myapp'),
  on_exit = function(_, code, _)
    vim.notify('Server exited with code ' .. code)
  end,
})

-- Inherit current env and override specific vars
-- Note: env replaces the entire environment by default.
-- To extend the current environment, merge with vim.fn.environ():
local current_env = vim.fn.environ()
current_env.MY_VAR = 'custom_value'
current_env.DEBUG = '1'

vim.fn.jobstart('my-tool', {
  env = current_env,
  on_stdout = function(_, data, _)
    vim.print(data)
  end,
})

jobstart Options Reference

OptionTypeDescription
on_stdoutfunction(job_id, data, event)Called when stdout data is available
on_stderrfunction(job_id, data, event)Called when stderr data is available
on_exitfunction(job_id, exit_code, event)Called when the process exits
envdictEnvironment variables for the child process
cwdstringWorking directory for the child process
ptybooleanRun in pseudo-terminal mode
stdinstringpipe (default), null, or a buffer number
stdout_bufferedbooleanBuffer stdout until job exits
stderr_bufferedbooleanBuffer stderr until job exits
detachbooleanDetach the job from Neovim process tree

How Do You Access Environment Variables?

Neovim provides vim.env to read and write environment variables in the editor session. Unlike shell expansion, $HOME and ~ are not automatically expanded in Lua strings — use vim.fn.expand() for that.

lua
-- Read environment variables
local home   = vim.env.HOME          -- '/home/user'
local editor = vim.env.EDITOR        -- 'nvim'
local path   = vim.env.PATH

-- Set environment variables (affects Neovim session + child processes)
vim.env.MYVAR = 'hello'

-- Expand $HOME and ~ in paths (vim.env does not expand these)
local config = vim.fn.expand('$HOME/.config/nvim')
local notes  = vim.fn.expand('~/notes')

-- Get all environment variables as a dict
local all_env = vim.fn.environ()

-- Check if a variable is set
if vim.env.VIRTUAL_ENV then
  print('Inside a Python venv: ' .. vim.env.VIRTUAL_ENV)
end

-- Conditional config based on environment
if vim.env.SSH_TTY then
  vim.opt.clipboard = ''  -- no clipboard over SSH
end

What Is vim.system and When Should You Use It?

Neovim 0.10 introduced vim.system() as a higher-level alternative to vim.fn.jobstart(). It wraps libuv process management and returns a SystemObj with :wait() for synchronous use or callback-based async.

lua
-- Synchronous: block until complete
local result = vim.system({ 'git', 'branch', '--show-current' }):wait()
print(result.stdout)  -- 'main\n'
print(result.code)    -- 0

-- Asynchronous with callback
vim.system({ 'curl', '-s', 'https://httpbin.org/ip' }, {}, function(obj)
  vim.schedule(function()
    vim.notify(obj.stdout)
  end)
end)

-- With environment and cwd options
vim.system(
  { 'npm', 'test' },
  { env = { CI = 'true' }, cwd = '/path/to/project' }
):wait()
Use casevim.fn.jobstart()vim.system()
Streaming output✓ on_stdout callback✗ Collects all output
Sync result✗ Must use jobwait()✓ :wait() returns result
PTY mode✓ pty option✗ Not supported
Send stdin✓ chansend()✓ stdin option
Neovim version0.5+0.10+
Best forLong-running, interactiveQuick commands, scripts

How Do You Use vim.uv for Async Operations?

vim.uv exposes the full libuv API — timers, filesystem operations, networking, and more. Since these run on the event loop, use vim.schedule() to safely call Neovim APIs from callbacks.

lua
-- Timer: debounced save
local timer = vim.uv.new_timer()
vim.api.nvim_create_autocmd('TextChanged', {
  callback = function()
    timer:stop()
    timer:start(1000, 0, vim.schedule_wrap(function()
      vim.cmd('silent! write')
    end))
  end,
})

-- File watcher: reload config on change
local watcher = vim.uv.new_fs_event()
local config_path = vim.fn.stdpath('config') .. '/init.lua'
watcher:start(config_path, {}, vim.schedule_wrap(function()
  vim.notify('Config changed, reloading...')
  vim.cmd('source ' .. config_path)
end))

-- Read a file asynchronously
vim.uv.fs_open('/tmp/data.txt', 'r', 438, function(err, fd)
  if err then return end
  vim.uv.fs_read(fd, 1024, 0, function(err2, data)
    vim.uv.fs_close(fd, function() end)
    if data then
      vim.schedule(function() vim.notify(data) end)
    end
  end)
end)

Error Handling with pcall and xpcall

Lua uses pcall() (protected call) to catch errors without crashing Neovim. This is essential in config code where a missing plugin or API change should not break your editor.

lua
-- Safe require (plugin might not be installed)
local ok, telescope = pcall(require, 'telescope')
if not ok then
  vim.notify('telescope.nvim not installed', vim.log.levels.WARN)
  return
end
telescope.setup({})

-- Safe API call with error message
local ok2, err = pcall(vim.api.nvim_set_hl, 0, 'BadGroup', { invalid = true })
if not ok2 then
  vim.notify('Highlight error: ' .. err, vim.log.levels.ERROR)
end

-- xpcall with traceback for debugging
xpcall(function()
  require('plugins.lsp')
end, function(err)
  vim.notify('LSP setup failed:\n' .. debug.traceback(err), vim.log.levels.ERROR)
end)

Common Patterns and Recipes

Toggle a boolean option

lua
vim.keymap.set('n', '<leader>tw', function()
  vim.opt.wrap = not vim.opt.wrap:get()
  vim.notify('wrap: ' .. tostring(vim.opt.wrap:get()))
end, { desc = 'Toggle word wrap' })

Run a formatter on save

lua
vim.api.nvim_create_autocmd('BufWritePost', {
  pattern = '*.lua',
  callback = function()
    local bufnr = vim.api.nvim_get_current_buf()
    vim.system({ 'stylua', vim.api.nvim_buf_get_name(bufnr) }, {}, function(obj)
      if obj.code == 0 then
        vim.schedule(function()
          vim.cmd('checktime')  -- reload buffer
        end)
      end
    end)
  end,
})

Create a scratch buffer

lua
vim.api.nvim_create_user_command('Scratch', function()
  local buf = vim.api.nvim_create_buf(false, true)
  vim.api.nvim_set_current_buf(buf)
  vim.bo[buf].filetype = 'markdown'
end, { desc = 'Open a scratch buffer' })

Frequently Asked Questions

How do you set environment variables for vim.fn.jobstart in Neovim Lua?

Pass an env table in the options: vim.fn.jobstart("cmd", { env = { KEY = "value" } }). This sets environment variables for the child process only. To extend rather than replace the environment, merge your variables with vim.fn.environ() first.

What is the difference between vim.fn and vim.api?

vim.fn calls Vimscript functions (both built-in like expand() and user-defined), with automatic Lua/Vimscript type conversion. vim.api calls the Neovim C API directly (nvim_* functions) and is faster but only exposes API functions, not legacy Vimscript ones.

How do you access $HOME or expand ~ in Neovim Lua?

Use vim.env.HOME for the raw value or vim.fn.expand('$HOME') / vim.fn.expand('~') for path expansion. Lua strings do not expand environment variables or tilde automatically — you must use these helper functions.

Should you use vim.fn.jobstart or vim.system for running external commands?

Use vim.system() (Neovim 0.10+) for simple commands where you want the full output as a result. Use vim.fn.jobstart() when you need streaming output via on_stdout/on_stderr callbacks, PTY mode, or compatibility with Neovim 0.5-0.9.

How do you reload your Lua config without restarting Neovim?

Run :source % to reload the current file. For full config reloads, :source $MYVIMRC works but does not clear previously loaded require() caches. For a clean reload, restart Neovim or use a plugin like plenary.reload.

What does vim.schedule() do and when do you need it?

vim.schedule() defers a function to run on the main event loop. You need it when calling Neovim API functions from callbacks that run outside the main loop — such as vim.uv timers, vim.system callbacks, or any libuv handler. Without it, API calls will error.

References

  • Neovim Lua Guide — official getting-started guide for using Lua in Neovim
  • Neovim Lua API Reference — complete reference for vim.fn, vim.api, vim.opt, and all vim.* namespaces
  • Neovim Channel & Job Control — documentation for jobstart(), channels, on_stdout/stderr/exit callbacks, and env options
  • kickstart.nvim — single-file, fully documented Neovim starter config in Lua
  • lazy.nvim — modern plugin manager with lazy-loading, lockfiles, and dependency resolution
  • nvim-lua-guide — community guide to Lua in Neovim (archived, now part of official docs)

New to Neovim? Start with the Neovim Developer Guide for modes, motions, and text objects, or grab the Neovim Cheat Sheet for a quick reference.