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.

$npx tablewalk mydata.sqlite
tablewalk opening a 65-table schema: a prose summary of what the database centres on, and a diagram of its most-connected tables

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.

customerHarbour Freightinvoice customer_id = 11001invoice_line invoice_id = 1001

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.

A record page: the employee named through its party reference, every relationship counted in a ranked rail beside the fields

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.

A query using a dotted path, its compiled SQL with a LEFT JOIN shown above the results, every value bound

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:

claude · tablewalk --mcp · erp (65 tables)

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:

referenceresolved_atassigned to
TKT-90012025-07-16Rae Haddad
TKT-90142025-08-20Maeve Lindqvist
TKT-90442025-08-15Maeve 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.