A foreign key is a place you can go.
Most database tools are SQL clients that happen to render a grid. tablewalk starts somewhere else: it reads the catalog on connect and turns the schema into something you walk — row to row, table to table, both directions. Built for the hours you spend working out how the data fits together — the unfamiliar schema, the bug that starts with one value, the migration you are not sure about. The same machinery answers over MCP, so your agent sees what you see.
SQLite · Postgres · MySQL — no config, no build step, read-only by default
In the development loop
The questions between writing code and trusting it
Schema questions do not arrive as queries. They arrive as
what is this table for, what breaks if I drop this column,
where did this value come from — and the usual answer is twenty minutes of
\dt, ORM files and guessing. Each of these is a thing tablewalk answers
directly, in a browser or over MCP, with the same words either way.
Orient in a schema you did not write
Sixty-five tables open as a paragraph and a diagram of the hubs, not a hairball: what the database centres on, where the events are, how deep the references go. An agent gets the same thing in one call.
Model before you write the code
Nullability, defaults, both directions of every key, and the values a column will
actually accept. profile saying a column is 52% null is what decides
LEFT against INNER — before the query exists, not after it
returns the wrong count.
Start from a value and find the row
One string out of a log or a bug report: find sweeps the text columns
of every table and says which table mentions it — then the record, then everything
pointing at the record. The trail lives in the URL, so it pastes into the ticket
intact.
Know what breaks before you change it
Every row that points at this one, counted, with its ON DELETE rule
beside it — and a delete that reports its impact before it will act. The difference
between an informed migration and a hopeful one.
Check the shape in CI
--lint reads the catalog for what it will cost: keyless tables,
foreign keys with no index behind them, references with no delete rule, timestamps
with no zone. --fail-on high makes it a build step, and
--diff staging answers whether two environments are still the same
shape.
Hand the position to an agent
Copy where you are — the question, the shape, the walk that got there — and paste it into a chat. The agent’s answers come back carrying links you can open. Two parties, one surface.
# a schema brief your agent reads for free, committed with the code npx tablewalk postgres://localhost/app --export md >> CLAUDE.md # and the same catalog, read for what it costs npx tablewalk postgres://localhost/app --lint --fail-on high
The idea
What points at this row?
Foreign keys are stored once and readable in both directions — the direction almost nothing surfaces is the interesting one. tablewalk counts every row elsewhere that points at the one you are looking at, and lets you click through. Each step is a real filter you can see and edit, and the whole trail lives in the URL, so a walk can be pasted into a ticket and arrive intact.
And rows are named the way a person would name them. A table whose best label is its
own key asks one hop further before settling for a number — an employee is
Maeve Lindqvist, found through party_id.display_name,
not 30. An unfamiliar schema opens with a paragraph, not a hairball:
what it centres on, where the events are, how deep the references go.
Query language
Compiles to structure, never to SQL text
The bar takes a small language: the table first, because that is how people say it out loud. Dotted paths walk references. Dates are phrases. Every value arrives at the database as a bound parameter — there is no path from what you type to what the database parses as syntax, so the dangerous shape is unrepresentable.
Press SQL and see exactly what your query compiled to. The tool teaches the database rather than hiding it — and a typo comes back with a suggestion, not an empty grid that looks like an empty table.
Records & pages
One record, and everything about it
Every table gets a record layout and a suggested page for free. A page composes the record with counts and lists of what points at it — including sections about records it points at: “warehouses at this work order’s site” is three clicks in the builder. Layouts and pages persist server-side in files you can read, diff, and commit, so work built in one browser is offered in every other.
Related lists sit in a tab strip and only the tab you open is fetched — six things pointing at a record is otherwise six queries and a scroll. Page view is sticky: a child row or a followed reference opens as a page too, using whichever page you built for that table. In write mode the record’s own fields become editors in place, with the statement shown before it runs. Pin a page and the home screen offers it, beside the records you were last looking at.
Walk back intact
The breadcrumb is a stack of real filters. Walking back restores the filter you walked away from — the detail most browsers lose.
Links that say what they open
#/app/@customer-desk~1885 — the connection, the page by name, the
record. Paste it into a ticket and it arrives intact.
Diagrams that open readable
A dense schema opens on its most-connected hubs, not a 140-edge hairball; a table's neighbourhood opens centred on the table, at a size you can read.
Read-only, and meant
The SQLite handle opens read-only; Postgres sets a read-only transaction default. Writes are an explicit opt-in, per connection, in your own config file.
Nothing to install around it
Hand-written ES modules, no framework, no bundler. npx tablewalk is the
whole deployment; Docker and a desktop app exist when it isn’t.
For coding agents
The same machinery, spoken over MCP
During development an agent guesses at your schema from ORM models. The actual
foreign-key graph — both directions — is exactly what it lacks and exactly what
tablewalk computes. tablewalk --mcp serves seventeen tools over stdio:
fourteen that only read — including lint for what a shape will cost,
diff for whether two connections still match, and order for
what has to exist before what — writes only where your config says
writable, and
a delete that reports its impact before it will act. Every answer says what it cost
in rows and milliseconds, so context spend is a number rather than a surprise.
Giving an agent SQL is commodity — a subprocess does that. What is not commodity is the shape: which tables are hubs, what points back at this row, what a column is allowed to hold, what a delete would take with it. That is the half an agent cannot get from the repo, and it is the half that decides whether the code it writes is right.
And it is the same surface you are on. The answers carry links that open the browser at that row; the agent console shows you what your agent asked and what it cost; copying a walk sends your position the other way; layouts and named queries are files in your repo that both of you read. Most agent tooling is a one-way pipe into a context window. This is a workspace two parties share.
One boundary, stated plainly: rows an agent reads leave your machine. Point it at development databases. This is a real session against the 65-table demo:
is there an employee table
called table employee · 13ms
Yes — employee exists, with 9 columns. The interesting bit: it has no name column. An employee’s identity lives on party via the required party_id — the identity-reference pattern the record tool resolves through when it shows a row’s human name. It is one of the schema’s hubs: 17 foreign keys point back at it — sales orders via owner_employee_id, purchase orders via both raised_by and approved_by, tickets via assigned_to… All are ON DELETE no action, so an employee with history cannot be deleted out from under those references.
anything odd about the open tickets?
called query ticket status = in_progress and resolved_at is not empty
show reference, resolved_at, assigned_to.party_id.display_name · 14ms · 14 rows
Yes — 14 tickets are still in_progress with a resolved_at date already set. Either the status transition is not firing or something writes resolved_at early. The two-hop path assigned_to.party_id.display_name compiled to two LEFT JOINs, every value bound:
| reference | resolved_at | assigned to |
|---|---|---|
| TKT-9001 | 2025-07-16 | Rae Haddad |
| TKT-9014 | 2025-08-20 | Maeve Lindqvist |
| TKT-9044 | 2025-08-15 | Maeve Lindqvist |
walk me into sales order 5
called record sales_order {id: 5} · 14ms
SO-7004 — shipped, $17,626, customer 9. Pointed at by 2 invoices, 5 order lines, and 1 shipment — and each relationship arrives with the query that fetches its rows (sales_order_line order_id = 5), so the walk continues without guessing a column name.
{
"mcpServers": {
"tablewalk": { "command": "npx", "args": ["tablewalk", "--mcp", "--config", "./tablewalk.json"] }
}
}
Get started
Point it at a database
# a file npx tablewalk mydata.sqlite # a server npx tablewalk postgres://user:pass@localhost/appdb # several, from a config the app never writes secrets into npx tablewalk --config ./tablewalk.json
MIT licensed. Source, issues, and the full README are on GitHub.