Query PostgreSQL from Neovim without leaving your editor, without blocking it,
and without exposing a password to psql.
require("dbsh").setup({
connections = {
analytics = {
type = "postgres",
host = "<host>",
port = 5432,
database = "<database>",
username = "<username>",
},
},
default = "analytics",
})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 | context scoped to the active buffer |
| Schema browsing | none | paged contexts and PostgreSQL object catalogs |
| Ad-hoc queries | scratch buffer, lost on exit | named scratchpads with independent metadata |
| 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 | mini.test, run with 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.
psql is always invoked with -w and resolves credentials from ~/.pgpass;
dbsh never puts a password or PGPASSWORD in its process list. Managed PgLS is
an opt-in exception described in Language server: its
in-memory session secret is sent only through the PgLS RPC, never to psql.
Contexts, paged catalogues and relation inspection all use the same picker surface. Selecting a relation previews rows; structural objects open a read-only definition buffer.
Every picker titles itself with the context it reads from —
dbsh tables - heimdall - dashboard: the connection, then the database when it
is not the connection's namesake, then the schema in scope.
- Neovim 0.10+ — the plugin uses
vim.system,vim.fn.getregionandvim.fs.joinpath psqlon yourPATHsnowon yourPATHwhen using Snowflake profiles- telescope.nvim — optional; every picker degrades to a clear message without it, and queries work fine
- postgres-language-server — optional; only needed if you enable an LSP mode, see Language server
lazy.nvim
{
"edjubert/dbsh.nvim",
dependencies = { "nvim-telescope/telescope.nvim" },
config = function()
require("dbsh").setup({
connections = {
analytics = {
type = "postgres",
host = "<host>",
port = 5432,
database = "<database>",
username = "<username>",
},
},
default = "analytics",
})
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 forpsql, 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 = {
analytics = {
type = "postgres",
host = "<host>",
port = 5432,
database = "<database>",
username = "<username>",
},
},
default = "analytics",
connect_timeout = 5,
query_timeout = 30000,
preview_limit = 10,
catalog_page_size = 200,
credentials = {
cache_ttl_ms = 900000,
},
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
safety = { mode = "confirm" }, -- "confirm" or "off"
progress = {
enabled = true,
delay_ms = 300,
summary_width = 60,
},
lsp = {
mode = "off", -- "off", "external", or "managed"; 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. |
catalog_page_size |
200 |
Number of catalog objects fetched per page. |
credentials.cache_ttl_ms |
900000 |
In-memory credential cache lifetime for backends that require a password command. |
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, and takes the cursor when a query starts -- unfocused it could neither be scrolled nor closed; q or <Esc> closes it and hands the cursor back. A split never takes the cursor: the point of it is to keep typing. Output arriving never moves the cursor either way. |
variable_patterns |
{} |
Lua patterns (one capture each) naming SQL variables to prompt for. See SQL variables. |
safety |
{ mode = "confirm" } |
Confirms mutating or ambiguous SQL. Use { mode = "off" } to disable the ergonomic guardrail. |
progress |
{ enabled = true, delay_ms = 300, summary_width = 60 } |
Reports operations while they run. delay_ms is how long dbsh waits before displaying anything; 0 displays immediately. summary_width truncates the SQL excerpt. See Progress indicator. |
lsp |
{ mode = "off" } |
Disable PgLS by default. Choose external for a user-owned client or managed for dbsh-owned PostgreSQL clients. |
There is deliberately no password field required for SQL execution. psql
always uses ~/.pgpass. Managed PgLS alone needs an in-memory password on
its PostgreSQL profile, usually sourced from an environment variable as shown
below; dbsh never passes that value to psql or persists it.
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
<host>:<port>:<database>:<username>:<password>
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.
Snowflake profiles use the snow CLI only. They are structured profiles: do not
use a JDBC URL, a Snow CLI named connection, or a literal password.
require("dbsh").setup({
connections = {
warehouse = {
type = "snowflake",
host = "<account-host>",
port = 443,
username = "<username>",
authenticator = "<native-sso-authenticator-url>",
role = "<role>",
warehouse = "<warehouse>",
database = "<database>",
schema = "<optional-schema>",
password_command = {
"security",
"find-generic-password",
"-s",
"dbsh.snowflake.<profile>",
"-w",
},
},
},
default = "warehouse",
})password_command is an argv array, never a shell string. Its output is held
only in memory and passed only as SNOWFLAKE_PASSWORD to the child snow
process. dbsh caches it for 15 minutes by default, clears it on VimLeavePre,
and invalidates it before one retry after a recognized authentication failure.
Every Snowflake query receives the effective role, warehouse, database and
schema as CLI flags. Manually typed USE statements affect only that command;
they do not update dbsh's context. Raw catalog output uses JSON_EXT: scalars
become cells, null becomes (NULL), and complex values are compact JSON.
Snowflake catalogs include roles, warehouses, databases, schemas, relations,
functions, sequences, stages, file formats, streams, tasks and pipes. Catalog
filters are literal and paged. dbsh's confirmation prompt remains an ergonomic
guardrail; Snowflake role permissions remain the security authority. Run
make snowflake-smoke to verify a private non-mutating connection locally.
The MongoDB backend drives mongosh. Queries are JavaScript, not SQL.
connections = {
atlas = {
type = "mongo",
srv = true,
host = "<cluster>.mongodb.net",
username = "<username>",
database = "<database>",
proxy = { host = "127.0.0.1", port = 1080 },
password_command = { "security", "find-generic-password", "-s", "dbsh.mongo", "-w" },
},
}dbsh does not open the tunnel; run ssh -D 1080 -N <bastion> yourself. The
password is injected in memory into a script delivered on standard input, never
argv or disk. Results are documents, not a table: :DbYankCell and
:DbYankCSV refuse. Catalog browsing, table preview, and CSV export are not
implemented for MongoDB yet.
postgres-language-server validates SQL against a real database: it will tell you a column does not exist before you run the query. dbsh supports three explicit modes:
require("dbsh").setup({
connections = {
local_db = {
host = "127.0.0.1",
port = 5432,
database = "app",
username = "app",
password = vim.env.PGAPP_PASSWORD,
search_path = { "extensions", "public" },
},
},
default = "local_db",
lsp = {
mode = "managed", -- "off", "external", or "managed"
command = { "postgres-language-server", "lsp-proxy" },
client_pool = {
strategy = "immediate", -- "immediate", "idle", or "session"
idle_timeout_ms = 30000,
},
notifications = { failures = true },
},
})offis the default and performs no LSP work.externalpreserves the original integration with a client you start through lspconfig, Mason, or another plugin. dbsh never starts, stops, attaches, or detaches that client.managedis PostgreSQL-only. dbsh starts and owns PgLS clients for eligible dbsh SQL contexts, then retires them according to the selected pool strategy.
lsp.enabled = true remains accepted as a deprecated alias for
lsp.mode = "external" and warns once. false maps to off. An explicit
mode always wins over the old boolean.
External mode sends the existing password-free
workspace/didChangeConfiguration delta and invalidates/warm-ups PgLS's schema
cache after successful queries. It never copies dbsh values into the
user-owned client's settings table.
A user-owned PgLS client has only one effective configuration. With distinct
buffer contexts, last-synchronized context wins: changing one buffer can
change diagnostics for another. Use :DbLspStatus to see the last public
context dbsh synchronized, or use managed mode when contexts need isolation.
Managed mode uses one client per public context key: PostgreSQL connection
identity, effective database, project root, and the resolved search path
(selected schema first, then the configured search_path, deduplicated).
For ordinary file buffers, the project root is discovered from the nearest
postgres-language-server.jsonc, then from the nearest Git root. An explicit
scratchpad project root keeps precedence over this discovery.
Changing one buffer's context detaches only that buffer from its stale client;
other buffers retain their references.
immediate stops a client when its final buffer detaches. idle waits for
idle_timeout_ms and cancels that timer if a buffer reattaches. session
retains the client until Neovim exits.
The password remains in dbsh memory and is sent only in the
pgls/set_configuration_overrides session RPC. It is never put in the spawned
command, environment, public client key, status output, notification, or
persisted dbsh state. If starting or configuring PgLS fails, SQL execution
remains unaffected; dbsh stores a sanitized per-context error and notifies once
by default. Set lsp.notifications.failures = false to suppress that
notification.
:DbLspStatus reports the active public managed record or, in external mode,
the user-owned/shared limitation and its last synchronized public context.
Managed mode requires PgLS 0.26.0 or newer. It uses the official sticky configuration overrides introduced by PR #809; no patched PgLS binary is required.
| Command | Description |
|---|---|
:DbConnections |
pick a connection |
:DbTemp |
open the named scratchpad picker |
:DbObjects |
choose an object catalog |
:DbDefinitions |
choose a definition buffer for the active context |
:DbToggleDefinition |
show or hide the current/last definition |
:DbRefreshDefinition |
re-fetch the current definition using its captured context |
:DbCloseDefinitions |
close visible definition windows for the active context |
: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 |
Catalog commands are declared from the union of installed backend contracts, then dispatched against the backend of the active buffer. A command that is not available for that buffer explains why instead of changing another buffer's context. PostgreSQL provides:
| Command | Description |
|---|---|
:DbDatabases |
pick a database on the current server |
:DbSchemas |
set the active schema context |
:DbRelations |
browse tables, views, materialized/partitioned and foreign tables |
:DbTables |
compatibility relation browser without foreign tables |
:DbColumns, :DbIndexes, :DbConstraints, :DbSequences |
browse structural objects |
:DbFunctions, :DbTypes, :DbPolicies, :DbTriggers, :DbExtensions |
browse remaining PostgreSQL object kinds |
Catalog searches are literal server-side filters, not arbitrary SQL. They use deterministic keyset pagination; choose Load more to fetch the next page.
Snowflake provides :DbRoles, :DbWarehouses, :DbDatabases, :DbSchemas,
:DbRelations, :DbFunctions, :DbSequences, :DbStages,
:DbFileFormats, :DbStreams, :DbTasks and :DbPipes. Context commands
and object catalogs share these names where selecting the value is meaningful.
:DbTables remains available for PostgreSQL users migrating from older dbsh or
psql.nvim configurations. It keeps the historical table/view/materialized-view/
partitioned-table coverage. Prefer :DbRelations for the canonical relation
browser, which also includes foreign tables. Backend authors should use the
separate contexts and catalogs contracts; the former backend.levels
hierarchy is no longer part of the plugin contract.
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.
Every operation dbsh starts is reported while it runs: user queries, catalog pages opened from a picker, CSV exports, and definition buffers. Two surfaces carry it.
- The winbar of the window that will hold the output — the
__DBSH__result window for a query, the DDL buffer window for a definition. - A notification titled with the connection name. This is what covers catalog requests, which feed a Telescope picker rather than a window of their own.
⠹ executing SELECT * FROM public.users WHERE… 12s
The indicator is armed before the backend prepares its runtime, so it also covers
a Snowflake password command and its SSO round trip — usually the longest wait in
a session. When dbsh retries after a rejected credential, the indicator is
relabelled re-authenticating rather than restarted: the clock keeps running, so
a doubled wait is visible rather than silent.
Nothing is displayed during the first progress.delay_ms (300 ms by default). A
catalog page that returns in 80 ms should not flash an indicator nobody had time
to read. Set delay_ms = 0 to display immediately, or
progress = { enabled = false } to turn the whole thing off.
With nvim-notify, Snacks.notifier or Noice installed, one notification is
replaced in place until the operation ends. Plain vim.notify cannot replace a
notification, so dbsh degrades to two messages: one when the operation crosses
the delay, one when it ends. The winbar animates either way. No notification
plugin is required.
By default dbsh asks before SQL that mutates data/schema, changes privileges, is
ambiguous, or contains multiple top-level statements. Read-only SELECT, WITH … SELECT, SHOW, DESCRIBE, DESC, and EXPLAIN run immediately.
The prompt describes only the classifier reason; it never displays resolved
variable values. Choosing Cancel stops before dbsh asks for SQL variables or
starts a subprocess. This is an ergonomic guardrail, not authorization:
PostgreSQL permissions remain authoritative. Set safety = { mode = "off" } if
you do not want confirmations.
Manually typed SET ROLE, USE, or similar SQL affects only that invocation.
dbsh never infers a persistent context change from arbitrary SQL.
Connection and database/schema selections belong to the active buffer. Two SQL
buffers can therefore use different connections and schemas at the same time;
their queries, result buffers, process slots, catalog pages, and definitions do
not overwrite each other. :DbGlobalConnection changes only the fallback used
by buffers that have not been explicitly bound.
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 the schema context for the active buffer.:DbRelations/:DbTables— browse relations with a schema scope or all schemas.DbTableskeeps the compatibility object set.- Structural catalogs — columns, indexes, constraints, sequences, routines, types, policies, triggers, and extensions are direct commands as listed above.
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.
Press <C-i> on a relation to open its inspector: Columns, Indexes,
Constraints, Triggers, Policies, Dependencies, and Definition. Structural
objects open a read-only DDL buffer. Introspection and DDL execution use
independent process slots, so neither cancels a user query.
There is one read-only DDL buffer per object identity and effective public context. Reopening the same object focuses it; opening a different object while reading a definition uses a split, keeping the previous DDL visible.
Definition buffers retain the context captured at opening. Consequently,
:DbRefreshDefinition does not accidentally use a connection selected later in
another buffer. :DbDefinitions lists them for the active context and
:DbCloseDefinitions hides only visible windows; hidden buffers remain reusable.
The plugin drives a database shell as a subprocess; which shell it drives is a property of the connection:
connections = {
analytics = {
type = "postgres",
host = "<host>",
port = 5432,
database = "<database>",
username = "<username>",
},
}type defaults to "postgres". The implemented backends are postgres,
snowflake and mongo. 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. A backend may also compose its whole script, require standard-input
delivery, classify its own statements, and declare non-tabular output. Context selectors live in backend.contexts; paged object browsers live in
backend.catalogs. Everything else — result and definition buffers, the CSV
yank, scratchpads, variable prompts, and export file handling — is shared and
knows nothing about any particular database.
:DbTemp opens a picker of named scratchpads. Each scratchpad is a real .sql
file with persistent metadata: backend, optional connection, effective context
levels, and an optional project root. Your SQL LSP, formatter, and persistent
undo work normally, and scratchpads survive restarts.
Scratchpads do not share an in-memory global context. Opening two of them keeps their sessions independent, just like ordinary SQL buffers.
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 / context.lua |
static options and buffer-scoped runtime contexts |
exec.lua |
asynchronous SQL/argv invocation, session slots, cancellation |
results.lua / definitions.lua |
read-only result and DDL buffers |
catalog.lua / backends/*.lua |
generic paged contract and backend catalog queries |
scratch.lua |
named scratchpads and their metadata |
safety.lua |
pure conservative SQL confirmation classifier |
csv.lua |
table parsing and CSV serialization, pure functions |
export.lua |
destination paths and the COPY statement |
telescope/pickers.lua |
connection, scratchpad, catalog, and definition pickers |
init.lua |
public API and user commands |
- mzarnitsa/psql — the original plugin.
- harrisoncramer/psql — the fork this one grew from.








