Configure Neovim – Code Formatting with conform.nvim
Published on Mar 19, 2026

In the last post we set up blink.cmp and got autocompletion working. Our LSP is running, our completion menu is active, and diagnostics are showing up in the gutter. One thing we still have not sorted out is formatting. The LSP can format our code, but relying on it for that job has some drawbacks. Not every language server formats well, some of them replace the entire buffer rather than applying minimal diffs, and there is no easy way to apply different formatting rules per filetype. That is where conform.nvim comes in.
Conform.nvim is a formatting plugin by Stevearc. It acts as a thin, well-designed layer between Neovim and whichever formatter you want to use for each filetype. You tell it which formatter to run for Lua, which to run for TypeScript, which to run for Python, and it handles the rest. It can format on save automatically, it can format a visual selection, and it falls back to the LSP if no dedicated formatter is configured for the current file. It is the formatter setup that most Neovim configs have converged on.
Prerequisites
Before continuing, make sure you have the following in place from the earlier posts in this series:
- Lazy.nvim set up as your plugin manager
- Mason installed — we will use it to install the formatters themselves
- The folder structure
lua/config/andlua/plugins/inside your Neovim config directory
The formatters that conform.nvim calls are separate programs that need to be installed on your system. Mason can install most of them for you, and we will cover that in the section below.
Installing conform.nvim
Inside lua/plugins/, create a new file called formatting.lua. We will keep all formatting-related configuration in this one file.
The recommended lazy-loading setup for conform.nvim uses BufWritePre as the load event and ConformInfo as a command trigger. BufWritePre fires just before a buffer is written to disk, which is exactly when formatting runs. Loading on this event means conform.nvim is not loaded at startup — it only loads the first time you save a file.
-- lua/plugins/formatting.lua
return {
"stevearc/conform.nvim",
event = { "BufWritePre" },
cmd = { "ConformInfo" },
---@module "conform"
---@type conform.setupOpts
opts = {
formatters_by_ft = {
lua = { "stylua" },
javascript = { "prettierd", "prettier", stop_after_first = true },
typescript = { "prettierd", "prettier", stop_after_first = true },
javascriptreact = { "prettierd", "prettier", stop_after_first = true },
typescriptreact = { "prettierd", "prettier", stop_after_first = true },
css = { "prettierd", "prettier", stop_after_first = true },
html = { "prettierd", "prettier", stop_after_first = true },
json = { "prettierd", "prettier", stop_after_first = true },
yaml = { "prettierd", "prettier", stop_after_first = true },
markdown = { "prettierd", "prettier", stop_after_first = true },
python = { "isort", "black" },
sh = { "shfmt" },
},
default_format_opts = {
lsp_format = "fallback",
timeout_ms = 2000,
},
format_on_save = {
timeout_ms = 2000,
lsp_format = "fallback",
},
},
keys = {
{
"<leader>fm",
function()
require("conform").format({ async = true, lsp_format = "fallback" })
end,
mode = { "n", "v" },
desc = "Format buffer or range",
},
},
init = function()
vim.o.formatexpr = "v:lua.require'conform'.formatexpr()"
end,
}
Save the file and run :Lazy sync to install the plugin. We still need to install the actual formatters before anything will run. Let us do that next.
Installing Formatters with Mason
conform.nvim is only the orchestration layer. The formatters it calls — stylua, prettier, black, and so on — are separate programs that need to be on your system. Mason can install most of them, and the cleanest way to manage this is to declare them in your existing mason.nvim configuration using mason-tool-installer.
Open lua/plugins/lsp.lua from the LSP post and add mason-tool-installer as an extra dependency:
-- Add this as a new entry in the return table inside lua/plugins/lsp.lua
{
"WhoIsSethDaniel/mason-tool-installer.nvim",
dependencies = { "mason-org/mason.nvim" },
opts = {
ensure_installed = {
-- Formatters
"stylua", -- Lua
"prettierd", -- JavaScript / TypeScript / CSS / HTML / JSON / YAML / Markdown
"black", -- Python
"isort", -- Python import sorting
"shfmt", -- Shell scripts
},
},
},
On the next Neovim startup, mason-tool-installer will check whether each tool is installed and download any that are missing. You can also trigger the install manually with :MasonToolsInstall.
Understanding the Configuration
Let me walk through the key parts of the conform.nvim config so the choices are clear.
formatters_by_ft
This table maps filetypes to the formatters that should run on them. The key is the Neovim filetype string — the value you would see from :set filetype? in a buffer — and the value is a list of formatter names.
For JavaScript and TypeScript we list two formatters: prettierd and prettier. The stop_after_first = true flag tells conform to run only the first one it finds installed. prettierd is a faster, daemonized version of prettier that starts a background process and reuses it across format calls. If prettierd is installed, conform will use it. If it is not, it falls back to regular prettier. This pattern lets you prioritise the faster option without breaking the setup on machines where only the standard version is available.
For Python we list isort followed by black. Without stop_after_first, conform runs both of them in sequence. isort organises the import statements at the top of the file first, then black reformats the rest of the file. Running them in this order matters because black has opinions about import formatting that can conflict with isort if they run the other way around.
default_format_opts
The lsp_format = "fallback" setting here tells conform what to do when there is no dedicated formatter configured for the current filetype. Instead of doing nothing, it falls back to asking the LSP to format the buffer. This means that any language with a server attached will still get formatting even if you have not explicitly listed a formatter for it.
format_on_save
Setting format_on_save to a table rather than a function is the simplest way to enable automatic formatting on every save. conform hooks into the BufWritePre event internally and formats the buffer synchronously before writing it to disk. The timeout_ms value is how long conform will wait for the formatter to respond before giving up. Two seconds is a reasonable ceiling — most formatters finish well under 500 milliseconds, but occasionally a slow formatter or a very large file will need a bit more time.
Disabling Format on Save
There are situations where you want to skip automatic formatting. A legacy codebase with inconsistent style, a file you are not supposed to touch, or a third-party file in your project that you do not want modified. conform.nvim makes it easy to disable formatting for these cases without removing the plugin entirely.
To disable format on save for a single buffer in the current session, run:
:lua vim.b.disable_autoformat = true
To re-enable it:
:lua vim.b.disable_autoformat = false
For a global toggle that affects every buffer, use vim.g.disable_autoformat instead. To make this easier to access, we can wire it up to a proper command by adding a config function to the plugin spec. Replace the opts = { ... } field with a config function like this:
config = function(_, opts)
require("conform").setup(opts)
-- Command to toggle format on save globally
vim.api.nvim_create_user_command("FormatDisable", function(args)
if args.bang then
-- FormatDisable! disables for the current buffer only
vim.b.disable_autoformat = true
else
vim.g.disable_autoformat = true
end
end, {
desc = "Disable autoformat on save",
bang = true,
})
-- Command to re-enable format on save
vim.api.nvim_create_user_command("FormatEnable", function()
vim.b.disable_autoformat = false
vim.g.disable_autoformat = false
end, {
desc = "Enable autoformat on save",
})
end,
Now you can run :FormatDisable to turn off auto-formatting globally, :FormatDisable! to turn it off for the current buffer only, and :FormatEnable to turn it back on.
For this to respect the flags we set, we also need to update the format_on_save option to use a function rather than a plain table, so it can check the flags before running:
format_on_save = function(bufnr)
if vim.g.disable_autoformat or vim.b[bufnr].disable_autoformat then
return
end
return {
timeout_ms = 2000,
lsp_format = "fallback",
}
end,
Checking Which Formatter Is Running
If you are not sure which formatter conform is using for a particular file, run :ConformInfo. This command prints a summary of the current buffer’s filetype, the formatters configured for it, whether each formatter is available on your system, and which one will actually run. It is the first thing to check when formatting is not working as expected.
Putting It All Together
Here is the complete lua/plugins/formatting.lua with everything from this post included:
-- lua/plugins/formatting.lua
return {
"stevearc/conform.nvim",
event = { "BufWritePre" },
cmd = { "ConformInfo" },
---@module "conform"
---@type conform.setupOpts
keys = {
{
"<leader>fm",
function()
require("conform").format({ async = true, lsp_format = "fallback" })
end,
mode = { "n", "v" },
desc = "Format buffer or range",
},
},
config = function(_, opts)
require("conform").setup(opts)
vim.api.nvim_create_user_command("FormatDisable", function(args)
if args.bang then
vim.b.disable_autoformat = true
else
vim.g.disable_autoformat = true
end
end, {
desc = "Disable autoformat on save",
bang = true,
})
vim.api.nvim_create_user_command("FormatEnable", function()
vim.b.disable_autoformat = false
vim.g.disable_autoformat = false
end, {
desc = "Enable autoformat on save",
})
end,
opts = {
formatters_by_ft = {
lua = { "stylua" },
javascript = { "prettierd", "prettier", stop_after_first = true },
typescript = { "prettierd", "prettier", stop_after_first = true },
javascriptreact = { "prettierd", "prettier", stop_after_first = true },
typescriptreact = { "prettierd", "prettier", stop_after_first = true },
css = { "prettierd", "prettier", stop_after_first = true },
html = { "prettierd", "prettier", stop_after_first = true },
json = { "prettierd", "prettier", stop_after_first = true },
yaml = { "prettierd", "prettier", stop_after_first = true },
markdown = { "prettierd", "prettier", stop_after_first = true },
python = { "isort", "black" },
sh = { "shfmt" },
},
default_format_opts = {
lsp_format = "fallback",
timeout_ms = 2000,
},
format_on_save = function(bufnr)
if vim.g.disable_autoformat or vim.b[bufnr].disable_autoformat then
return
end
return {
timeout_ms = 2000,
lsp_format = "fallback",
}
end,
},
init = function()
vim.o.formatexpr = "v:lua.require'conform'.formatexpr()"
end,
}
Verifying the Setup
Restart Neovim and open a Lua file. Make a change — add an extra space somewhere or remove a comma after the last item in a table — then save with :w. The file should be formatted instantly before the save completes.
To confirm visually, try pasting a block of badly indented JavaScript into a .js file and saving it. Prettier should normalise the indentation and spacing immediately on write.
If nothing happens, run :ConformInfo to check that the formatter is installed and available, and :Lazy to confirm that conform.nvim loaded successfully.
Wrapping Up
With conform.nvim in place, every file we save is formatted consistently without any manual intervention. Combined with the LSP diagnostics and autocompletion from the previous posts, our Neovim setup is now doing everything we would expect from a modern development environment.
In the next post we are going to take a step back from code tooling and look at a quality-of-life plugin for those long, focused coding sessions: zen-mode.nvim.
To find the rest of my posts on Neovim, click here.




