Query PostgreSQL from Neovim without leaving your editor, without blocking it, and without putting a password in your config.
require("dbsh").setup({
connections = {
local_db = { host = "localhost", port = 5432, database = "postgres", username = "dev" },
},
default = "local_db",
})This is a fork of harrisoncramer/psql, itself forked from mzarnitsa/psql. Those projects contributed the core idea this one still rests on: write SQL in a normal buffer, read the result in another one, no modal UI in between.
Everything under that idea has been rewritten. Execution moved off the main
thread, authentication moved to ~/.pgpass, the schema became browsable, and the
plugin gained a test suite. If you are coming from either upstream, read
Migrating — the configuration format
changed.
| upstream | dbsh.nvim | |
|---|---|---|
| Execution | vim.fn.systemlist, editor frozen until the query returns |
vim.system, asynchronous and cancellable |
| Authentication | password or hash stored in your Neovim config | ~/.pgpass, resolved by psql itself |
| Switching database | one Lua file per connection, hand-written | Telescope picker over declared connections |
| Schema browsing | none | connections, databases, schemas, tables |
| Ad-hoc queries | scratch buffer, lost on exit | a real .sql file per connection |
| Getting data out | one cell at a time | CSV to file, or CSV from a visual selection |
| Result buffer | soft-wrapped, editable | horizontal scroll, read-only |
| Lua namespace | lua/psql.lua, lua/util/, lua/hash/ |
everything under lua/dbsh/ |
| Tests | none | 95 cases on mini.test, make test |
Queries run through vim.system. The editor stays responsive while one is in
flight, and :DbCancel kills it. A generation counter invalidates results that
belong to a connection you have already left, so a slow answer can never
overwrite a fresh one.
There is no password field to fill in, no hash, and no PGPASSWORD in your
process list. psql is always invoked with -w and resolves credentials from
~/.pgpass, which is the mechanism PostgreSQL already ships for this.
Four pickers, and a drill-down from schemas into their tables. Selecting a table previews it immediately.
- Neovim 0.10+ — the plugin uses
vim.system,vim.fn.getregionandvim.fs.joinpath psqlon yourPATH- telescope.nvim — optional; every picker degrades to a clear message without it, and queries work fine
- postgres-language-server —
optional; only needed if you turn on
lsp.enabled, see Language server
lazy.nvim
{
"edjubert/dbsh.nvim",
dependencies = { "nvim-telescope/telescope.nvim" },
config = function()
require("dbsh").setup({
connections = {
local_db = { host = "localhost", port = 5432, database = "postgres", username = "dev" },
staging = { host = "db.example.com", port = 5432, database = "app", username = "readonly" },
},
default = "local_db",
})
end,
}packer.nvim
use({
"edjubert/dbsh.nvim",
requires = { "nvim-telescope/telescope.nvim" },
config = function()
require("dbsh").setup({ --[[ ... ]] })
end,
})To get the pickers under :Telescope, load the extension:
require("telescope").load_extension("dbsh")
-- :Telescope dbsh tables- Declare a connection in
setup()— four fields, no password. - Add a line for it to
~/.pgpass, thenchmod 600 ~/.pgpass. - Open a
.sqlfile, put the cursor in a statement, and run:lua require("dbsh").query_paragraph().
If a password prompt appears, ~/.pgpass is being ignored — see
Troubleshooting.
require("dbsh").setup({
connections = {
local_db = { type = "postgres", host = "localhost", port = 5432, database = "postgres", username = "dev" },
},
default = "local_db",
connect_timeout = 5,
query_timeout = 30000,
preview_limit = 10,
csv_delimiter = ",",
export_dir = vim.fs.joinpath(vim.fn.stdpath("data"), "dbsh", "exports"),
results_split = "horizontal",
variable_patterns = {}, -- e.g. { ":(raw_data)" }, see SQL variables below
lsp = { enabled = false }, -- steer postgres-language-server, see below
})| Option | Default | Meaning |
|---|---|---|
connections |
{} |
Named connections. type names the backend driving it and defaults to "postgres"; a postgres connection then needs host, port, database, username. |
default |
nil |
Connection selected at startup. Falls back to any declared one. |
connect_timeout |
5 |
PGCONNECT_TIMEOUT, in seconds. |
query_timeout |
30000 |
Kills a runaway query, in milliseconds. |
preview_limit |
10 |
LIMIT used when previewing a table from the picker. |
csv_delimiter |
"," |
Column separator, for both CSV export and CSV yank. |
export_dir |
<stdpath("data")>/dbsh/exports |
Where :DbExportCSV suggests writing. |
results_split |
"horizontal" |
"horizontal", "vertical" or "float": which window opens __DBSH__ in. Only applies the first time the window is created; combine with vim.opt.splitright = true for a right-hand split. "float" is styled after your telescope config, when installed. |
variable_patterns |
{} |
Lua patterns (one capture each) naming SQL variables to prompt for. See SQL variables. |
lsp |
{ enabled = false } |
Point an already-running postgres-language-server at the connection you pick. Off by default: it talks to a client dbsh does not own. |
There is deliberately no password field.
psql resolves passwords from ~/.pgpass. The plugin passes -w, so it never
prompts and never leaks a password into the process list.
# ~/.pgpass — hostname:port:database:username:password
db.example.com:5432:*:readonly:secret
localhost:5432:*:dev:secret
The file must be chmod 600:
chmod 600 ~/.pgpassPostgreSQL silently ignores a .pgpass with looser permissions. The symptom is an
unexpected password prompt, and it is the single most common setup mistake.
postgres-language-server validates SQL against a real database: it will tell you a column does not exist before you run the query. It only knows one database at a time, declared in a configuration file — which does not survive someone who changes database ten times a day.
Turn this on and the database you pick with :DbDatabases becomes the database
it diagnoses against, with no restart and no file editing:
require("dbsh").setup({
connections = { ... },
lsp = { enabled = true },
})dbsh does not start, stop or install the server: it talks to the client you
already run, through lspconfig or mason. It applies to every .sql file in the
session, not just the scratchpad. Picking a schema with :DbSchemas pushes the
same connection again, which costs nothing.
After every successful query, dbsh clears the server's schema cache and warms it
back up, so a CREATE TABLE is reflected in completion right away. It refreshes
unconditionally rather than guessing which statements were DDL — dbsh never
parses your SQL. The warm-up takes roughly half a second in the background,
which is why it happens then rather than under your fingers on the next
completion.
dbsh pushes the host, the port, the user and the database. Never the
password. There is deliberately no password field in a dbsh connection, and
that stays true here: a password pushed through the LSP protocol would land in
client.settings, and in plain text in the Neovim LSP log for anyone running
vim.lsp.log at debug level.
The server only overrides the fields it receives, so the password you declare elsewhere survives. Three ways to give it one:
- A
passwordin the project'spostgres-language-server.jsonc— the nominal path, and what survives dbsh pushing everything else. PGPASSWORDin Neovim's environment.- No password at all:
trust, a Unix socket, orpeerauthentication.
If none applies, the server falls back to its own default, the connection fails, and static diagnostics keep working — parse errors and lints are unaffected. Only completion and type-checking stop, and they recover on their own once the connection succeeds.
- A
connectionStringin yourpostgres-language-server.jsoncwins over everything. The server reads the URI first and ignores the individual fields, and dbsh cannot erase a field it does not send. Use the separatehost/port/username/databasefields in that file. PGHOST,PGPORT,PGUSER,PGDATABASEorDATABASE_URLin your environment win too, because the server merges the environment last. This is a real trap if you live inpsqland exportPGDATABASE. dbsh warns once per session when it sees one.- Editing
postgres-language-server.jsoncmid-session makes the server reload the file and lose what dbsh pushed. Switching connection fixes it.
| Command | Description |
|---|---|
:DbConnections |
pick a connection |
:DbTemp |
open the scratchpad of the current connection |
:DbToggleResults |
toggle the result window closed or open, keeping its content |
:DbExportCSV |
export a query result to a CSV file — accepts a range |
:DbCancel |
cancel the running query |
:DbInfo |
show the current connection and database |
The catalog commands are declared by the backend of the current connection, not by the plugin, and are redeclared whenever you switch connection. A postgres connection gives you:
| Command | Description |
|---|---|
:DbDatabases |
pick a database on the current server |
:DbSchemas |
pick a schema, then drill into its tables |
:DbTables |
pick any table, as a flat schema.table list |
A backend with a different hierarchy declares different commands, and the ones that do not apply simply do not exist — completion only offers what makes sense for the connection you are on.
The plugin defines no keymaps. These are the functions worth binding:
| Function | Description |
|---|---|
query(sql) |
run an arbitrary string |
query_paragraph() |
run the block around the cursor, delimited by blank lines |
query_current_line() |
run the line under the cursor |
query_selection() |
run the visual selection, in any visual mode |
yank_cell() |
copy the result cell under the cursor |
yank_csv() |
copy the selected result cells as CSV |
export_csv(opts) |
the function behind :DbExportCSV |
last_query() |
the last query handed to query(), or nil |
local dbsh = require("dbsh")
local opts = { noremap = true, silent = true, nowait = true }
vim.keymap.set("n", "<localleader>r", dbsh.query_paragraph, opts)
vim.keymap.set("v", "<localleader>r", dbsh.query_selection, opts)
vim.keymap.set("n", "<localleader>e", dbsh.query_current_line, opts)
vim.keymap.set("v", "<localleader>e", dbsh.query_selection, opts)
vim.keymap.set("n", "<localleader>y", dbsh.yank_cell, opts)
vim.keymap.set("v", "<localleader>y", dbsh.yank_csv, opts)Bind every key in both normal and visual mode, even where one of the two looks
redundant. An unmapped <localleader> prefix falls through in visual mode, and the
next key is then read as a plain Vim command: with <localleader>r left unmapped,
pressing it over a selection runs r, which silently replaces every selected
character. Mapping it closes that trap.
| normal mode | visual mode | |
|---|---|---|
<localleader>r |
run the paragraph | run the selection |
<localleader>e |
run the current line | run the selection |
<localleader>y |
yank the cell under the cursor | yank the selection as CSV |
Three ways to send SQL, none of which need a precise selection:
query_paragraph()— the block of lines around the cursor, bounded by blank lines. This is the one you will reach for. Separate your statements with a blank line and you never have to select anything.query_current_line()— just the line under the cursor.query_selection()— whatever is selected, in any visual mode.
While a query runs, the result buffer shows a # Running... placeholder, and
:DbCancel kills the process.
Results land in a reused __DBSH__ buffer, which keeps previous queries in view as
a working history.
Wrapping is off, so wide tables scroll horizontally (zl / zh, zL / zH for
bigger jumps) instead of folding into unreadable blocks. The buffer is also
read-only: it is rendered by the plugin, and hand edits would only desync it
from what psql returned.
On failure, stderr is rendered in place of the result, so a syntax error reads
exactly where you expect the table.
![]() |
![]() |
:DbConnections— switch between the connections you declared.:DbDatabases— every database on the current server. Selecting one keeps the same host, port and user, and swaps only the database.:DbSchemas— pick a schema, then land in its tables.<BS>goes back to the schema list; it is bound in normal mode only, so backspace still edits the prompt.:DbTables— every table, as a flatschema.tablelist, annotated with its kind:table,view,matview,partitioned.
Selecting a table runs SELECT * FROM "schema"."table" LIMIT 10;, with the limit
taken from preview_limit. Identifiers are quoted, so mixed-case names and
reserved words survive.
Catalog navigation is generic: the backend declares an ordered list of levels,
and the plugin renders each of them with the same picker. <BS> walks back up
that list. Introspection runs on its own execution slot, which means opening a
picker never cancels a query you are waiting on.
The plugin drives a database shell as a subprocess; which shell it drives is a property of the connection:
connections = {
local_db = { type = "postgres", host = "localhost", port = 5432, database = "postgres", username = "dev" },
}type defaults to "postgres", which is the only backend implemented today. A
backend is a small table under lua/dbsh/backends/: it says how to build the
CLI invocation, what preamble to write, how to parse raw output, how to declare
a query variable, how to export CSV, and which navigation levels its catalog
has. Everything else — the result buffer, the CSV yank, the scratchpad, the
variable prompts, the export file handling — is shared and knows nothing about
any particular database.
:DbTemp opens <stdpath("data")>/dbsh/<connection>.sql — a real file on disk,
not a throwaway buffer. Your SQL LSP, your formatter and persistent undo all work
normally, and the file survives restarts.
Each connection gets its own, so your working queries follow the database you are working on.
Shared SQL files often target a table that changes from one run to the next:
SELECT * FROM :raw_data WHERE created_at > now() - interval '7 days';Declare which names are variables, and dbsh.nvim asks for their value before running the query:
variable_patterns = { ":(raw_data)" },Each entry is a Lua pattern with one capture, which gives the variable name.
This mirrors the User Parameters setting of JetBrains DataGrip, where the same
declaration reads :(raw_data). The default is an empty list, so no existing SQL
file changes behaviour until you opt in.
The prompt doubles as the input field: the list holds the values you already used
for that variable, most recent first. <CR> takes the highlighted entry, or the
text you typed when nothing matches. <C-e> always takes the typed text, which is
how you enter a value that happens to be a substring of an existing one. Dismissing
the prompt cancels the whole run — a half-parameterised query never reaches the
server.
Without telescope.nvim, the prompt degrades to vim.ui.input, prefilled with the
most recent value.
Values are remembered per connection in
<stdpath("data")>/dbsh/vars/<connection>.json, deduplicated and capped at 50 per
variable. Table and schema names only make sense on the database they came from,
which is the same reasoning the scratchpad follows. Edit or delete that file by
hand if you need to clean it up.
The plugin does not rewrite your SQL. It emits \set directives ahead of the
query and lets psql interpolate :name itself. Two consequences worth knowing:
- Nothing is ever substituted inside a quoted literal, because
psqldoes not interpolate there. That is the guarantee DataGrip has to offer as a checkbox. - Only
:name-shaped variables can work. A bare token with no colon is not somethingpsqlcan interpolate.
A wide pattern catches everything at once:
variable_patterns = { ":([%w_]+)" },Be aware it also matches things you did not mean: a::text yields a variable
named text, and '12:30' yields 30. Naming each variable explicitly, as in
":(raw_data)", avoids this entirely and is the recommended default.
:DbExportCSV writes a query result to a .csv file. What it exports depends on
where you run it:
- from the
__DBSH__buffer — the query currently displayed, re-run; - over a visual selection — the selected lines;
- anywhere else — the SQL paragraph under the cursor.
Queries holding SQL variables are resolved before the export, so :DbExportCSV
asks for their values first and the destination path second.
The suggested path is <export_dir>/<YYYYMMDD>_<connection>.csv, or
..._scratchpad.csv when no connection is selected. It gains a _1, _2 suffix
when the file exists, and the prompt lets you edit it. An existing file is never
overwritten: the suffix is applied again to whatever path you confirm.
Under the hood it runs COPY (...) TO STDOUT WITH (FORMAT CSV, HEADER). That needs
no superuser right, keeps multi-line queries valid, and the file is written by
Neovim on the machine you are sitting at.
yank_csv() turns a visual selection of the rendered table into CSV:
Vtakes every column of the selected rows;<C-v>takes only the columns the block covers, which also handles the single-cell case.
Character-wise v is rejected with a message rather than guessed at — the meaning
of a character selection across a drawn table is ambiguous.
Frame and separator lines are ignored, so a selection that overshoots the table still yields clean CSV. Values are escaped per RFC 4180: a field holding the delimiter, a quote or a newline gets quoted, and inner quotes are doubled.
The text goes to the default register and to the clipboard registers your
'clipboard' option asks for — + under unnamedplus, * under unnamed. So
vim.opt.clipboard = "unnamedplus" is enough to paste it into a spreadsheet.
For a single cell without selecting anything, yank_cell() grabs the one under the
cursor.
(NULL)versus empty. A CSV yank copies what is on screen, so a null cell reads(NULL). A CSV export goes throughCOPY, which knows the real SQL null and writes an empty field. Same data, two honest representations.
A password prompt appears. ~/.pgpass is not chmod 600, or has no line
matching this host, port, database and user. PostgreSQL ignores an over-permissive
file without saying so.
:DbTables says telescope is required. Telescope is optional but needed for
the pickers. Queries still work without it.
yank_csv reports success but nothing pastes. Your 'clipboard' sends puts
through +. This is handled since the CSV yank honours 'clipboard' — make sure
you are on a current version.
Pressing <localleader>r over a selection mangles the buffer. The prefix is
unmapped in visual mode, so Vim's r takes over. See Keymaps.
A query hangs. :DbCancel kills it. query_timeout caps it automatically.
Note that killing psql does not repair a degraded SSH tunnel underneath.
- Replace
harrisoncramer/psqlormzarnitsa/psqlwithedjubert/dbsh.nvim. - Delete your
~/.config/nvim/lua/psql/<name>.luafiles. Move theirhost,port,databaseandusernameinto theconnectionstable passed tosetup(), and droppasswordandhash_algorithm. - Create
~/.pgpass,chmod 600it, and add a line per connection. :PSQL <name>is gone — use:DbConnections.
The parts you knew are unchanged: the result buffer is still __DBSH__, and
paragraph, line and selection queries behave the same.
The plugin was renamed to make room for other database shells. The break is
clean: there is no deprecated :PSQL* alias and no psql.* Lua namespace.
- Replace
require("psql")withrequire("dbsh")in your config. - Rename your commands:
:PSQLTablesbecomes:DbTables, and so on. - Move your stored data, which is not migrated automatically:
mv ~/.local/share/nvim/psql ~/.local/share/nvim/dbshmake testTests run on mini.test, cloned into
deps/ automatically. Set MINI_TEST_DIR if you keep it elsewhere.
The modules are small and single-purpose, which is what makes them testable:
| Module | Responsibility |
|---|---|
config.lua |
declared connections, current selection, generation counter |
exec.lua |
asynchronous psql invocation, execution slots, cancellation |
results.lua |
the __DBSH__ buffer |
introspect.lua |
catalog queries and their parsing |
scratch.lua |
per-connection scratchpad file |
csv.lua |
table parsing and CSV serialization, pure functions |
export.lua |
destination paths and the COPY statement |
telescope/pickers.lua |
the four pickers |
init.lua |
public API and user commands |
- mzarnitsa/psql — the original plugin.
- harrisoncramer/psql — the fork this one grew from.








