README
¶
dbc — a TUI database client, scriptable in Go
dbc is a terminal database client for Postgres, MySQL, SQLite, and
bytdb with a twist: instead of a
bespoke macro language, you script it in Go. Write a .go file — in
dbc's own script editor or yours — loop over parameters, run queries against
any configured connection, and export the results — CSV, Markdown, HTML,
JSON — to a file or straight to the clipboard. And when a query is slow,
explain it: the plan as a tree or a flame graph, the step that costs, and
what to do about it, on every engine — in the terminal or as an interactive
page in the browser. And when you would rather have a browser than a
terminal, dbc web serves the whole workbench there.
Build
go build -o dbc .
Or install a tagged release with go install github.com/rohanthewiz/dbc@latest,
through Cats (As a Cats plugin), or from the archives on
the GitHub Releases page. dbc version (or dbc --version) prints the version.
Tests
go test ./... runs the unit tests. Four opt-in suites need more than Go:
- Live databases (
db/live_*): setDBC_LIVE_PG_DSNand/orDBC_LIVE_MYSQL_DSNto a throwaway Postgres or MySQL. - dbc web in a browser (
web/e2e, a module of its own so go-rod never enters dbc's dependencies):cd web/e2e && DBC_E2E=1 go test -count=1 -v .It builds dbc, servesdbc webfrom a temporary HOME on two SQLite files, and drives a headless Chrome through it: sign-in, a run, the Tables list, Show columns, copies, switching, Disconnect, Refresh, the connection form, the Postgres in Docker dialog (opened, never started) and tabs across a reload. A JavaScript error on the page fails it.DBC_E2E_CHROMEnames the browser,DBC_E2E_HEADFUL=1shows it, andDBC_LIVE_PG_DSNadds the Postgres schema picker. - Postgres in Docker (
pgdocker):DBC_LIVE_DOCKER=1 go test -run LiveDocker -v ./pgdocker, with Docker running, starts a real container, connects through the connection it registers, restarts it, then removes the container and its volume.DBC_LIVE_DOCKER_PGpicks the version (default: the newest). Adbc-pg<version>container that is already there makes it skip rather than touch it. - The TUI in a terminal (
tui/e2e, also a module of its own):cd tui/e2e && DBC_TUI_E2E=1 go test -count=1 -v .It builds dbc, runs the TUI in a 140×45 pseudo-terminal on two SQLite files, and reads the screen through a VT emulator while it types and clicks: a run, completion, a table preview by double-click, a sort by header click, a connection switch and quitting.
Releasing
Fast-forward the release branch to main and push it
(git push origin main:release). The Release workflow runs the tests, bumps
the patch number in version/version.go and cats-plugin.toml, tags
v<x.y.z>, and publishes linux/darwin archives. For a minor or major release,
edit both files by hand in the commit you push and the workflow uses that
number as-is. Merge release back into main afterwards.
Configure connections
Copy dbc.example.toml to ./dbc.toml (or ~/.config/dbc/config.toml):
# scripts_dir = "scripts" # default ~/.config/dbc/scripts; relative = beside this file
max_rows = 1000 # rows fetched from the server
max_display_rows = 2000 # rows the results table draws (0 = all)
result_tabs = 10 # result tabs per connection, per query tab (1–50; see Result tabs)
conn_idle_timeout = "1h" # close pooled connections idle this long ("0" = never)
connect_timeout = "5s" # give up opening a connection after this ("0" = no limit; Ctrl+K cancels)
default_connection = "local-pg"
[[connection]]
name = "local-pg"
driver = "postgres" # postgres | mysql | sqlite | bytdb
dsn = "postgres://postgres:${PGPASS}@localhost:5432/mydb?sslmode=disable"
Two of the four drivers are embedded — there is no server to start, and the DSN is just a file:
[[connection]]
name = "scratch"
driver = "sqlite"
dsn = "file:scratch.db"
[[connection]]
name = "notes"
driver = "bytdb"
dsn = "notes.bytdb" # created on first open
bytdb is an embedded relational store
over an ordered key-value engine, with a Postgres-flavored dialect ($1
placeholders, pg_catalog introspection) and serializable transactions. dbc
opens it in-process, so Ctrl+T, cancellation, exports, and scripts work
against it exactly as they do against the other three. Unlike SQLite it has no
in-memory mode, so the DSN is always a real path.
Env vars in DSNs are expanded, so secrets can stay out of the file. A DSN
referencing an unset var warns by name at startup (a typo'd ${PGPASS} will
not silently become an empty password). Duplicate connection names and a
default_connection that names no connection are rejected at load.
TLS
Postgres and MySQL connections take TLS settings of their own, spelled the same way for both engines:
[[connection]]
name = "prod"
driver = "postgres" # or mysql
dsn = "postgres://app:${PGPASS}@db.example.com:5432/app"
tls = "verify-full" # disable | prefer | require | verify-ca | verify-full
tls_ca = "certs/ca.pem" # CA to verify against (blank: the system's trusted CAs)
tls_cert = "~/certs/client.pem" # client certificate, if the server asks for one
tls_key = "~/certs/client.key" # its private key (PEM)
tls_key_password = "${PGKEYPASS}" # only for an encrypted key: the env var holding its passphrase
The modes are libpq's sslmode names, and mean the same on both engines:
tls |
Encrypted | Server certificate checked |
|---|---|---|
disable |
no | — |
prefer |
if the server offers TLS (else plaintext) | no |
require |
yes | no; with tls_ca, the chain (as verify-ca) |
verify-ca |
yes | signed by the CA; host name not checked |
verify-full |
yes | signed by the CA and issued for the host |
Only verify-full stops a man in the middle. Leave tls out and TLS is up to
the DSN, as before (sslmode=… for Postgres, tls=… for MySQL). Set, it wins
over the DSN: tls = "verify-full" beside a copied ?sslmode=disable still
verifies. For MySQL this is the only way to name a CA file or a client
certificate: its DSN cannot. Paths may use ~ and ${VAR}; a relative path
is relative to the config file. A misspelled mode, or a tls_ca with no
tls, is rejected at load rather than silently connecting without TLS. A
CA, certificate or key that cannot be read fails the connect and names the
file.
An encrypted client key needs tls_key_password, which is always an
environment variable reference ("${PGKEYPASS}" or "$PGKEYPASS"), never the
passphrase itself: a literal is rejected at load, so the passphrase stays out
of the config file, the saved-connections file and the browser. The variable
is read when the connection opens; unset, the connect fails and names it. Both
encodings OpenSSL writes are read, on both engines: legacy PEM encryption
(Proc-Type: 4,ENCRYPTED, from openssl rsa -aes256 -traditional) and
PKCS#8 (ENCRYPTED PRIVATE KEY, OpenSSL 3's default) with PBKDF2 and AES-CBC.
A key encrypted with scrypt or DES is refused with the openssl pkcs8 command
that re-encrypts it.
The same settings are in dbc web's connection form, and for a one-off
connection there are --tls, --tls-ca, --tls-cert, --tls-key and
--tls-key-password '${VAR}' (single-quoted, so the shell leaves it to dbc).
With no config at all, dbc starts on two built-in connections, one per
embedded engine, each seeded with the same cats table — so you can try
everything immediately, and compare the two engines on the same query:
| Connection | Engine | Storage |
|---|---|---|
demo-bytdb |
bytdb | demo.bytdb in the OS cache dir (~/Library/Caches/dbc, ~/.cache/dbc) — persists between runs |
demo-sqlite |
SQLite | in-memory, fresh every run |
demo-bytdb is the active one out of the box. --demo sqlite (or
DBC_DEMO=sqlite) makes demo-sqlite active instead; either way both are in the
connections list, so Ctrl+L — or -c demo-sqlite headless — switches between them.
The flag is ignored once a config file exists, since a config names its own
default_connection.
If the bytdb demo cannot be opened — another dbc already holds the file, or the cache directory is not writable — it is dropped with a warning and dbc starts on the SQLite demo rather than failing.
The TUI
./dbc # mouse-first, with the AI assistant
dbc ▶ Run ■ Stop ◈ Explain ⧉ Copy ▾ ⤓ Export ⌕ History ƒ Scripts ▦ Tables ✦ Ask ● conn ▾
╭ Connections ╮╭ Query ─────────────────────────────╮╭ ✦ Copilot · model ▾ ─ ⟲ new ✕ ╮
│● local-pg ││ 1 SELECT … ││ ❯ why is this slow? │
╰─────────────╯╰─────────────────────────────────────╯│ … │
╭ Tables · 12 ╮╭ Results · 8 rows · 1.2ms ───────────╮│ ┌ sql ─── ⤓ insert ⧉ copy ┐ │
│ cats ││ │ id │ name │ … ││ [✓] with: query, 10 rows │
│ owners │╰─────────────────────────────────────╯│ ask about the query… ⏎ send │
╰─────────────╯╭ Log ───────────────────────────────╮╰───────────────────────────────╯
● conn │ 8 rows in 1.2ms ^R run ^K stop ^A ask ^E export
A toolbar, a connections and tables sidebar, the SQL editor, the results grid, a log, and — when you open it — the AI assistant. Everything has a mouse gesture and every gesture has a key.
Row counts in the tables list are off until you ask for them: tick the
rows box in the Tables heading in dbc web, or press # in the
terminal's tables list (also on its right-click menu; the pane's title then
reads Tables · 12 · rows). Each count is an exact count(*) — never the
database's statistics estimate, which can be far off — so it reads every
table, and on a big schema it can take a while; the box's label pulses
until the numbers land. They show as cats (8) in dbc web, where hovering
says "cats with 8 rows", and a right-aligned 8 in the terminal. The switch
is per query tab, and unticking it cancels a counting still running.
The same list can show the schema's stored functions and procedures
instead of its tables, on Postgres and MySQL: click Routines beside
Tables in the heading in dbc web, or press f in the terminal's
Tables pane (the title then reads Routines · 3; f again goes back). The
list is the schema the tables are of — pick another schema and its
routines come with it — and each row shows its kind (fn, proc, agg,
trigger, window), with the arguments of overloads that share a name.
Enter or a double-click on one shows its DDL: the CREATE statement as
the server renders it (pg_get_functiondef, MySQL's SHOW CREATE FUNCTION|PROCEDURE), colored as SQL, with Copy (y) and Insert
into editor (i, at the caret — nothing in the editor is replaced). On
Postgres an aggregate has no such rendering and says so. Like the row
counts, the switch is per query tab, and the routines are read only while
it is on; a CREATE FUNCTION run in dbc relists them, as a CREATE TABLE
does the tables.
On Postgres the whole list is counted in one statement (query_to_xml runs
a count(*) per table server-side); if that fails — a table you can see
but not SELECT from, say — each table is counted on its own, so only that
one goes without a number. MySQL, SQLite and bytdb count table by table. A
single count gives up after 30 seconds and the whole counting after two
minutes; what is not counted by then shows no number. Views are not
counted.
Counts load after the list, never holding it up, and are cached per
connection for two minutes. While they are on, a run that may have changed
rows (an INSERT, DELETE, TRUNCATE, DDL, a COMMIT, a script) recounts
the list straight away, past the cache; a SELECT, SET or BEGIN does
not. In dbc web every query tab on that connection with counts on, in any
window, is recounted, not just the one that ran it; writes made by other
clients show at the next connect. A write inside an open transaction shows
once it is committed, since the counts are read outside your session.
When a connection's tables span more than one schema, dbc web puts a
schema filter under the Tables heading, so a big catalog can be narrowed to
the one you are working in. Click it (or tab to it) for the list of schemas,
each with its table count; type to narrow the list — names starting with
what you typed come first — then Enter or a click picks one, and Enter
again moves to its tables. Tab does both at once: it picks the schema
typed to (or arrowed to) and moves on to its tables, so sal Tab ord
Enter previews sales.orders. Esc drops the typing; emptying the box and
picking "all schemas" (or its ×) shows everything again. The heading then
reads · 12 / 340. The pick is remembered per connection, across reloads
and windows. The heading and the filter stay put while the tables scroll.
Under the pickers, a table box finds a table in the list: type part of
its name and the list narrows to the matches, the ones starting with it
first, with the first selected. ↑/↓ move the selection while you keep
typing, Enter previews the selected table, and Esc clears the find (a
second Esc leaves the box). Typing schema. matches qualified names.
Tab goes on to the selected row, where c and e work. Typing on a row
starts a new find with what you type, except for c and e, which are
that row's own keys; / goes back to the box.
On Postgres the sidebar is loaded a level at a time, so connecting to a big server costs what the sidebar shows, not what the server holds:
- db: the server's databases (those you may
CONNECTto; templates left out). Picking one switches the tab to it, using the same host, credentials and TLS. A Postgres connection is bound to one database, so this opens a connection named<conn>/<database>(e.g.ProdDr/analytics). It shows in the header, and tabs, history and the schema pick remember it like any connection. Moving off it closes its connection to the server once no tab is on it, so browsing a server's databases does not leave one open per database visited. Picked withTab, the keyboard moves on to the schema box once it is connected. - schema: the database's schemas with their table counts, empty ones
included. A connect opens on the first schema on
search_pathif it has tables (else the first schema that does), or on the schema last picked for that connection. "all schemas" is offered while the database has at most 5,000 tables. - tables: the picked schema's tables, read from
pg_catalog. That includes ones your role holds no grant on (as psql's\dtdoes), whichinformation_schema.tableswould hide, making the schema look empty. Names are schema-qualified whenever the database has more than one schema, so a preview works off thesearch_path.
MySQL has the db level too: the server's databases you hold a privilege
in (all of them with SHOW DATABASES), less the server's own
information_schema, mysql, performance_schema and sys unless your
DSN opened one. Picking one opens <conn>/<database> in the same way,
TLS settings and all. A MySQL database is its one schema, so there is no
schema level: the picked database's tables are listed whole. A DSN that
names no database (user:pw@tcp(host:3306)/) lists no tables until you
pick one.
The terminal UI has the same two levels, as two rows at the top of its
Tables pane: ⛁ analytics ▾ and ◫ sales · 40 ▾ (on MySQL, the first
only). Click one, or press d or s in the Tables pane, for a list you
can type into to narrow; Enter or a click picks. The schema picked is
remembered per connection, across restarts too (in
~/.config/dbc/schema-picks.json).
The assistant matches table names in the loaded schema, and also
schema.table names in any other schema of the database. The ERD
button diagrams the picked schema. No table list is cut at max_rows, which applies only to results you ask
for (Ctrl+T included). SQLite and bytdb list their tables whole, as before,
with no database picker.
Mouse
| Where | Gesture | Does |
|---|---|---|
| anywhere | click | focuses that pane — the keyboard follows the mouse |
| toolbar | click | Run, Stop, Explain, Copy ▾, Export, History, Scripts, Tables, Assistant; ● conn ▾ switches connection |
| connections | click | connects |
| connections | right-click (or the toolbar's ● conn ▾) |
Disconnect the active connection, Refresh it (read its databases, schemas and tables again, for what another client created or dropped — the session and any open transaction stay), then the list to connect to; on a row, Edit… and Remove… for a connection added in dbc (below), and Stop container on one Postgres in Docker made; Add a connection…; Postgres in Docker… (below) |
| tables | click ⛁ db ▾ / ◫ schema ▾ |
(Postgres; MySQL has ⛁ db only) pick another of the server's databases / another schema, from a list you can type into |
| tables | click / double-click / right-click | select / preview the first 100 rows / show columns, diagram it, diagram all tables, copy the ERD as Mermaid, insert name, copy name |
| tables | c on the selected table |
show its columns: its information_schema.columns rows (name, type, nullable, default, length) in the grid, ready to copy |
| tables | # |
show or hide each table's row count (an exact count(*) per table; off by default) |
| tables | e on the selected table |
diagram it and its neighbours (an ERD, below), saved as a PNG and opened |
| tables | f |
(Postgres, MySQL) list the schema's functions and procedures in place of its tables, and back |
| routines | Enter / double-click / right-click |
show its DDL (y copies it, i inserts it at the editor's caret) / the same / show DDL, insert name, copy name, show tables |
| editor | click, drag, double-, triple-click | caret, selection, word, line |
| editor | right-click | run, copy, cut, select all, undo, history, ask the assistant; go to definition, usages, rename; the consoles |
| editor | Ctrl+click |
go to where the alias or CTE under the pointer is declared |
| results | click, drag, shift-click | cell, rectangular range, extend |
| results | row number click | selects the row |
| results | header click | sorts by that column (again: descending; again: result order) |
| results | header border drag / double-click | resizes the column / fits it to its content (past the 40-cell auto-size cap) |
| results | header right-click | hide that column (or the selected range's); hidden ones are listed there to show again |
| results | double-click | inspects the value in full (JSON is pretty-printed) |
| results | right-click | copy cell / row / range / whole result as HTML table, Markdown, CSV, TSV, JSON; sort; transpose; hide, fit, show columns; export; ask the assistant |
| results, transposed | name click · row-number click · border drag | sorts by that column · selects the row · widens every row (or, beside column, the names) — see Transposed |
| any pane | wheel, shift+wheel | scrolls what is under the pointer, vertically / sideways |
| scrollbars | click, drag | jump, drag |
| pane borders | drag | resize the sidebar, the editor/results split, the log, the assistant — the sizes are kept for the next start |
‹ on Connections · › on the left edge |
click | fold the sidebar away · bring it back (Ctrl+B) |
| assistant | ⤓ insert on a code block |
puts that SQL in the editor at the caret |
| results title | click Results / ◈ Plan |
switches the results pane between the grid and the plan |
| result tabs (the results pane's bottom border) | click · right-click | shows that result · rerun its query, pin or unpin, share with the assistant, close, close the unpinned ones (see Result tabs) |
| ↻ after the result tab on screen | click | reruns its query into that tab, as r does (a statement that writes asks first) |
| log title | click ⧉ copy / ✕ clear |
copies the connection's log to the clipboard / empties it |
| plan | click / double-click / right-click | select a step (fold it) / step menu: copy, zoom the flame graph, ask the assistant |
| plan | ▸/▾ beside a step |
folds or unfolds it |
| plan | chips | Tree · Flame · Insights, size by time / cost / rows, ▶ Analyze, ↗ Browser, ⧉ Copy |
| flame graph | click / double-click | select a step / zoom into it (click the breadcrumb to zoom out) |
| insights | go to step · ⧉ copy · ⤓ insert |
show the step · copy the suggested SQL · put it at the end of the editor |
Mouse reporting takes the terminal's own text selection away, so every pane that shows text has its own copy. To select raw terminal text anyway, hold Shift (most terminals) or ⌥ Option (iTerm2, Terminal.app) while dragging.
Keys
| Key | Action |
|---|---|
Ctrl+R |
Run the statement under the caret (or the selection); the gutter marks which |
Ctrl+Shift+R / Alt+R |
Run every statement in the buffer, in order |
Ctrl+X |
Explain the statement under the caret (or the selected one) — the plan opens in the results pane |
Alt+X / Ctrl+Shift+X |
Explain analyze: run it and measure every step (a write is rolled back or not run — see below) |
Ctrl+K |
Stop the running query or script, a connect still dialing — or the assistant's answer |
Ctrl+A |
Open the assistant / move between it and the editor |
Ctrl+E |
Export the result (format picker; file, or clipboard) |
Ctrl+O |
The scripts browser: Enter runs, e edits in $VISUAL/$EDITOR (checked when the editor exits), n new from a template, d duplicate, r rename, Del trash, / filter; the built-in examples and the trash are listed too (its title names the directory) |
Ctrl+P |
Query history of this database (Tab: every database) — filter, then Enter inserts (never runs) |
Alt+T · Alt+W · Alt+1…9 |
A new query tab · close the tab · go to tab N (see Query tabs) |
Alt+N · Alt+C |
A new console of the database · the database's next console (see Consoles) |
F12 · Shift+F12 · F2 |
(editor) On an alias, a CTE name or a column the query names: go to its declaration · mark its uses (again: the next one) · rename it (see Go to definition, usages and rename) |
Ctrl+Space |
(editor) Suggestions at the caret — they also open by themselves after ., :: and two letters of a word (see below) |
Ctrl+T |
List the tables and views on the active connection |
Ctrl+L |
Jump to the connections list |
Ctrl+B |
Fold the sidebar away / bring it back (also the ‹ on the Connections box and the › left on the edge) |
F1 · ? |
Every key, in a dialog (? outside the editor and the assistant's input) |
x |
(connections) Disconnect without picking another connection. If the session may hold a transaction, dbc asks first, because disconnecting rolls it back |
r |
(connections) Refresh the active connection: read its databases, schemas and tables again, on the schema the list shows now. The session, and any transaction open on it, stays; if the tables cannot be read, the old list stays up |
a · + · e |
(connections) Add a connection · a menu of Add a connection… and Postgres in Docker… · edit the one under the cursor (see Adding connections) |
d / s |
(tables, Postgres; d on MySQL too) Pick a database / a schema |
Ctrl+G |
(inside Cats) Hand the statement to an agent in another pane |
y / Y / c |
(results) Copy the cell or range / the row / open the copy menu |
< / > / = |
(results) Narrow / widen the column / fit it to its content |
- / + |
(results) Hide the column (or the range's columns) / show every hidden column |
Enter |
(results) Inspect the value under the cursor |
t |
(results) Transpose: each row a column, each column a line — copies and exports follow; t again turns it upright |
p |
(results) Switch between the result grid and the plan |
{ / } |
(results, plan) The previous / next result tab of the connection (see Result tabs) |
P · x |
(results, plan) Pin the result tab (a run then opens a new one) or unpin it · close it |
r |
(results, plan) Rerun the result tab's query into that same tab, pinned or not (a statement that writes asks first); or click its ↻ |
S |
(results, plan) Share the result tab with the assistant, or stop sharing it (s too; needs ai_rows — see Sharing a result) |
[ / ] |
(results) After a script that showed several results (s.Show): the previous / next one within its result tab — also a click on its number in "Result 1 · 2 · 3" on the results title |
z |
(results) Give the results pane the whole column / give it back |
y · x |
(log) Copy the connection's log · clear it |
Tab / Shift+Tab |
Cycle focus through the panes |
Esc |
Close a dialog or menu |
Ctrl+C |
Stop what's running; quit when idle (a query running in another tab keeps it from quitting — Ctrl+Q quits anyway) |
Ctrl+Q |
Quit |
The editor is a code editor, not a text box: it keeps the indent on
Enter, highlights SQL (keywords, strings, numbers, comments, parameters —
from the same scanner that splits statements, so what looks like a string is
one), and undoes typing a word at a time (Ctrl+Z, redo Alt+Z). Long lines
scroll sideways rather than wrap, so a click lands exactly where you point.
In terminals that deliver the kitty keyboard protocol, ⌘E, ⌘P, and
⌘G are equivalents for export, history, and handing to an agent.
Adding connections in the terminal
a in the Connections pane, or Add a connection… in its right-click
menu or in the menu + opens there (beside Postgres in Docker…), opens
the same form as dbc web's + (see
the browser workbench): name, driver,
Enter as Fields (host, port, user, password, database and options —
or a file for SQLite and bytdb) or the whole DSN, TLS for Postgres and
MySQL, and whether the assistant may see result rows. Tab/Shift+Tab (or
↑/↓) move between the controls, ←/→ or Space change a choice,
and a click works everywhere. Test connection (Ctrl+T) dials, pings
and closes, and says how long it took or the driver's error, inside the
form; Enter saves, Esc cancels. A save adds the connection to
~/.config/dbc/connections.toml and connects to it.
Connections added this way — here or in dbc web; they share the file —
show a muted ✎ before their driver (· tls after it when TLS is on). e
on one, or Edit… in its right-click menu, opens the form filled in, the
password withheld as in the browser (leave it empty to keep it; the DSN as
text starts empty, meaning "keep the saved one"). Remove… asks first.
The connection dbc is on can't be renamed, given a new DSN, driver or TLS,
or removed — disconnect (x) or switch first — though whether the
assistant may see its rows can change at any time. A rename takes the
connection's schema picks along. The config file's connections are changed
in the file: their Edit and Remove rows say so.
Postgres in Docker
Postgres in Docker… in the connections menu — the TUI's and
dbc web's — starts a PostgreSQL server in a local container and adds the
connection to it, for a scratch database without installing anything but
Docker. Pick a version from the supported ones (PostgreSQL 18 back to 14;
each leaves the list at its end of life):
the TUI shows them as a menu, dbc web as a dropdown. Beside each is the
state of its container, if there is one (running on :5518, stopped).
A pick pulls postgres:<version> if Docker does not have it yet (about
150 MB; the log says so), runs it as the container dbc-pg<version>
(dbc-pg18) on 127.0.0.1 port 55<version> (5518, or any free port
when that one is taken), with its data in the Docker volume
dbc-pg<version>-data and a generated password. Once the server accepts
connections, dbc adds docker-pg<version> to connections.toml (as the
image's postgres superuser on its postgres database) and connects to it.
Picking the same version again starts the container if it was stopped, and
reuses it and its connection as they are. If the container was removed and
made again, the connection is pointed at the new one. A config-file
connection that already has the name is left alone, and the new one is
docker-pg18-2.
Stop container dbc-pg18 in the right-click menu of a connection made
this way stops its container (docker stop). It is refused while a query
tab is on the connection: disconnect first, as for Remove…. The data
stays, and picking the version again starts the container. dbc never
removes containers: docker rm dbc-pg18 does, keeping the data in the
volume, which the next pick reattaches. docker volume rm dbc-pg18-data deletes the data.
A container named dbc-pg18 that dbc did not create is refused, not used.
The docker CLI must be installed and running (Docker Desktop, colima,
Rancher Desktop…). dbc looks for it on PATH, then in the usual install
directories, so the macOS app finds it too.
Completion
The editor suggests what comes next from the connection's schema — its tables, columns, types and foreign keys, and its stored functions and procedures, read once per connection — and from the dialect's vocabulary:
| Where | Suggested |
|---|---|
FROM ▮ · UPDATE ▮ · INTO ▮ |
tables and views (the browsed schema's first), schemas, the statement's CTEs; then set-returning functions (SETOF, TABLE(…)) |
JOIN ▮ |
first the tables a foreign key links to the ones already named, as whole clauses: customers c ON c.id = o.customer_id |
ON ▮ |
the foreign key's condition between the table just joined and the others |
o.▮ |
the columns of the table or CTE alias o stands for (a SELECT * CTE included); public.▮ lists a schema's tables, and its routines of the kind the context calls for |
SELECT ▮ · WHERE ▮ · = ▮ … |
the columns of every table the statement names — written after the caret too, so SELECT ▮ FROM orders works — qualified when two share a name; then functions (built-in and the database's own) and keywords |
CALL ▮ |
the database's procedures |
DROP FUNCTION ▮ · ALTER PROCEDURE ▮ · EXECUTE FUNCTION ▮ … |
the functions, procedures or (for a trigger) trigger functions, by name |
INSERT INTO t (▮ |
t's columns |
::▮ · CAST(x AS ▮ |
type names |
| after a finished phrase | the clauses that can follow it: WHERE, GROUP BY, ILIKE, ON CONFLICT … |
Each column shows its type and keys, and each table its DDL. Postgres gets
its keywords and its everyday functions with their signatures — aggregates,
window, string, regex, date/time, JSON/JSONB, array, full-text search and
admin (pg_size_pretty, pg_terminate_backend …). bytdb gets Postgres's
keywords but only the functions and casts it implements (a test runs every
one against bytdb), so nothing it would answer with "unknown function" is
suggested. MySQL and SQLite get their own. Names are quoted where the engine needs it
("Orders", `order`), and keywords follow the case you type in.
A table goes in bare when its bare name finds it on the connection's
Postgres search_path (read with the schema), and schema-qualified
otherwise: with search_path = app, public, app.users goes in as
users, and a public.users it shadows as public.users. That is the
path a new connection gets from the role and database, not a
SET search_path run in your session.
Stored functions and procedures come from pg_proc on Postgres and
information_schema.routines on MySQL (SQLite and bytdb have none to
list). A function goes in as a call with the caret between its
parentheses, qualified by the same search_path rule as a table, and its
overloads are one suggestion whose detail lists every signature. What no
statement calls is left out: the built-ins of pg_catalog (the
vocabulary has those), and an extension's type I/O and index-support
functions (those taking internal or cstring). Trigger functions are
offered only after EXECUTE FUNCTION, procedures only after CALL (and
PROCEDURE/ROUTINE).
In the terminal the list opens by itself after . or :: and on the second
letter of a word, and follows your typing; Ctrl+Space asks anywhere. ↑/↓
choose, Tab picks, Enter picks only when that changes the text (at the
end of a word already typed in full it is a new line), Esc closes. In dbc
web it is Monaco's suggest list, fed by the same rules. Running DDL
(CREATE, ALTER, DROP …) or a script makes the next suggestion read the
schema again.
Go to definition, usages and rename
On a table's alias, a CTE's name, or a column the statement names itself,
F12 (or Ctrl/⌘+click) goes to where it is declared, Shift+F12 lists every use, and F2 renames it. All
three are also in the editor's right-click menu, in the terminal as in
dbc web.
In the terminal, F12 (or Ctrl+click) selects the declaration.
Shift+F12 marks every use in the statement (underlined) and names their
lines in the log; pressed again on the same name it moves the caret to the
next use, wrapping round, and Esc or any edit clears the marks. F2 opens
a prompt holding the current name: type the new one and Enter renames
every use as one edit, which one Ctrl+Z undoes. A refused rename keeps the
prompt open and says why under the field. They work within the
statement under the caret, and as SQL scopes names: a subquery that
aliases a table o keeps its own o when you rename the outer one, and a
correlated o.id inside it still follows the outer one. The new name is
quoted where the engine needs it ("Ord" on Postgres, `order` on
MySQL). A rename is refused if the name is already taken in the same query.
It works by scanning the text, not by parsing SQL, the same way
completion does. A table referenced without an alias is found (orders.id
goes to FROM orders) but not renamed: renaming the text would not rename
the table, only stop the query from finding it.
Columns are resolved when the statement itself names them:
- a CTE's or a derived table's output columns: in
WITH t AS (SELECT count(*) AS n FROM orders) SELECT t.n FROM t,t.ngoes toAS n, and renaming either renames both. A column list (WITH t(n) AS …,(…) AS d(n)) declares them instead. A column passed on by another CTE (SELECT n FROM t,SELECT * FROM t) is the same column, so a rename follows it through; - a select-list alias used in
ORDER BY(count(*) AS n … ORDER BY n).
A bare n (no t.) is resolved only where nothing else could own it:
every table in that part of the query must be a CTE or a subquery whose
columns are known, since one catalog table in scope could have an n too.
Catalog columns are not resolved: that needs the schema. A CTE column that
is just a table's column (WITH t AS (SELECT o.id FROM orders o)) is
found from t.id but not renamed, as a table is not; alias it
(o.id AS order_id) and rename that. A column rename is refused if it
would change what any other name in the statement refers to. An alias used bare, without a column (SELECT o FROM orders o,
MySQL's DELETE o FROM …), is left alone.
Explaining a query
Ctrl+X (or ◈ Explain) asks the database how it will run the statement
under the caret. The plan opens in a ◈ Plan tab beside the results; the
rows from the last run stay one click (or p) away.
╭─ Results · 5 rows │ ◈ Plan · 26.8 ms · ▲1 ─────────────────────────────────────────╮
│ ◈ postgres · analyzed · execution 26.8 ms · planning 0.4 ms was 131 ms → 26.8 ms ▼80% │
│ Tree Flame Insights ▲1 size by time cost rows ▶ Analyze ↗ Browser ⧉ Copy │
│ step rows self time % share │ ⋈ Hash Join │
│ ▾ ⇅ Sort (sum(o.total)) DESC 5 rows 16 µs 0% ▏ │ time 9.9 ms total │
│ └▾ Σ HashAggregate u.city 5 rows 5.6 ms 21% ██▏ │ rows 36,650 · est │
│ └▾ ⋈ Hash Join (o.user_id… 36,650 rows 9.9 ms 37% ███▊ │ Hash Cond (o.use… │
│ ├─ ▤ Seq Scan · orders o ▲ 50,000 rows 6.4 ms 24% ██▍ │ ▲ Full scan of … │
- Tree — every step with its rows, its own time (or cost) and share, a heat bar from green to red, and a marker on each step with a finding. The panel beside it shows everything the engine said about the selected step: time total and self, loops, rows actual against estimated, rows a filter threw away, buffers, spills, and every property in the engine's own words.
- Flame — an icicle graph: each step's width is its subtree's share, so a wide block with nothing under it is the step to look at. Double-click zooms in.
- Insights — findings in plain words, each with the step it is about, why
it costs, what to try, and — where there is one — the statement that does it
(
CREATE INDEX idx_orders_user_id ON orders (user_id);), with⧉ copyand⤓ insert. Nothing is run for you. The rules look for a full scan that keeps a sliver of the table, a scan repeated inside a loop or a correlated subquery, an index that fetches rows only to discard them, a planner estimate off by ×10 or more (named where it starts, not everywhere it cascades), a sort or hash that spilled to disk, a temporary index built on every run, and where most of the time goes. Small tables stay quiet.
Explain the statement again after adding an index and the headline compares
the two runs: was 131 ms → 26.8 ms ▼80%.
Alt+X explains analyze: the statement is run and every step measured.
What that means depends on the engine, and on whether the statement writes:
| Engine | Plan | Analyze a read | Analyze a write |
|---|---|---|---|
| Postgres | EXPLAIN (FORMAT JSON); a $1 statement gets a generic plan (16+) |
per-step time, rows, loops, buffers | inside a transaction dbc rolls back — or a savepoint, if you have one open, so your own work survives |
| MySQL | EXPLAIN FORMAT=TREE, else the classic table (MariaDB) |
EXPLAIN ANALYZE (8.0.18+) |
not run — the estimate, with a note |
| SQLite | EXPLAIN QUERY PLAN, plus each table's size |
the whole statement, timed | not run — the estimate, with a note |
| bytdb | EXPLAIN |
the whole statement, timed | not run — the estimate, with a note |
The explain runs on the editor's pinned session, so your SETs, temp tables
and open transaction shape the plan just as they would the next Ctrl+R.
Engines that report no costs (SQLite, bytdb) are sized by a labeled heuristic,
never by invented numbers.
Already typed EXPLAIN ANALYZE SELECT …? Ctrl+X unwraps it (keeping the
ANALYZE), and running an EXPLAIN with Ctrl+R opens its output in the Plan
tab too, with the raw rows still in Results.
y copies the plan as text, Y the engine's own output. The assistant, asked
about an explained statement, gets its plan and findings too.
The plan in your browser
Every plan can also be opened as an interactive web page — a bigger canvas than a terminal pane, and a file you can send to someone.
Opening it
| From | How |
|---|---|
| the TUI | ↗ Browser in the Plan tab, b, or right-click ▸ Open in browser (interactive) |
| the shell | dbc explain --open "SELECT …" — explain and open in one step |
| the shell, to keep | dbc explain -t html -o plan.html "SELECT …" — write the page, open it yourself |
The TUI and --open save the page as plan-<connection>-<date-time>.html
in a dbc-plans folder in the system temp directory, and the log (or, for
--open, the terminal) says exactly where. The file is readable only by
you, since a plan quotes your SQL.
Sharing it where a page won't go. A plan is also a PDF, a picture, or a Mermaid chart: the same graph, cards and heat colors, with the statement above and the findings below, drawn by dbc itself (no browser needed, so it works from the shell and on CI too).
| Form | For | From |
|---|---|---|
| a document, an email, a printer | web: ⤓ Save ▸ PDF · TUI: right-click ▸ Save as PDF · shell: -t pdf -o plan.pdf |
|
| JPEG / PNG | a chat, a ticket, a slide | web: ⤓ Save ▸ JPEG / PNG · TUI: right-click ▸ Save as JPEG / Save as PNG · shell: -t jpeg / -t png |
| Mermaid | a pull request or wiki (GitHub, GitLab and Notion draw ```mermaid blocks) |
web: m or ⤓ Save ▸ Copy as Mermaid · TUI: right-click ▸ Copy as Mermaid chart or Save as Mermaid chart (.mmd) · shell: -t mermaid |
The web's files are drawn as the view is showing them: sized by its metric,
in its light or dark, with the before/after comparison when there is one.
The shell's and the TUI's use dbc's dark palette, like the page — or light,
for a PDF headed to a printer: plan_theme = "light" in the config sets it
for both, and dbc explain --theme light|dark for one run. The PDF is a
picture on a page sized to fit the plan, so its text is not selectable; y
copies the plan as text.
What you see
- Header — the engine, how the plan was obtained (estimated or analyzed), execution and planning time, the EXPLAIN dbc sent, any notes (a rolled-back write, JIT time), and your statement, collapsible, with SQL highlighting.
- Graph (the default tab) — one card per step, laid out as a tree from
the final result at the top to the table reads at the bottom. Each card
shows the step, the table and index, its key condition, its rows, a
×N↑/×N↓badge where the estimate was off, and its own time (or cost) with a share bar. Card color runs from green to red with that share, so the expensive step is the one that stands out. Edges get thicker with the rows flowing through them and are labeled with the join side (Outer/Inner) or subplan. A step with a finding carries a severity mark; a step that never ran is dashed. - Flame — the same plan as an icicle: each block's width is its share of the total including everything under it, so a wide block with nothing below it is where the time goes.
- Side panel — everything about the selected step: time total and self, loops, rows actual against estimated, rows thrown away by a filter, cost, buffers, spills, every property in the engine's own words, and the findings about it.
- Insights — the same findings as the TUI, most serious first, each with its fix and, where there is one, the SQL with a Copy button. Click a finding to jump to its step.
The page opens on the step behind the most serious finding.
Using it
| Action | Does |
|---|---|
| click a card | select it and show it in the side panel |
| double-click a card, or its chevron | fold / unfold the steps under it |
| hover a card | highlight its path up to the result |
| drag the background | pan |
| mouse wheel | zoom toward the pointer (− / + / Fit buttons do the same) |
| Time · Cost · Rows · Shape ≈ | choose what colors, bars and flame widths measure |
| click a flame block | select it and, if it has steps under it, zoom into it; click the top block again, or a step in the breadcrumb above, to zoom back out |
| ◐ (top right) | switch between dark and light |
| Key | Does |
|---|---|
↑ / ↓ |
parent / first child |
← / → |
previous / next sibling |
Enter |
fold / unfold the selected step |
1–4 |
size by time, cost, rows, shape (as available) |
f |
fit the graph to the window |
g |
back to the graph tab |
Esc |
zoom the flame graph back out |
Good to know
- The page is one self-contained file: no server, no internet, nothing loaded from anywhere. Attach it to a ticket or a chat and it opens the same for whoever receives it.
Shape ≈appears for SQLite and bytdb, which report no costs or timings; it sizes steps by a labeled rough estimate, not measured numbers.- A very large plan (over 120 steps) opens folded below the fifth level, so the first view is a map; unfold what interests you.
- Add
#flameto the file's URL to open straight on the flame view. - It looks like dbc wherever it is opened, whatever your terminal's theme.
Entity-relationship diagrams
dbc draws a connection's schema as an ERD: a box per table with its columns,
types and key badges (PK, FK, UK, and PF for a primary-key column
that is also a foreign key), and a line per foreign key, from the child's key
column to the parent's, with crow's-foot ends:
| End | Means | When |
|---|---|---|
┼┼ at the parent |
exactly one | the key's columns are NOT NULL |
┼o at the parent |
zero or one | one of them is nullable |
>o at the child |
zero or many | the usual foreign key |
┼o at the child |
zero or one | the key's columns are unique in the child (1:1) |
Keys to the same parent column with different ends (one NOT NULL, one
nullable) attach a little apart on its row, one point per kind of end; keys
with the same end share it.
Parents sit to the left of their children, a column per level. A table with many children has them wrapped into a block. Lines to a far column run through the gaps between the boxes in their way; a gap is widened when more lines need it than it holds. Tables with no relationships are packed together underneath. Views are left out unless asked for, since they have no keys. Past 40 columns a table shows its key columns and then as many others as fit.
| Form | For | From |
|---|---|---|
| PNG / JPEG | a chat, a ticket, a slide, a design doc | web: ERD beside Tables, or a table's right-click ▸ Diagram around it (e), then ⤓ PNG / ⤓ JPEG · TUI: e on a table, or right-click ▸ Diagram around it / Diagram all tables (saved as a PNG and opened) · shell: dbc erd -t png / -t jpeg |
| Mermaid | a pull request, a README or a wiki (GitHub, GitLab, Notion and Obsidian draw ```mermaid blocks) |
web: ⧉ Copy as Mermaid (m) or ⤓ Mermaid · TUI: right-click ▸ Copy ERD as Mermaid · shell: dbc erd (-t markdown adds the fence) |
"Around" a table means the table plus every table one foreign-key hop away,
in either direction: what it references and what references it. The web
dialog can widen that to 2 or 3 hops or to everything it connects to, and
can include views. f there switches between fitting the window and actual
size. The shell and the TUI draw dark unless plan_theme = "light" or
dbc erd --theme light. The web's pictures follow the page's light or dark.
The schema is read fresh each time, from the engine's own catalog. On
Postgres and bytdb that is pg_constraint and pg_attribute. On MySQL it
is information_schema.key_column_usage, and on SQLite its pragma_*
functions. A Postgres partitioned table is drawn once: its partitions, and
the copies of its foreign keys Postgres keeps on each of them, are left
out (the sidebar still lists them). It runs on the connection pool, never inside your open
transaction, and it doesn't wait for a running query. Mermaid needs a plain
word as an entity name, so a table like public.cats on a multi-schema
connection becomes t1["public.cats"], an alias that needs Mermaid 10.5 or
later. A type such as numeric(10,2) becomes numeric(10_2), with the
original kept as the attribute's comment.
Copying results — including into Teams
Right-click a result (or ⧉ Copy ▾) and pick Table for Teams / Outlook /
Docs (HTML): dbc puts a real HTML table on the clipboard, with inline
styles, so it pastes into Teams, Outlook, Slack or Google Docs as a formatted
table rather than as markup. Where the table flavor cannot land — a target
that pastes only text, or a copy over SSH that goes through the terminal —
the paste is the same result as an aligned text table, never the HTML
source (that is Export → HTML page). Markdown, CSV, TSV and JSON are one
row down.
The scope is the selected range when there is one, or the whole result.
Transposed. t in the grid (or Transpose in its right-click menu;
⇄ Transpose on the results bar in dbc web) turns the result on its side,
as psql's \x does: each row becomes a column headed by its number, and
each column a line led by its name — the easy way to read, or share, one
wide record. Copies and exports follow what the grid shows, so Table for
Teams pastes the vertical table (names as row headers; a single row comes
out as column | value), y/Y copy the values in the grid's
orientation, and an export (Ctrl+E; ⤓ Export ▾ → HTML page in dbc web) writes it. Sorting, hiding and selecting work as they do upright; the
arrow keys follow the screen. Click a name to sort by that column, a row's
number to select it whole. Every record shares one width: drag any record's
border to widen them all (in the terminal < > step it and = fits it),
or the border beside column to widen the names; a double-click on either
fits it. The grid stays turned for the next result until t turns it back.
The rich copy needs the local clipboard (macOS, Windows, or Linux with an
X11 display or wl-copy on Wayland). Over SSH dbc falls back to the terminal's clipboard
protocol, which carries plain text only, and says so in the log.
AI assistant
✦ Ask (or Ctrl+A) opens a chat about the query in the editor and the
result in the grid. It runs GitHub Copilot by default through its official
language server, speaking the Agent Client Protocol, so dbc holds no
credential: the language server keeps it, shared with every editor that uses
it (ced, VS Code, Neovim). Signed in from one of those, dbc just works. If you
are not, the pane says so and offers ⎆ sign in to Copilot. It shows a
code to enter at github.com/login/device, copies it, and opens your browser
there. The question you asked goes out once GitHub confirms. It is GitHub's
device flow, so it works over SSH too: enter the code in a browser on any
machine. The transcript's right-click menu has Sign in to Copilot… at any
time, which also tells you which account is signed in.
npm install -g @github/copilot-language-server # if you do not have it
What is sent. Each question carries the statement under the caret and, if its last run failed, the error. It also carries the schema of the tables involved: every table the statement or the question names (up to eight) is described by its column names and declared types, read from the database's catalog when you press Enter, so the SQL that comes back uses your real columns. Schema is the table's shape, not its contents, so it goes on every connection. Result rows are not sent unless the connection allows it — rows are your data:
ai_context_rows = 10 # cap on rows per question (0 = none)
[[connection]]
name = "scratch"
ai_rows = true # this connection may send result rows
Without ai_rows only the column names go. The rows the assistant gets are
the grid's view, as a copy's are: in the header sort's order (and it is told
the order is yours, not the query's), without the hidden columns. It is
still told the hidden columns' names, so it can point out that the answer
may be in a column you hid. The chip above the input says
what the next question will carry (click it to send the question alone), and
the transcript records what each one did carry.
Sharing a result. By default a result goes with a question only when
it is the result of the statement under the caret. To ask about a
particular one — an earlier result tab, a result next to the query you are
fixing — share it: ✦ Share with the assistant on the result tab's or the
grid's right-click menu, or S in the results. Its tab is marked ✦, and
its result goes with every question asked on that connection, framed as
"shared result 2" together with the statement it came from, until you stop
sharing it (the same item, or S again) or close the tab. One result per
connection is shared at a time; sharing another moves the share. A shared
tab keeps its result as a pinned tab does — the next run opens a new tab
rather than change what the assistant sees. Sharing is offered only on a
connection with ai_rows = true; elsewhere the item says which setting
would allow it. The data rule still holds: at most ai_context_rows rows go,
in that tab's sort order, without the columns hidden in that tab, and if
ai_rows is turned off after sharing only the column names go.
The assistant can answer but not act: dbc declines every request from the
agent to run a command or touch a file. SQL in an answer gets ⤓ insert,
which puts it in the editor for you to read and run — there is deliberately
no run-it-for-me button. Click the title for the model picker (Copilot's
premium multiplier is shown beside each model) or to switch assistant:
ai_agent = "claude" uses Claude Code (claude-code-acp), "gemini" uses
the Gemini CLI.
Conversations are kept. Each one is saved to ~/.config/dbc/chats/ (one
JSON file per conversation, readable only by you, the last 30 kept) after
every answer, on ⟲ new, and on quit. The empty pane lists the most recent
ones — click one to reopen it — and right-click the transcript for
Recent conversations… to see them all. Click the list's header to
fold it down to the header and its count; it stays folded (across reloads
in dbc web, across runs in the terminal) until you click it again. A reopened conversation is the
transcript, not the agent's memory: it starts a fresh session, so quote what
matters in a follow-up. To delete one, right-click its row (in the pane or
the list) and pick Delete conversation, or press d twice on it in the
list; to delete the one on screen, right-click the transcript and pick
Delete this conversation — the pane clears without saving it.
Everything else
Non-SELECT statements (INSERT/UPDATE/DDL…) run as exec and report rows
affected. A real SQL NULL is drawn muted and italic, so it cannot be
confused with a column holding the string "NULL".
Queries run on a session pinned to the active connection, so BEGIN …
COMMIT, SET, and temp tables carry across runs as they do in psql.
max_rows is how many rows are fetched from the server; max_display_rows
how many of those the grid shows. The grid is virtualized, so drawing is cheap
either way, but the cap keeps both UIs showing the same thing; rows past it
are still in the result and still go into an export or a whole-result copy.
Hidden columns are a view of the result, and copies and exports take that
view (so does the assistant): hide the noisy columns, then copy the table
into Teams. The header
marks where columns are hidden with ║, the bottom strip counts them, and
the copy's log line says how many were left out. Hidden columns and
hand-set widths survive re-running a query with the same columns; any other
result starts fresh. The sort survives only a rerun of the result tab (r).
Ctrl+T is the \dt: it runs the active driver's catalog query and drops
the tables and views into the results as table_schema · table_name · table_type — the same three columns on all four drivers.
The editor's consoles, the query history and the assistant's conversations
persist between sessions under ~/.config/dbc.
The interface wears a muted green theme — dark gray-green surfaces with a single green accent, shared with cdx. The accent is the focus cue: the pane holding the keys is the one whose border and title are lit.
Cats integration
When dbc runs inside Cats, it detects the host automatically; no dbc configuration is required. Its colors follow the active Cats theme, including live theme changes. Outside Cats — or if the host control socket is unavailable — dbc continues as the standalone client described above.
Cats labels the dbc pane with its current state: working while a query or script runs and idle when it finishes. That lets Cats show its normal completion badge, toast, or notification for a long-running task.
Ctrl+G (or ⌘G) gathers the selected SQL, or the statement under the
cursor, along with its connection, driver, and most recent error. Choose a
sibling agent pane to stage the question there and switch to it; dbc never
presses Enter on your behalf. The Cats chat panel is always available as an
alternative and receives the question immediately.
As a Cats plugin
dbc is also a Cats plugin of type db_client, declared in
cats-plugin.toml:
catctl plugin install rohanthewiz/dbc # or, from this checkout: catctl plugin link .
catctl plugin run rohanthewiz.dbc # opens the TUI in a new tab
Installing builds bin/dbc and links it as ~/.cats/bin/dbc, so dbc
typed in any shell is the same build the plugin launches. catctl completion
picks up dbc's subcommands and flags as well. catctl plugin update rohanthewiz.dbc fetches and rebuilds; add --ref v0.2.0 to the install to pin
a release instead of tracking main.
The plugin tab opens in your current directory, so a project's own
./dbc.toml is used when there is one, otherwise
~/.config/dbc/config.toml, otherwise the demo connections. The integration
described above works the same either way. Its declared type is what keeps
Cats from offering the dbc pane as a place to drop an agent prompt.
The palette also has dbc — web, which runs dbc web (below) in a pane
of its own: the pane is the server's console, and closing it stops the
server.
Query history
Every statement you run is recorded. Ctrl+P opens the history newest first;
type to filter it — over the SQL and the connection name — ↑/↓ to pick,
and Enter drops the statement into the editor at the cursor. It is not run:
a recalled query can be edited first, which is usually why you went looking
for it.
The history is one list, but it opens on this database: the statements
run on the database the editor is connected to — the same idea of "database"
the consoles use, so two connection names onto one database share it, and a
<conn>/<database> has its own. Tab (or the ● app · ○ all chip beside
the filter) widens it to every database and back, keeping what you typed. A
database with no history yet opens on all of them. dbc web's Ctrl+P does
the same for the query tab's database (the scope button in its footer, or
Tab). Statements recorded by a dbc from before this was kept belong to no
database, and show only under "all".
Each statement of a multi-statement run is recorded on its own, so any one of
them is recallable. A statement identical to the one before it on the same
database is not recorded twice, and the app's own catalog query (Ctrl+T)
never is.
History lives in ~/.config/dbc/history.jsonl — one JSON object per line, so
multi-line SQL comes back exactly as it was written — capped at the last 500
entries and readable only by you. Delete the file to forget everything.
Consoles and multi-statement buffers
The editor holds a console: a running SQL file of the database you are
connected to, not one buffer for everything. Consoles are kept per host, then
per database on it, and a database can have as many as you like — one per
line of work. Switch connections and, once the connect lands, the editor
saves what you were writing to the old database's console and opens one of
the new database's (the one you last had open there this session, else its
first); the log says which (console: db.example.com:5432 · app · console).
Two connections onto the same database (another name, another role) share
its consoles, and a derived <conn>/<database> has its own. A connect that
fails leaves the console where it was.
⌥N (Alt+N) opens a new console of the database, ⌥C (Alt+C) moves to
its next one, and the editor's right-click menu lists them all; the Query
pane's title names the one you are in. The same menu renames the console in
the editor (Rename console…: a name of letters, digits, ., - and _,
not one the database already has; the text, caret and undo stay) and deletes
it (Delete console…, after asking; the editor moves to the database's
next console, or a fresh empty one if it was the last). A dbc web tab
showing that console finds out at its next save, which is refused because the
file moved on; the tab then shows the file as it is, and Ctrl+Z brings its
text back.
Consoles are plain .sql files, readable only by you, under
~/.config/dbc/consoles/<host>/<database>/<name>.sql:
consoles/
localhost_5432/ host and port
app/ a database on it
console.sql its first console
console-2.sql
analytics/
console.sql
db.example.com_5432/
app/ another server's "app": consoles of its own
console.sql
local/ SQLite and bytdb: the file, by name + path hash
scratch.db-0b5e9f12/
console.sql
A host or database name that is not already a safe file name (a socket path,
a database with a space in it, an embedded database's full path) gets a short
hash of the original added, so two names that fold together stay apart. A
console is saved on every switch and on quit, so each database's scratchpad is
still there tomorrow; one that never had anything typed into it gets no file.
On the first launch with consoles, the old single ~/.config/dbc/buffer.sql
seeds the console you start on; it is left in place, no longer written.
dbc web edits the same files (see the browser workbench): the TUI writes a
console back only when you changed it, so one it merely had open does not
overwrite what a browser tab saved meanwhile.
A console you switch back to is as you left it: the caret, the selection, the
scroll and the undo history come back with it (for as long as dbc runs). If its
file changed while it was away, the new text comes in as one edit, so Ctrl+Z
brings back what you left.
Keep a whole scratchpad of SQL in the editor and run one statement at a time:
Ctrl+R executes only the statement the cursor sits in, and the log says
which one (running statement 2/4 …), then that it finished
(statement 2/4 completed on pg in 12ms — 3 rows). Select a region first and Ctrl+R
runs exactly that instead — a selection holding several statements runs them
in order, stopping at the first failure, with a result tab for each statement
that returns rows (see Result tabs)
and the last of them on screen.
To run the whole buffer without selecting it, press Ctrl+Shift+R (or
Alt+R — a terminal without the kitty keyboard protocol sends Ctrl+Shift+R
as plain Ctrl+R, and on macOS Alt+R needs Option set to send Meta), or pick ▶ Run all from the editor's right-click menu;
it takes the same path as a selection of every statement.
Statements are separated on semicolons, ignoring the ones inside strings,
quoted identifiers, comments, and PostgreSQL $$ bodies — so a function
definition stays in one piece.
Editor runs share one pinned database session per connection, just like a
headless multi-statement buffer: BEGIN in one Ctrl+R and COMMIT in a
later one bracket a real transaction, and SET, PRAGMA, and temp tables
persist between runs. Switching connections releases the session at once —
along with any transaction it had open, which is rolled back, and a warning in
the log if it had one. If the server drops the session's connection (an idle
timeout, a restart), a session that ran BEGIN, SET, or other statements
fails the run with "session lost", so a COMMIT is never replayed on a fresh
connection outside the transaction it was meant to end. A session that has
only run queries is quietly replaced and the statement retried — when the
driver can tell it never reached the server; if it may have, its error is shown
as-is rather than risk running it twice. Either way the next run starts on a
new session.
Other than the editor's pinned session, pooled connections that sit idle for
conn_idle_timeout (default "1h"; "0" = never) are closed and reopened on
demand.
Query tabs
As in dbc web, the editor can hold several query tabs, each its own
workspace: its own pinned session (a BEGIN in one does not leak into
another), its own console, result, plan and Tables list. ⌥T (Alt+T) opens
a tab on the connection you are on, ⌥W closes the tab (asking first when its
session may hold a transaction, since closing rolls it back; the last tab
stays), and ⌥1…⌥9 switch. The connections list, the log and the assistant
are shared; the assistant always talks about the tab on screen.
With two or more tabs, the editor's top border becomes a strip of them:
╭ Query 1 · console ● ─ wip • ─ Query 3 ◆ ─ + ──────────────╮
Click a tab to switch, double-click it to rename it, right-click it for
rename, close, a new tab and its database's consoles; + opens one. A tab
left running keeps running: ● marks it while it runs and • once it has
finished (in the error color if it failed), and its result is drawn when you
come back to it. ◆ marks a tab whose session may hold a transaction, SET
values or temp tables. In the connections list, ● is the tab's connection
and ○ one another tab is on; a connection a tab is on cannot be removed or
re-pointed until that tab moves off it.
Two tabs on one database get two consoles, never one file written by both: a
new tab takes a console no other tab shows (or a new one), ⌥C skips the
ones other tabs show, and the console menu sends you to the tab that has one.
Tabs are kept between sessions with the pane sizes, in
~/.config/dbc/tui-layout.json — each tab's title, connection and console. On
the next start only the tab on screen connects; the others connect the first
time you look at them, so ten saved tabs do not dial ten connections.
Result tabs and the log, per connection
Each query tab keeps a result set per connection it has been on. Switch
the tab to another connection and the results pane shows that connection's
results — nothing, the first time — and switching back shows the old
connection's again, as you left them: the same result tabs, each with its
sort, hidden columns, widths and scroll, and the same plan. The assistant
follows along: "the last result", "the last error" and the plan it is told
about are the connection's on screen. Each tab's sets are its own, as its
session is: two tabs on one connection keep separate results. Results are
kept in memory for as long as dbc (or the dbc web page's tab) runs.
A set holds result tabs, drawn on the results pane's bottom border in
the terminal and on the results bar in dbc web:
╰─ 1⚑ select * from orders · 2✦ columns cats · 3 select count(*) … ↻ ── { } switch · r rerun · P pin · S share · x close ─╯
A run replaces the result in the tab it started from, unless that tab
is pinned (⚑, P or the tab's right-click menu): then it opens a new
tab. So the edit-the-WHERE-and-run-again loop keeps one tab, and a result
worth keeping is one key from kept. The tab a run lands in is picked when it
starts, so you can look through the other tabs while a slow query runs
without its result landing on the one you are reading. A tab is named after
its statement (or preview cats, columns cats, script x.go). A script's
s.Show results all land in one tab, where "Result 1 · 2 · 3" steps through
them.
Rerun a tab (r, a click on the ↻ beside the tab on screen, or
↻ Rerun its query on its right-click menu) to bring its result up to
date: its statement runs again, on the same session,
and the fresh result goes back into that same tab — a pinned one
included, which stays pinned, in its place in the strip, with its name (a
shared tab stays shared). The pin keeps a result from being replaced by
other runs; rerunning the tab is how you ask for that result again. The
tab comes on screen with its hidden columns, its widths and its sort kept
(a statement edited and run into the tab starts unsorted). A rerun is a run:
one at a time, Ctrl+K stops it, and if it fails the tab keeps its old
result. A tab whose statement may write (an INSERT's "n affected") asks
before running it again, and a script's tab has no statement to rerun — run
the script again instead. A rerun is not added to the history again.
A run of several statements (Run all, or a selection of several) opens
a tab per statement that returns rows, as DBeaver and DataGrip do; an
INSERT, UPDATE or DDL statement gets none, and when no statement
returns rows the last one's result lands alone. The log has a line for each
write instead (statement 2/3: 4 affected — UPDATE …, or done for DDL;
none for BEGIN, SET or COMMIT); past five, the rest fold into one
line with their total (… 495 more writes, to statement 500: 495 affected in all), so a script of INSERTs does not flood it. The last
tab filled is the one on screen. Those tabs stay together, and running the
buffer again refills them in order instead of piling up more — a rerun
with fewer row-returning statements closes the tabs it has nothing for,
while a pinned (or shared) tab is left out and keeps its result. Running
one statement with one of those tabs on screen replaces just that tab. When
a statement fails, the results of the ones before it still land.
result_tabs (default 10, at most 50) caps the tabs of one connection's
set. When a run needs a new tab and the set is full, the oldest tab that is
neither pinned nor shared is dropped — another run's before one of this
run's own, so a run with more results than the cap keeps its last ones (the
log says how many did not fit). If every tab is pinned, the run is
refused before it starts, and the log says to unpin or close one. { / }
step through the tabs, x closes the one on screen (its right-hand
neighbour takes its place), and the strip's right-click menu also closes
every unpinned tab at once. A tab shared with the assistant (✦, see
Sharing a result) is kept like a pinned one.
The log is per connection too: the log pane shows the messages of the
connection the tab on screen is on — its runs, notices, connects and the
s.Print lines of scripts run on it — under the title Log · <connection>.
Two tabs on one connection share its log. A line goes to the log of the
connection on screen when it is written. A run or explain that finishes
after you switched away keeps its lines (and its result or plan) for its
own connection, and the log on screen gets one line saying so.
Lines written while the tab is on no connection (after a Disconnect, say)
are moved into the next connection's log when it connects, so none is left
where it can't be seen. ⧉ copy on the log's title puts the connection's
log on the clipboard (one HH:MM:SS message line each), and ✕ clear
empties it; in the terminal, y and x do the same with the log focused.
In dbc web a script tab, which is on no connection, has a log of its own.
It is the script's log, kept by its name: close the tab and open the script
again, and its earlier lines are there; rename the script and the log moves
with it. Logs are kept while the page is open in dbc web (a reload starts
them empty) and while dbc runs in the terminal; neither is saved.
Stopping a long query
While a query or script runs, the status bar shows a live elapsed time and a
■ Stop button appears at its right edge. Ctrl+K or a click on the button
cancels it: the statement is aborted on the server (Postgres, MySQL) or
interrupted in process (SQLite, bytdb), and the run is reported as stopped
rather than failed.
A canceled script unwinds through its own error handling — the query in flight
is aborted and every later s.Query/s.Exec fails immediately. A script that
loops without touching the database can still check s.Canceled(), and
sdb.IsCanceled(err) tells a stop apart from a real failure:
r, err := s.Query("demo-sqlite", bigQuery)
if err != nil {
if sdb.IsCanceled(err) {
return nil // stopped by the user, not a failure
}
return err
}
The browser workbench: dbc web
dbc web # serve on 127.0.0.1:8450 and open the browser
dbc web --no-open # print the sign-in link instead
dbc web --listen 127.0.0.1:9000
dbc web is the TUI's workbench in a browser: connections and tables on
the left, a Monaco SQL editor, the results grid, the plan view, the log, and
the AI assistant. It runs statements through the same code the TUI does, so
the run slot, pinned sessions, cancel, history, explain and the assistant's
data rules behave the same in both.
Layout. A draggable bar separates every pair of neighbouring sections:
the sidebar and the work column, Connections and Tables, the editor and the
results, the results and the log, and the work column and the assistant.
Double-click a bar to go back to the default size. ‹ beside Connections
(or Ctrl+B / ⌘B, or dragging the sidebar's edge shut) folds the sidebar
away; the › tab left on the window's edge brings it back. Sizes and the
fold are saved with the layout.
Access. It listens on loopback, and only a browser holding this launch's
secret can use it: dbc opens …/login?s=<secret> (and prints it), which
trades the secret for a session cookie. A restart signs every browser out;
use the new link. Scripts can send Authorization: Bearer <secret> (set it
with --secret or DBC_WEB_SECRET). A --listen address off loopback is
allowed, with a warning — it is a local tool, not a team server. The port is
8450, or a free one when 8450 is taken.
Query tabs. (The TUI has them too — see Query tabs.) Each tab is its own workspace with its own pinned session,
so a BEGIN in one does not leak into another. Alt+T opens a tab, Alt+W
closes it (asking first when its session may hold a transaction, since
closing rolls it back), Alt+1…Alt+9 switch, and a double-click renames.
A tab keeps its result tabs, each with its grid view (sort, hidden columns,
widths), and its plan while another is on screen — per connection, as in
the terminal (see
Result tabs and the log); a run
left going in the background marks its tab (● running, • done) and names its
log lines.
Tab groups gather tabs behind a short coloured chip, as ced's do.
Right-click a tab → Add to group… to start one: an ad-hoc group
holds the tabs you put in it (a tab opened from one joins it), and a
connection group holds every tab on its connection or on one of its
other databases, including tabs that switch to it later. Click a chip to
fold its group down to the chip (wip +2), and right-click it to rename,
ungroup, close the group's tabs, or jump to one of them. Names are up to 8
letters or digits. Groups are saved with the layout and follow a
renamed connection.
A tab's text is one of its database's consoles — the TUI's files (see
Consoles), so the terminal and the
browser share each database's running SQL. The tab names its console after
its title. When the tab's connection lands on another database, the tab
saves its console and shows one of the new database's that no other tab of
the window is showing, or a new one, so two tabs on one database get two
consoles. Alt+N gives the tab a new console of its database, Alt+C moves
to the next one, and right-clicking the tab lists the database's consoles
(and which tab shows each) with new, rename and delete. A rename or a delete
reaches every window; tabs showing a deleted console move to another. Saves
are checked against the file: if it changed since this tab loaded it (another
window, the TUI, an editor), it is not overwritten. The tab shows the file's
text instead and says so in the log, and Ctrl+Z brings your text back to
save over it. Another window's save shows up at once in a tab with no unsaved
edits to that console. A console keeps its caret, scroll and undo history in
the window for as long as the page is open, even while no tab shows it, so a
tab that comes back to it finds it as it was left. On the first start with consoles, each saved tab's
text moves into a console of its database (without overwriting one that
exists). The tab keeps its own copy too, so an older dbc still opens it.
Tabs, their connection and console, the pane sizes and light/dark are saved in
~/.config/dbc/web.bytdb; a reload keeps every tab's session. A saved tab
is shown by one browser tab of dbc web at a time: a second browser tab gets
the saved tabs the first is not showing, or a fresh one, with sessions of its
own. Closing a browser tab frees its tabs for the next one opened.
Postgres in Docker…, under + or in a connection's right-click menu, starts a local PostgreSQL in a container and adds its connection; see Postgres in Docker.
Adding connections. The + beside Connections opens a menu:
Add a connection… and Postgres in Docker…. The first opens a form: name,
driver, the connection itself, TLS, and whether the assistant may see
result rows (ai_rows). Enter as switches between Fields (host, port,
user, password, database and extra options, or a file for SQLite and bytdb)
and the whole DSN as text. From fields, dbc writes the DSN for you and
handles the quoting and escaping, so a password with @, / or spaces
needs nothing special. Options are key=value pairs separated by spaces
or & (for example application_name=dbc or parseTime=true). The password
may be a ${VAR}. The TLS section shows for Postgres and MySQL, and its file
fields appear once a mode other than disable is picked. A relative path
there is relative to ~/.config/dbc.
Test connection opens the DSN, pings it within connect_timeout and
closes it again, so nothing is saved until you choose to. It reports how long
the connect took, or the driver's error. For a SQLite or bytdb file that does
not exist yet, it says so rather than creating one. Save adds the
connection and switches the tab to it, whether or not it was tested. The
connection is kept in ~/.config/dbc/connections.toml, not in your config
file, which dbc never rewrites. Every dbc reads it after the config file,
so the TUI, headless runs (dbc -c name …), script, migrate and
explain see these connections too. If the config file later defines the
same name, the config file's wins, with a warning. connections.toml uses
the same [[connection]] tables as the config file, so an entry can be
copied across. You may edit it by hand, but dbc rewrites it whole on the
next change from the browser, so comments are not kept. A running dbc web
reads it only at startup. A DSN is stored as typed: write ${PGPASS} and the
password stays in the environment instead of the file. Each dbc expands it
from its own environment, so set the variable wherever you run the TUI too.
A DSN typed with the password inline is stored as typed, in a file only you
can read (0600). A DSN is never sent back to the browser. Connections that
an older dbc web kept in web.bytdb are moved to connections.toml the
first time it starts. Connections added this way show a muted ✎ before their
driver (the tab's connection is marked by the bar on its left; a dimmer bar
marks one another query tab, here or in another browser window, is on — hover
it to see which), and any
connection with TLS on says · tls beside its driver. To change one,
right-click it and pick Edit…: the same form, filled in. In fields, every
part of the saved DSN comes back except the password (a ${VAR} password
does come back, since it names the secret rather than being it). Leave the
password empty to keep the saved one. If a DSN can't be split into fields
(several Postgres hosts, a MySQL unix socket), the form opens on the DSN
text instead and says why. As text, the DSN field starts empty. Leave it
empty to keep the saved DSN (for a test too), or type a new one; changing
the driver needs a new DSN. A rename keeps
the connection's place in the list and moves the saved query tabs that were
on it. To remove one, pick Remove…. Disconnect, on the row the tab is
on, takes the tab off the connection and keeps the connection in the list.
If the session may hold a transaction, it asks first, because disconnecting
rolls it back. The connection to the server closes when no other tab is
on it. A connection a tab is still on can't
be removed, renamed or given a new DSN until you switch that tab away;
whether the assistant may see its rows can be changed at any time. The
file's connections are changed in the file. The TUI has the same form —
a in its Connections pane (see
Adding connections in the terminal).
Keys. The TUI's, bent where a browser keeps the chord for itself:
| Key | Does |
|---|---|
Ctrl+Enter (Ctrl+R) |
run the statement under the caret, or the selection |
Ctrl+Shift+Enter |
run every statement |
Ctrl+X (nothing selected) · Ctrl+Shift+X |
explain · explain analyze |
Ctrl+K |
stop the run (in the assistant: stop the answer) |
Ctrl+P · Ctrl+E · Ctrl+O |
history · export · scripts |
Ctrl+S |
in a script tab, save the script |
Ctrl+I |
the assistant, and back (Ctrl+A stays select-all) |
Ctrl+Space |
suggestions (they also open as you type — see Completion) |
F12 · Shift+F12 · F2 |
on an alias, a CTE name or a column the query names: go to its declaration · list its uses · rename it (see Go to definition, usages and rename) |
Alt+T · Alt+W · Alt+1…9 |
new tab · close tab · go to tab |
Alt+N · Alt+C |
new console · next console of the tab's database |
{ · } · P · r · S · x |
in the results: previous · next result tab · pin · rerun its query into it · share with the assistant · close |
F1 or ? |
every key |
On a Mac, ⌘ works wherever Ctrl is listed.
What a browser adds. Copy puts a real HTML table on the clipboard (the
browser writes text/html itself, so "copy for Teams" works over SSH too);
exports are downloads; the plan view is the interactive page's own, with
before/after comparison when you explain again; findings' SQL goes into the
editor with ⤓ Insert.
The assistant (Ctrl+I, or ✦ Assistant) is the TUI's: the same agent,
the same data rule — rows only on connections with ai_rows = true, in the
grid's sort order, without its hidden columns — and the same conversation
archive, so a conversation had in either is offered in both. "✦ Ask" in the
grid's menu, the value inspector, the editor's right-click menu and the plan
drafts a question about what you are looking at. Copilot sign-in runs in the
pane: dbc shows the device code, with buttons to copy it and open GitHub's
page.
Scripts (Ctrl+O, or ▷ Scripts) open the scripts browser: run, make,
rename or trash a script, or copy one of the built-in examples. Editing a
script opens a script tab — Go in Monaco, errors marked as you type, sdb
completion, Ctrl+S to save, and Run saves first. s.Print lines reach the
log and s.Show results the grid as they happen. See
The scripts browser and
Script tabs in dbc web.
Ctrl+C in the terminal stops the server; every tab's run is stopped and
its session released — anything left open is rolled back.
As a macOS app
./mac-install.sh # from a checkout: builds it, installs ~/Applications/dbc.app
curl -fsSL https://raw.githubusercontent.com/rohanthewiz/dbc/main/mac-install.sh | bash
dbc.app is dbc web in a window of its own rather than a browser tab: a
small Swift/WebKit shell (macapp/DbcApp.swift)
around a bundled dbc. It needs the Xcode Command Line Tools
(xcode-select --install) for swiftc; Go comes from your PATH, or the
script fetches the version go.mod asks for into ~/.local/go. Run from a
checkout, the script builds that checkout as it is. Piped from curl, it
keeps a clone of main in ~/.dbc-src and resets it on every run. The app
is built on your Mac and signed ad hoc, not downloaded, so there is nothing
to notarize. Re-run the script to update it. DBC_APP_DIR and
DBC_APP_NAME change where it goes and what it is called (the script's
header lists the rest).
Each launch starts its own dbc web on a free loopback port with a secret
of its own, waits for /api/v1/health, and signs its window in, so a
dbc web in a terminal keeps working beside it. The server gets your login
shell's environment ($SHELL -il), as a terminal would. That makes the
assistant's agents findable on PATH, and ${PGPASS} in a DSN and
ANTHROPIC_API_KEY resolve as they do there. It starts in your home
directory, so ~/dbc.toml, then ~/.config/dbc/config.toml, is the config.
Everything else (connections, history, conversations, tabs and layout) is
the same ~/.config/dbc every dbc uses. Only one dbc web can save tabs
at a time, though: whichever of the app or the terminal starts second warns
in its log and does not save them.
Downloads go to ~/Downloads. The plan's standalone page opens in a second
window, and links off the workbench (GitHub's Copilot sign-in page, say)
open in your browser. View ▸ Open in Browser signs your browser in to
the same server. View ▸ Show Log opens ~/Library/Logs/dbc/dbc-web.log,
the server's console (the previous launch's is dbc-web.log.1). ⌘Q stops
the server as Ctrl+C would. If the app crashes or is force-quit, the
server notices its stdin close and shuts down the same way. The app never
leaves a server behind.
Scripting in Go
A script is a plain Go file defining one function:
//go:build ignore
// Count the cats at each minimum age.
package main
import "github.com/rohanthewiz/dbc/sdb"
func Run(s *sdb.S) error {
for _, minAge := range []int{1, 3, 5} {
r, err := s.Query("demo-sqlite",
"SELECT id, name, breed, age FROM cats WHERE age >= ? ORDER BY age", minAge)
if err != nil {
return err
}
s.Print("min age %d → %d cats", minAge, len(r.Rows))
s.Show(r) // lands in the TUI results table
}
return nil
}
Scripts live in ~/.config/dbc/scripts, beside the consoles and the
history, unless the config sets scripts_dir. A relative scripts_dir is
relative to the config file that sets it, not to the directory dbc starts
in, so the TUI from any directory, dbc web and dbc.app all read the same
scripts. ~ and ${VAR}s are expanded. The directory is made on the first
save. dbc scripts prints the directory and lists what is in it, and the
scripts browser names it in its title.
Scripts are interpreted at runtime (via yaegi) — no compile step, edit and
re-run. The full Go standard library is available. The //go:build ignore
line just keeps go build from compiling script files if they live inside a
Go module; dbc runs them regardless.
A script is an ordinary file, so it can be written anywhere: in dbc web's script tabs, in your own editor from the TUI, or in vim and committed to a repo of its own. Every save is revision-checked against the file, so a change made elsewhere (another window, the TUI, an editor) is a conflict to resolve, never silently overwritten.
The scripts browser
Ctrl+O (or ƒ / ▷ Scripts on the toolbar) opens it, in the TUI and in dbc
web alike:
┌ Scripts · ~/.config/dbc/scripts ─────────────────────── [+ New] ┐
│ ⌕ filter │
│ copy_mytable.go Copy myschema.mytable from ProdDr to dev 2h │
│ nightly_report.go Export yesterday's orders as HTML 3d │
│ ─ Examples ───────────────────────────────────────────────────── │
│ copy_table.go Copy a Postgres table from one connection… │
│ loop_params.go … │
│ ─ Trash (2) ▸ ────────────────────────────────────────────────── │
└──────────────────────────────────────────────────────────────────┘
- Scripts — yours, by name, each with its description (the comment
above
package main, to its first sentence) and age. With none yet, the section lists the templates instead: blank, query and show, a loop over parameters, copying a table, exporting a report. A template's connection names are filled in from your config, the current connection first. - Examples — the samples in the repo's
scripts/, built into the binary, so a fresh install has them with no checkout. They are read-only: Enter makes your own copy, which opens for editing. Headless,dbc script loop_paramsruns the built-in copy as it is. - Trash — folded. Trashing asks nothing, because it moves the file into
<scripts_dir>/.trash/(the newest 50 are kept), and Enter on a trashed script restores it, offering another name if the old one has been taken since.
Enter on a script runs it, as Ctrl+O always has. New, duplicate and copying an example ask for a name, write the file, then open it for editing. The rest:
| TUI | dbc web | |
|---|---|---|
| run · edit | Enter · e (or double-click) |
Enter · Shift+Enter (or ▶ · ✎) |
| new from a template | n (or + New) |
Alt+N (or + New ▾) |
| duplicate · rename | d · r / F2 |
⋯ Duplicate… · F2 |
| trash | Del / x |
Ctrl+Delete (⌘⌫) |
| copy the path | y |
⋯ Copy path |
| filter | / (Esc or Tab back to the list) |
type — the filter has the focus |
| unfold the trash | t (or click its heading) |
click its heading |
In the TUI a right-click on a row has these as a menu; in the web, ⋯ on a
script's row does. The keys differ because the web's filter keeps the focus,
so bare letters would type into it, and a terminal does not reliably pass
Shift+Enter or Ctrl+Delete through.
Script tabs in dbc web
Edit (or New) opens the script in a script tab: the editor in Go mode, with the grid and the log beside it as for a query.
- Run (
Ctrl+Enter, ▶ Run) saves first, then runs the file, so what runs is what is on disk and whatdbc scriptwould run. A save that does not land stops the run.■ Stop/Ctrl+Kstops it as any run. - Save is explicit:
Ctrl+Sor ⤓ Save. A script never autosaves, but unsaved edits are kept in the browser, so a reload does not lose them. Each browser tab keeps its own: two tabs editing one script never take each other's unsaved text, and a tab that was closed with unsaved edits hands them to the next tab that opens the script. The tab reads▷ name.go ●while it has unsaved changes, and closing it asks whether to save, discard or keep it open. If the file changed on disk meanwhile, the save is a conflict: the file's text loads as one undoable edit, andCtrl+Zbrings yours back to save over it. - Errors as you type. 600 ms after you stop typing, the unsaved text is
checked (as
dbc script --checkdoes) and the errors are marked on their lines; the header adds· 1 error. ✓ Check also lists them in the log. - Completion and hover for the
sdbAPI (s.,sdb., the fields of aResult,CopyOptsorWriteOpts), with signatures and doc comments, and connection names inside a connection argument (s.Query("…). - A script names its own connections, so a script tab is never connected: a click on a connection in the sidebar puts its name in at the caret, and Explain, Run all and the rows box are hidden.
- Several results. Each
s.Showis kept (the newest 20 of a run) in the one result tab the run lands in (see Result tabs), and "Result 1 · 2 · 3" on the results bar switches between them. Copies, exports and the assistant follow the one shown. - The assistant is asked about the script: it gets the whole file as
Go, the configured connection names and drivers, the tables its SQL names,
and the last run's error or shown result when that run was this script.
The first script question of a conversation also sends a summary of the
sdbAPI (signatures and one line each), so answers call the real methods. Go in answers gets ⤓ insert, into the script at the caret.
Double-click the tab to rename the script. One script has one tab; opening it again goes to that tab.
Editing scripts from the TUI
e in the browser suspends the TUI and opens the script in $VISUAL, else
$EDITOR, else vi. The value is split on spaces, so EDITOR="code -w"
works (the editor must wait until the file is closed). When it exits, the
TUI comes back, logs whether the file changed, checks it and logs each
problem as ~/…/name.go:6:9: undefined: x. The browser then reopens on the
script, so Enter runs it. An editor that is not on PATH is said in the log,
and nothing is suspended. As in dbc web, a script that shows several
results keeps them (the newest 20): "Result 1 · 2 · 3" on the results
title switches between them, by a click or [ / ] in the grid.
Checking without running
dbc script --check NAME checks a script without running any of it (not
Run, not init()): syntax, that it is package main with a
func Run(s *sdb.S) error, and the interpreter's compile pass (undefined
names, type mismatches, an import other than the standard library and
sdb). It also warns about the map-assignment quirk below. It is the same
check both UIs run after an edit. yaegi is not the Go compiler, though: only
the first compile error is reported, and an unused variable or import
passes. See Scripts headless.
The sdb.S API
| Method | Purpose |
|---|---|
s.Conns() []string |
Configured connection names |
s.Query(conn, sql, args...) (*sdb.Result, error) |
Run a query with params |
s.Exec(conn, sql, args...) (int64, error) |
Run a statement, get rows affected |
s.DB(conn) (*sql.DB, error) |
Raw database/sql handle — transactions, prepared stmts, anything |
s.Explain(conn, sql, analyze) (*sdb.Plan, error) |
The plan, as Ctrl+X sees it: p.Text(sdb.PlanText{Insights: true}), p.Insights, p.Root — see scripts/plan_check.go |
s.Show(r) |
Push a result to the results table (stdout when headless) |
s.Print(format, args...) |
Log to the TUI log pane (stdout when headless) |
s.Export(r, format, path) |
Export a result; empty path → clipboard |
s.Canceled() bool |
True once the user has stopped this run |
s.Ctx() context.Context |
The run's context, for select on Done() |
sdb.IsCanceled(err) bool |
Tells a stop apart from a real query failure |
sdb.Result gives you Columns []string, Rows [][]string, Raw [][]any,
Duration, and Affected. Use the placeholder style of the target driver
($1 postgres/bytdb, ? mysql/sqlite).
The DDL log
Every DDL statement a script runs is logged where s.Print lines go (the
connection's log in the TUI and dbc web; stdout or stderr headless) just
before it runs, verbatim. That means CREATE, ALTER, DROP, TRUNCATE,
RENAME, COMMENT, GRANT and REVOKE. It is always on, and a script
cannot turn it off:
DDL local: CREATE TABLE IF NOT EXISTS staging (id int PRIMARY KEY, body text)
DDL local: DROP TABLE nope
DDL local failed: SQL logic error: no such table: nope (1)
It covers s.Query and s.Exec, and each statement of a multi-statement
Exec. It also covers what s.Copy and s.Writer run to prepare a
destination: a Create's CREATE TABLE, a Truncate, and a Writer's
Setup. A Query or Exec that fails adds a failed line, because a
script may carry on past the error. Logging before the statement rather than
after it means a long CREATE INDEX, or an ALTER waiting on a lock, shows
up while it is still running. It has limits:
- It works from each statement's first keyword, so DDL inside a function
body or a Postgres
DOblock is not seen. - Statements run through the raw
s.DBhandle are not logged. - On SQLite and MySQL a
Truncateruns as aDELETE, which is not DDL, so it adds no line. dbc copyis not a script and logs nothing.
Sample scripts live in scripts/: parameter loops, multi-host
sweeps, CSV/HTML report generation, and copying tables between connections.
They are the browser's Examples.
ETL across connections
Scripts can move rows between connections, on the same engine or different ones (Postgres, MySQL, SQLite, bytdb, in any pairing). Copying a table from one Postgres to another is one call:
st, err := s.Copy("prod-pg", "local-pg", "public.orders", sdb.CopyOpts{
Create: true, Truncate: true, ProgressEvery: 250_000,
})
s.Print("%s", st) // copied 500000 rows prod-pg:public.orders → local-pg:public.orders in 886ms (direct COPY)
"prod-pg" and "local-pg" are connection names, not DSNs: the same
names as in the connections list, from the config file's [[connection]]
entries (see Configure connections) or from
~/.config/dbc/connections.toml (connections added in the TUI or dbc web).
The DSN, its ${VAR} expansion and the TLS settings all come from that
entry. A script has no way to define a connection of its own. The name
also gives the driver, which decides how the rows move (below). Each
connection's pool opens on first use, and a name dbc doesn't know fails with
unknown connection.
On Postgres and MySQL, <conn>/<database> names another database on the
same server, with the connection's credentials and TLS settings. For example,
s.Copy("prod-pg/reporting", "local-pg", …) reads from the reporting
database through prod-pg. A configured connection whose name matches
exactly always wins.
| Method | Purpose |
|---|---|
s.Copy(src, dst, table, sdb.CopyOpts{…}) (sdb.CopyStats, error) |
Copy a table (or query) from one connection to another |
s.Reader(conn, sql, args...) (*sdb.Reader, error) |
Stream a query's rows: for rd.Next() { row := rd.Row() }, then rd.Err() |
s.Writer(conn, table, cols, sdb.WriteOpts{…}) (*sdb.Writer, error) |
Load rows in one transaction: w.Write(row) …, w.Close() commits, defer w.Abort() |
sdb.CopyOpts fields: To (destination table, default the same name),
Columns, Where + Args, or a whole Query instead of a table; Create
(make the destination when missing), Truncate (empty it first);
Transform func(row []any) ([]any, error) to edit each row in Go (return
nil to skip it); ProgressEvery (print the running count) or Progress
(your own callback); BatchSize. sdb.WriteOpts has Setup (statements
run first, in the load's transaction), Truncate and BatchSize.
How it moves the rows:
- Postgres → Postgres with no
Transformand noArgsstreamsCOPY (SELECT …) TO STDOUTstraight intoCOPY … FROM STDIN. The rows are never decoded, so it is lossless for every type both servers know (arrays, jsonb, ranges, enums) and runs at the speed of the servers and the network.COPYcannot take bind parameters, soArgsgoes row by row. - Into Postgres otherwise:
COPY … FROM STDINin text format, so the server parses each value as it would a literal —"42"from MySQL loads into aninteger. Besides what aReaderyields, aTransformor aWritermay hand over a Go slice (an array:[]stringintotext[],[][]intintoint[][]), a map or struct (JSON, forjsonb), atime.Duration(aninterval), a pointer (nilis NULL), or ansql.NullStringor otherdriver.Valuer. - Into MySQL, SQLite or bytdb: multi-row
INSERTbatches (500 rows, fewer for wide tables), the full-batch statement prepared once.
The destination loads in one transaction, together with Create and
Truncate. A failed, stopped (Ctrl+K) or panicking copy leaves it as it
was: the old rows still there, a table it was creating not created. Two
engines can't do DDL that way: on bytdb and MySQL the CREATE TABLE runs
just before the load, so a failure there can leave an empty new table, but
never a partial one. MySQL's Truncate is a DELETE, since its TRUNCATE
commits on the spot. So is Postgres's when source and destination are the
same database (one connection, or two names for it): TRUNCATE's lock
would stall the copy's own read of the table, so a table copied onto itself
to rewrite it through a Transform works — at DELETE's speed on a big
table.
A Query may be a write with RETURNING, which makes the copy a move:
st, err := s.Copy("prod-pg", "archive-pg", "", sdb.CopyOpts{
Query: "DELETE FROM jobs WHERE finished_at < now() - interval '90 days' RETURNING *",
To: "jobs_archive", Create: true,
})
From Postgres, the source's change commits only after the destination's load
has. A copy that fails, even at the load's final commit, leaves the rows
where they were. MySQL, SQLite and bytdb commit the write as it runs, before
the load. A write without RETURNING has nothing to copy and is refused
before it runs.
Reading from Postgres pins the settings that shape a value's text —
DateStyle to ISO, IntervalStyle to postgres, extra_float_digits to
exact — for that read only. A copy between two servers configured
differently (one with DateStyle = 'SQL, DMY', say) then moves dates,
intervals and floats unchanged rather than swapping day and month. A
Query may end in ; or a comment, but holds one statement.
Create copies a Postgres table to Postgres with its exact column types,
NOT NULLs and primary key. Between other engines each column gets a broad
type (integer, float, numeric, boolean, date, timestamp, bytes, or text) and
the source's primary key, where it can be read. A Postgres Query copied
to Postgres keeps each column's type name but not its length or precision
(varchar(80) becomes varchar), and a user type — an enum, a
composite — becomes text, since it may not exist on the destination.
Defaults, sequences, other indexes and constraints are not copied. bytdb requires a primary key, so to
copy a query into bytdb, create the table first. bytdb has no exact decimal
type, so a numeric column created there is double precision: values with
more than about 15 significant digits get rounded. To keep every digit,
create the table yourself with a text column. That works from Postgres
and MySQL, whose numerics arrive as text, but not from SQLite.
A Reader has no max_rows cap and keeps values typed: int64,
float64, bool, string, time.Time, []byte for binary columns, nil
for NULL. Postgres numeric, uuid, intervals, arrays and ranges arrive
in their text form, the same from every server (ISO dates, exact floats). A
Reader or Writer a script leaves open is closed, or rolled back, when
Run returns. A Writer is never committed unless the script calls Close.
scripts/copy_table.go shows all three levels, up
to a join across two connections. To copy a single table, with no script,
use dbc copy.
One interpreter quirk: yaegi silently drops a two-value assignment into a map
element. m[k], _ = v.(string), m[k], _ = other[k] and m[k], _ = f()
store nothing, and m[k], _ = <-ch panics. Assign to a variable first, then
store it in the map. Present in yaegi v0.16.1 and on master as of 2026-10;
tracked upstream in
traefik/yaegi#1655.
Headless mode
Everything works without the TUI, for cron jobs and shell pipelines:
./dbc "SELECT * FROM cats" # aligned text table
./dbc -c local-pg -t csv "SELECT * FROM users" # any format to stdout
./dbc -t html -o report.html "SELECT ..." # straight to a file
./dbc "INSERT ...; SELECT ..." # several statements, in order
./dbc -f report.sql # the SQL in a file
./dbc -c local-pg < report.sql # … or piped to stdin
./dbc script scripts/loop_params.go # run a Go script
./dbc script copy_mytable # … one from scripts_dir, by name
./dbc script --check copy_mytable # check it without running it
./dbc scripts # list scripts_dir (and say where it is)
./dbc copy --from prod --to local --create orders # copy a table across connections
| Flag | |
|---|---|
-f, --file FILE |
read the SQL from a file; -f - reads stdin |
-t, --format FORMAT |
text (default), csv, tsv, markdown, html, json |
-o, --out FILE |
write the output to a file instead of stdout |
-c, --conn NAME |
which connection to run on |
--tx |
run the statements in one transaction: all commit, or none do |
-k, --keep-going |
go on past a failed statement instead of stopping there |
--config FILE |
config file (default ./dbc.toml, then ~/.config/dbc/config.toml) |
--demo bytdb|sqlite |
which demo starts active when there is no config (also $DBC_DEMO) |
--driver NAME --dsn STRING |
a one-off connection that is in no config file |
--tls MODE (--tls-ca, --tls-cert, --tls-key FILE, --tls-key-password '${VAR}') |
TLS for the --dsn connection, overriding the DSN's own |
dbc --help lists them all. Long names take one dash or two (-dsn works),
and flags may go before or after the SQL. With no SQL argument and no -f,
dbc reads the SQL from stdin when stdin is a pipe or a file, and otherwise
opens the TUI. Give the SQL as one quoted argument: more than one argument is
an error rather than a silently shortened query. Use -- before SQL that
starts with a comment, so the flag parser leaves it alone. Ctrl+C cancels a
running query or script and exits 130; bad usage exits 2.
-f was the output format before it was the SQL file; dbc -f csv … now
says to use -t csv rather than looking for a file named csv.
Copy headless
dbc copy is s.Copy without the script, for a cron job or a shell
pipeline:
./dbc copy --from prod --to local public.orders # into an existing table
./dbc copy --from prod --to local --create public.orders # … created when missing
./dbc copy --from prod --to local --truncate public.orders # … emptied first
./dbc copy --from prod --to local public.orders archive.orders # … under another name
./dbc copy --from prod --to local --truncate \
--where "created_at >= now() - interval '1 day'" orders # only some rows
| Flag | |
|---|---|
--from NAME, --to NAME |
source and destination connections (both required; may be the same one, copying to a different table). A <conn>/<database> name works as it does in s.Copy |
--create |
create the destination table when it does not exist (see Create under ETL) |
--truncate |
empty the destination first, in the load's transaction |
--where CONDITION |
copy only the source rows that match, written in the source's SQL |
A second table name is the destination table; without it, the destination
has the source's name. The copy is the same as s.Copy in a script:
Postgres to Postgres streams COPY to COPY, and the destination loads in
one transaction, so a failed or interrupted (Ctrl+C, exit 130) copy leaves
it as it was. On success it prints one line to stdout
(copied 179982 rows pg1:public.orders → pg2:public.orders in 405ms (direct COPY)).
When stderr is a terminal, it also prints the row count there every
100,000 rows. A cron job gets only the summary line. Exit status: 0
copied, 1 failed, 2 bad usage, including an unknown connection name or a
table copied onto itself. -c, -f, --tx, -k, -t and -o are
refused, because a copy has no use for them.
Explain headless
./dbc explain "SELECT * FROM orders WHERE user_id = 42" # tree + findings
./dbc explain -a -f slow.sql # analyze
./dbc -c pg explain -t json "SELECT …" | jq .insights # for tooling
./dbc explain -t html -o plan.html "SELECT …" # the interactive page
./dbc explain --open "SELECT …" # … straight into the browser
./dbc explain -t pdf -o plan.pdf "SELECT …" # graph + findings, to send
./dbc explain -t jpeg -o plan.jpg "SELECT …" # … as a picture (png too)
./dbc explain -t pdf --theme light -o plan.pdf "SELECT …" # … on paper, to print
./dbc explain -t mermaid "SELECT …" | pbcopy # a chart for a PR or a wiki
--fail-on warn (or crit) exits 3 when the plan has a finding that
severe, which turns the findings into a CI gate: keep the queries that matter
in a folder and fail the build when one of them starts scanning a big table.
for q in queries/*.sql; do ./dbc -c staging explain --fail-on warn -f "$q" || exit 1; done
explain takes one statement. -t is text (colored on a terminal, unless
NO_COLOR is set), markdown, json, html, pdf, jpeg, png, or
mermaid. The PDF and pictures are bytes: write them with -o, or pipe
them — dbc refuses to print them on a terminal. They are dark unless
--theme light (or plan_theme = "light" in the config) asks for paper.
Diagrams headless
./dbc erd # Mermaid erDiagram source for the whole connection
./dbc erd -t markdown >> docs/schema.md # … in a ```mermaid fence, which GitHub draws
./dbc -c pg erd -t png -o schema.png # the diagram as a picture (jpeg too)
./dbc erd --open # … straight into your image viewer
./dbc erd --table orders -t png -o orders.png # orders and the tables one key away
./dbc erd --table orders --depth 2 … # … two hops out (-1: all it connects to)
./dbc erd --views … # views too
./dbc erd -t png --theme light -o schema.png # on paper rather than slate
-t is mermaid (the default), markdown, png or jpeg. The pictures
are bytes: write them with -o, or pipe them. dbc refuses to print them on
a terminal. --table takes a name as the sidebar shows it (schema.name
on a connection with several schemas), and can be repeated or
comma-separated. A table that isn't there is a usage error (exit 2).
Scripts headless
-t and -o apply to scripts too (-f does not, since a script is its own
input; neither do --tx and -k, since a script runs its statements itself):
./dbc -t json script scripts/loop_params.go # one JSON array on stdout
./dbc -t csv -o report.csv script scripts/loop.go # straight to a file
./dbc script nightly_report # by name, from scripts_dir
./dbc script loop_params # a built-in example, by name
./dbc scripts -t json # the scripts and examples, as JSON
./dbc script --check nightly_report # check, don't run (exit 1 on an error)
./dbc script --check -t json ~/scripts/*.go # several, diagnostics as JSON
The argument is a file path, as it always was. If no such file exists and
the argument has no /, it is looked up in scripts_dir, with .go
optional, and then among the built-in examples (./dbc script loop_params
works in a fresh install). A file in the current directory wins over a
script of the same name, and your own script wins over an example of the
same name. Running an example says so on stderr. dbc scripts lists name,
modified time, size, description (the comment above package main, to its
first sentence) and kind: your scripts (script), then the examples no
script of yours shadows (example, with no modified time; null in JSON).
The directory and the counts go to stderr, so -t json stays one document.
--check finds names the same way, and names an example
example:loop_params.go in its output.
--check takes one or more scripts (names or paths) and runs none of them.
It prints one line per problem on stdout, as a compiler does
(file:line:col: message, with warning: before a warning), and nothing for
a clean script. It exits 1 when any script has an error and 0 when there are
only warnings, so it can gate a scripts repo in CI. -t json prints one JSON
array of {file, line, col, severity, msg} instead ([] when clean). Only
the config is read; nothing is connected.
On stdout, in a block format (text, markdown, csv, tsv), each result a
script pushes with s.Show is written the moment it is shown, so it lands in
order among the s.Print lines around it. A script can show any number of
results, so their banners count up without a total — #1, #2 — where a
multi-statement query's read 2/5; a script that shows one result still
gets its #1. With -o, or in json or html, the results are collected
and rendered together when the script finishes — so -t json yields one
array rather than a run of separate documents. A block format written with
-o holds exactly what the stream would have written.
s.Print output is progress, not data, and streams as it happens. It shares
stdout with a text table, but moves to stderr when a machine-readable format
has stdout to itself, so ./dbc -t json script … | jq parses.
Multi-statement runs
The SQL argument may hold several statements, split the same way the editor
splits them — semicolons inside strings, comments, and $$ bodies don't count.
They run in order on one connection, and every result is rendered:
./dbc "INSERT INTO cats (id, name, breed, age) VALUES (9, 'Zed', 'Tabby', 4);
SELECT breed, count(*) AS n FROM cats GROUP BY breed ORDER BY n DESC, breed"
-- 1/2 │ demo-bytdb │ 1 rows affected in 3.73ms
-- INSERT INTO cats (id, name, breed, age) VALUES (9, 'Zed', 'Tabby', 4)
rows_affected
-------------
1
-- 2/2 │ demo-bytdb │ 5 rows in 80µs
-- SELECT breed, count(*) AS n FROM cats GROUP BY breed ORDER BY n DESC, b…
breed n
---------- -
Tabby 3
Maine Coon 2
Siamese 2
Bengal 1
Sphynx 1
In text, markdown, csv and tsv on stdout, each result is written as
soon as its statement finishes, so a long run shows its progress. html and
json are one document around every result, and -o writes one file, so
those wait for the run to end. Either way the output is the same.
A run stops at the first statement that fails — later statements are skipped —
but the results before it are still written, and the error names the one that
broke (statement=2/3). Exit status is 1 for a failure, 130 for a Ctrl+C.
With -k (--keep-going) a failed statement is reported on stderr as it
happens and the run goes on to the next. Each result keeps its statement's
number (-- 3/3 after a failed 2/3), and the run exits 1 at the end if any
statement failed. A Ctrl+C or a lost connection still stops it.
The statements share one pinned database session, so session-scoped SQL means what it says across them: nothing is committed until you say so in
./dbc "BEGIN; UPDATE cats SET age = age + 1; SELECT * FROM cats; COMMIT"
and SET, PRAGMA, and temp tables set up by one statement are still there
for the next. Nothing is wrapped in a transaction for you — without a BEGIN
each statement commits on its own — unless you ask with --tx:
./dbc --tx -f backfill.sql
dbc then runs BEGIN first and COMMIT after the last statement, and on the
first failure (or a Ctrl+C) it runs ROLLBACK instead and says so on stderr. The
results that ran are still shown, but none of their changes are kept. --tx
refuses a buffer that manages its own transaction (BEGIN, COMMIT,
ROLLBACK, START TRANSACTION, …) and does not mix with -k. On MySQL, DDL
(CREATE, ALTER, DROP, …) commits implicitly, so keep it out of a --tx
run there.
Each format keeps its own shape across statements:
| Format | Multi-statement output |
|---|---|
text, markdown |
a banner per statement (position, connection, rows, time) above each table |
csv, tsv |
blocks separated by a blank line, each with its own header row — no banners to break parsing |
html |
one page, a section per statement |
json |
an array of envelopes: {"statement", "conn", "duration", "columns", "rows"}, or "rows_affected" for a non-query |
A single statement renders exactly as it always did, in every format. A
result that hit max_rows is noted on stderr, so a truncated export cannot
pass for the full set while the data stream stays clean. In json, duplicate
column names are suffixed (a, a_2) rather than silently collapsed.
Postgres server notices (RAISE NOTICE, RAISE WARNING, "table … does not
exist, skipping") go to stderr as each statement returns, one line each
(NOTICE: …), as psql prints them. A notice from a failing statement comes
before its error, and a deferred trigger's notice comes when --tx commits.
Migrations
dbc migrate applies versioned SQL migrations in the goose file format, with
goose's goose_db_version table — so a database goose has been migrating
carries straight over, and the goose binary stops being a thing to install.
./dbc -c local-pg migrate status # each file, applied or pending
./dbc -c local-pg migrate up # apply everything pending
./dbc -c local-pg migrate down # roll back the latest
./dbc -c local-pg migrate create add_users_table # new timestamped file
./dbc -c local-pg -t json migrate version # for scripts
Verbs: status, version, up, up-by-one, up-to VERSION, down,
down-to VERSION, redo, create NAME.
The migrations directory is --dir, else the connection's migrations setting
in the config, else the working directory:
[[connection]]
name = "church-dev"
driver = "postgres"
dsn = "postgres://devuser:secret@localhost:5432/church_development?sslmode=disable"
migrations = "db/migrate"
With no config file at all, --driver and --dsn name the database on the
command line — goose's whole invocation, one flag longer:
./dbc --driver postgres --dsn "$DATABASE_URL" --dir db/migrate migrate up
A migration file is NNN_name.sql with -- +goose Up and -- +goose Down
sections. Statements are split by the same scanner the editor uses, so a
$$-quoted function body is one statement without help; the goose
StatementBegin/StatementEnd block and NO TRANSACTION annotations are
honored too. Each migration runs in a transaction with its version row, so a
failure leaves neither behind. up refuses a pending migration older than the
current version — the merge-of-two-branches case — unless --allow-missing is
given, exactly as goose does.
Works on all four engines. bytdb runs DDL outside transactions, so there each statement commits on its own.
Export formats
csv, tsv, markdown, html (styled standalone page), json
(array of objects), text (aligned table) — from the Ctrl+E dialog
(clipboard or file), from scripts via s.Export, or headless via -t.
To the clipboard, html is not the page but a self-styled <table>
offered as the clipboard's HTML flavor, so it pastes into Teams, Outlook and
Docs as a table (see Copying results above). That holds for a script's
s.Export(r, "html", "") too.
The HTML page wears the same muted green as the TUI, surface for surface.
Both read the palette from theme/, so a change to those
constants reaches the app and its exports together.
License
MIT. See LICENSE.
Documentation
¶
Overview ¶
dbc — a TUI database client with Go scripting.
dbc launch the TUI dbc "SELECT * FROM cats" run one query headless (uses -c / default conn) dbc "INSERT …; SELECT …" run several statements in order, on one conn dbc -f report.sql run the SQL in a file headless dbc -f - < report.sql … or from stdin, spelled out dbc < report.sql … or from stdin, when it is a pipe or a file dbc script scripts/loop.go run a Go script headless dbc script --check NAME check a script without running it (see scriptcheck.go) dbc copy --from a --to b t copy a table between connections (see copycmd.go) dbc migrate up apply pending migrations (see migrate.go) dbc explain -a "SELECT …" show a statement's plan and findings (see explain.go) dbc web the workbench in a browser (see webcmd.go) dbc version print the version (same as --version / -v)
Headless runs are cancelable with Ctrl+C, which aborts the statement on the server and exits 130.
Flags (github.com/urfave/cli/v3, so a long name takes one dash or two, and flags may come before or after the SQL):
--config path config file (default: ./dbc.toml, ~/.config/dbc/config.toml)
-c, --conn name connection name for a one-off query
-f, --file path read the SQL to run headless from a file ("-" = stdin)
-t, --format fmt headless output format: text|csv|tsv|markdown|html|json
-o, --out file write headless output to a file instead of stdout
--tx run a headless query's statements in one transaction:
all of them commit, or none do
-k, --keep-going go on past a failed statement instead of stopping there
--demo engine which built-in demo starts active: bytdb (default) | sqlite
(also $DBC_DEMO; only applies when there is no config file)
--driver name with --dsn: run against an ad-hoc connection instead of a
--dsn string configured one (no config file needed)
--tls mode with --dsn: TLS for it — disable|prefer|require|verify-ca|
verify-full (a config file sets the same per connection)
--tls-ca path with --tls: CA certificate(s) to verify the server against
--tls-cert path with --tls: client certificate, and --tls-key its key
--tls-key path
--tls-key-password '${VAR}'
with --tls-key: the environment variable holding an
encrypted key's passphrase (single-quoted, so the shell
leaves it for dbc to read)
--dir path migrations directory for `dbc migrate`
--allow-missing let `migrate up` apply out-of-order migrations
--format and --out apply to scripts and migrate too: the results they show are rendered in the chosen format, to the chosen destination. --file is SQL input for a headless query only; script and migrate refuse it.
With no config file, dbc starts on two seeded demo connections — demo-bytdb (embedded bytdb, a file in the OS cache directory) and demo-sqlite (in-memory SQLite) — so the same query can be run against both engines. --demo picks which one starts active; the other is still there in the connection list.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package ai is dbc's AI chat assistance: a conversation with a coding agent about the query in the editor and the result it produced.
|
Package ai is dbc's AI chat assistance: a conversation with a coding agent about the query in the editor and the result it produced. |
|
aitest
Package aitest is a scripted ACP agent for testing code that uses package ai, without spawning or signing in to a real one.
|
Package aitest is a scripted ACP agent for testing code that uses package ai, without spawning or signing in to a real one. |
|
Package cats is dbc's client for the cats terminal multiplexer it may be running inside: the control socket (one JSON request per line, one response back), the event stream that socket can be upgraded to, and the separate hook socket a pane program reports its state on.
|
Package cats is dbc's client for the cats terminal multiplexer it may be running inside: the control socket (one JSON request per line, one response back), the event stream that socket can be upgraded to, and the separate hook socket a pane program reports its state on. |
|
Package clip puts text on the local system clipboard, and HTML alongside it when the caller has some, so a chat app or document editor pastes a real table rather than the markup that describes one.
|
Package clip puts text on the local system clipboard, and HTML alongside it when the caller has some, so a chat app or document editor pastes a real table rather than the markup that describes one. |
|
Package codehl classifies the spans of a fenced code block for syntax highlighting in the assistant's answers: SQL, Go, Python, JavaScript, JSON and shell.
|
Package codehl classifies the spans of a fenced code block for syntax highlighting in the assistant's answers: SQL, Go, Python, JavaScript, JSON and shell. |
|
Package connedit adds, tests, edits and removes the connections a user creates from inside dbc — dbc web's connection form and the TUI's — as opposed to the config file's, which are the file's to change.
|
Package connedit adds, tests, edits and removes the connections a user creates from inside dbc — dbc web's connection form and the TUI's — as opposed to the config file's, which are the file's to change. |
|
Package erd draws a connection's schema as an entity-relationship diagram: its tables, their columns and keys, and the foreign keys between them.
|
Package erd draws a connection's schema as an entity-relationship diagram: its tables, their columns and keys, and the foreign keys between them. |
|
Package etl moves rows between database connections — possibly on different engines — for dbc scripts: stream rows out of one connection (Reader), optionally reshape them in Go, and load them into another (Writer).
|
Package etl moves rows between database connections — possibly on different engines — for dbc scripts: stream rows out of one connection (Reader), optionally reshape them in Go, and load them into another (Writer). |
|
Package explain turns what a database says about how it will run (or did run) a statement into one engine-neutral plan tree, works out where the time or the work goes, and says what looks wrong in plain words.
|
Package explain turns what a database says about how it will run (or did run) a statement into one engine-neutral plan tree, works out where the time or the work goes, and says what looks wrong in plain words. |
|
Package migrate applies versioned SQL migrations, in the file format and with the version table that goose uses — so a project that has been migrating with the goose binary can switch to `dbc migrate` without rewriting a single file or touching its goose_db_version table.
|
Package migrate applies versioned SQL migrations, in the file format and with the version table that goose uses — so a project that has been migrating with the goose binary can switch to `dbc migrate` without rewriting a single file or touching its goose_db_version table. |
|
Package pgdocker starts a PostgreSQL server in a local Docker container and sets up the dbc connection to it — the connections menu's "Postgres in Docker…" in the TUI and in dbc web.
|
Package pgdocker starts a PostgreSQL server in a local Docker container and sets up the dbc connection to it — the connections menu's "Postgres in Docker…" in the TUI and in dbc web. |
|
Package raster is the drawing kit dbc's pictures are painted with: the Go fonts, text measured and fitted in CSS pixels, and anti-aliased filled shapes and strokes — all pure Go, on golang.org/x/image's vector rasterizer.
|
Package raster is the drawing kit dbc's pictures are painted with: the Go fonts, text measured and fitted in CSS pixels, and anti-aliased filled shapes and strokes — all pure Go, on golang.org/x/image's vector rasterizer. |
|
Package script runs user Go scripts with the yaegi interpreter, exposing the sdb API plus the Go standard library.
|
Package script runs user Go scripts with the yaegi interpreter, exposing the sdb API plus the Go standard library. |
|
Package scripts carries dbc's sample scripts and new-script templates inside the binary, so a fresh install has working examples without a checkout, and they never go stale against the sdb API of the binary that ships them.
|
Package scripts carries dbc's sample scripts and new-script templates inside the binary, so a fresh install has working examples without a checkout, and they never go stale against the sdb API of the binary that ships them. |
|
Package sdb is the API surface handed to Go scripts.
|
Package sdb is the API surface handed to Go scripts. |
|
sdbapi
Package sdbapi describes the sdb package — what a script can call on s *sdb.S, and the fields and methods of the types it hands back — as data, for the script editor's completion and hover in dbc web.
|
Package sdbapi describes the sdb package — what a script can call on s *sdb.S, and the fields and methods of the types it hands back — as data, for the script editor's completion and hover in dbc web. |
|
Package sqlcomplete suggests what to type next in a SQL buffer: the columns, tables and join conditions the connection's schema holds, and the dialect's keywords, functions and types.
|
Package sqlcomplete suggests what to type next in a SQL buffer: the columns, tables and join conditions the connection's schema holds, and the dialect's keywords, functions and types. |
|
Package sqlsplit splits a SQL buffer into individual statements while tracking byte offsets, so the editor can run just the statement under the cursor.
|
Package sqlsplit splits a SQL buffer into individual statements while tracking byte offsets, so the editor can run just the statement under the cursor. |
|
Package theme holds the muted green palette, ported from cdx (GoNotes): warm dark gray-green surfaces with a single green accent.
|
Package theme holds the muted green palette, ported from cdx (GoNotes): warm dark gray-green surfaces with a single green accent. |
|
Package tui is dbc's Bubble Tea v2 terminal interface: connections and tables sidebar, SQL editor, results grid, log, AI assistant, with the mouse as a first-class input.
|
Package tui is dbc's Bubble Tea v2 terminal interface: connections and tables sidebar, SQL editor, results grid, log, AI assistant, with the mouse as a first-class input. |
|
Package userdata is the state dbc keeps for the user between sessions: the query history, the editor's per-database SQL consoles (and the single editor buffer they replaced), the assistant's conversations and the terminal UI's schema picks, all under ~/.config/dbc.
|
Package userdata is the state dbc keeps for the user between sessions: the query history, the editor's per-database SQL consoles (and the single editor buffer they replaced), the assistant's conversations and the terminal UI's schema picks, all under ~/.config/dbc. |
|
Package version holds dbc's release version.
|
Package version holds dbc's release version. |
|
Package web is `dbc web`: the workbench in a browser.
|
Package web is `dbc web`: the workbench in a browser. |
|
pages
Package pages renders dbc web's HTML with element: the workbench shell, the result table the page swaps in after a run, and the sign-in notice.
|
Package pages renders dbc web's HTML with element: the workbench shell, the result table the page swaps in after a run, and the sign-in notice. |
|
Package workspace is one person's working state against the databases, and the rules for changing it — with no UI in it.
|
Package workspace is one person's working state against the databases, and the rules for changing it — with no UI in it. |