Your agent is confident, and it is blind
AI coding assistants write SQL well, and they write it blind. They don't
know your schema, your indexes, or your constraints, so they generate
migrations that take ACCESS EXCLUSIVE locks on busy tables, add
varchar(255) columns when the rest of the schema uses text, or
create an index that already exists under a different name. Catching
that in review takes a person who reads DDL and remembers what
PostgreSQL does with it. That person is not always around, and the
number of migrations per week keeps going up.
Most PostgreSQL MCP servers answer this by asking for a database connection, and the administrative tools push you toward a superuser role. That trades one problem for a worse one. We have already seen where it leads: production databases wiped by agents, and SQL injection in MCP servers that were supposed to be read-only.
The model does not need to query your database. It needs to understand it. That knowledge is structural: it changes when you deploy a migration, not between queries. So capture it once, put it in a file, and hand the file around.
What that buys you, plainly: two commands and your agent stops guessing. Nobody new needs production credentials. The migration check runs in CI, where an argument about whether a change is safe gets settled cheaply. The snapshot is a file you own, so none of it depends on a service being up.
Two commands, then everyone is set up
dryrun is a CLI and an MCP server, deliberately separate. The CLI is
the only part that ever touches PostgreSQL. It introspects the catalog
and writes a snapshot. Everything after that reads the snapshot.
dryrun init --db "$DATABASE_URL"
dryrun setup
init captures: schema, planner statistics, activity counters, and — if
pg_stat_statements is enabled — the query workload too, in the same
pass.
setup looks at what is already in the repo — a .claude/ directory,
.cursor/, .zed/, a Codex config in your home directory — and offers
to wire each one to the MCP server. For Claude Code and Cursor it writes
the server entry into .mcp.json and .cursor/mcp.json directly. Codex
and Zed keep their config somewhere it should not be edited behind your
back, so those get a paste-ready snippet printed instead. Either way it
adds a short block to AGENTS.md or CLAUDE.md:
This repo uses Postgres; the schema is captured in
.dryrun/. Do not guess columns, indexes, or types — call thedryrunMCP server to inspect the schema, validate queries, and check migrations before writing SQL.
Commit dryrun.toml and the config files it wrote, and the next person
to clone the repo has an agent that knows the schema without asking
anyone for anything. Non-interactive, for a dotfiles or CI path:
dryrun setup --agents=all.
If you would rather wire it by hand:
claude mcp add dryrun -- npx -y @boringsql/dryrun mcp-serve
The server auto-discovers the project's snapshot, so there is nothing further to configure.
If you have one database, you are done here. Everything below is either optional depth or the story for teams with replicas — read it when you need it.
It runs against the database you already have
dryrun installs nothing on the server. There is no extension to compile, no collector to deploy, no agent process, and nothing that dials out from your infrastructure. It opens one read-only connection, reads the catalog, and writes a file. That is why it works unchanged on managed Postgres, where you could not install a collector even if you wanted to:
- Amazon RDS and Aurora
- Google Cloud SQL and AlloyDB
- Azure Database for PostgreSQL
- Neon, Supabase, and anything else serving stock PostgreSQL 14+
The whole privilege requirement is a login role holding two roles that
ship with PostgreSQL: pg_read_all_data for the catalog and
pg_read_all_stats for the statistics views. Both are grantable by the
default admin user every managed platform hands you — no superuser
anywhere in the picture. There is a dryrun-readonly-role.sql in the
repo that creates exactly that and nothing more.
dryrun also declines to be pointed at a superuser role:
Error: role "postgres" is privileged (superuser, replication, bypassrls);
dryrun refuses to read production with it.
Use a read-only role (see dryrun-readonly-role.sql) or pass --allow-privileged
For query stats specifically you need the pg_stat_statements
extension, which is standard equipment but sometimes off by default: on
RDS and Cloud SQL it is a parameter-group or database-flag change, on
Supabase and Neon it is generally already on. Without it, everything else
still works — dryrun records that the extension was unavailable and
carries on.
Share the capture, not the credentials
One person with database access captures. Everyone else, humans and agents alike, gets full schema intelligence without a connection string. CI runners included.
# The person with credentials
dryrun init --db "$DATABASE_URL"
dryrun snapshot push --to-path ./snapshots --all
# Everyone else
dryrun snapshot pull --from-path ./snapshots --all
dryrun lint
Snapshots are content-addressed, so pushing the same capture twice is a no-op and a shared history deduplicates. A shared directory works, as does a dedicated git repo. So does any OCI registry, and that is usually the better choice: GitHub Container Registry, Google Artifact Registry, Amazon ECR, or a self-hosted one already handles authentication, retention, and access control, so there is no server to run.
dryrun remote add ghcr --ref ghcr.io/myorg/dryrun --default
dryrun snapshot take --push
Consumers pull only the latest capture by default, so a cold CI runner stays cheap no matter how much history the registry holds.
The review comments you would have written
lint is the fastest way to see whether any of this is worth your time.
Point it at a schema and it reports what a careful reviewer would, with
the reasoning attached:
[ERROR] public.task_comments: table has no primary key
fix: Without a primary key, there is no guaranteed unique row identifier.
Replication, upserts, and ORM mappings require one.
[ERROR] public.tasks.project_id: FK 'tasks_project_id_fkey' on column(s) (project_id) has no covering index
fix: Add an index on FK columns to avoid sequential scans on DELETE/UPDATE
of the referenced table.
[WARN ] public.users.email: column 'email' uses character varying(255) - prefer text
fix: Column 'public.users.email' uses varchar. In PostgreSQL, text and varchar
have identical performance. varchar(N) only adds a length check.
[WARN ] public.projects.created_at: timestamp column 'created_at' uses timestamp
without time zone instead of timestamptz
fix: use timestamptz for timestamp columns
[WARN ] public.projects.project_id: PK/FK column 'project_id' uses integer - risk of
32-bit overflow
fix: use bigint for PK and FK columns
26 finding(s): 6 error, 20 warning, 0 info (13 tables checked)
That is a real run against the sample schema in the repo, abridged to five of the twenty-six findings. You can reproduce it in about thirty seconds with no database at all:
git clone https://github.com/boringsql/dryrun.git
cd dryrun/examples/demo
dryrun lintWhat the database is actually spending its time on
Schema tells you what is possible. Query stats tell you what is
happening. dryrun captures pg_stat_statements as its own stream, per
node, into the same history — init does it automatically, and you can
take a fresh one whenever you want a new window:
dryrun snapshot capture --from "$DATABASE_URL" --label primary --streams query
dryrun snapshot diff --latest --kind query --node primaryQuery stats: node=primary, window 20s (2026-08-23 15:28:38 -> 2026-08-23 15:28:59)
+92972 calls, +10899 ms total
STATUS CALLS TIME(ms) MEAN(ms) QUERY
grew +18586 +9033 0.49<-0.50 UPDATE pgbench_branches SET bbalance = bbalance + $1 WHERE …
grew +18586 +1249 0.07<-0.06 UPDATE pgbench_tellers SET tbalance = tbalance + $1 WHERE t…
new +25 +342 13.67 SELECT pgbench_accounts.bid, count(*) FROM pgbench_accounts…
grew +18586 +187 0.01<-0.01 UPDATE pgbench_accounts SET abalance = abalance + $1 WHERE …
grew +18586 +44 0.00<-0.00 SELECT abalance FROM pgbench_accounts WHERE aid = $1
(39 unchanged)
Two things make that readable. First, the shapes: an ORM generates a hundred variants of one query, and dryrun normalizes and groups them so you see the query, not the noise. Parameter values are never stored — what lands in the snapshot is the canonical shape.
Second, the mean column. 0.49<-0.50 is this window against the last
one, which is the comparison you actually want; pg_stat_statements'
own mean_exec_time averages over everything since the last reset and
barely moves when a query gets six times slower this week. The new row
above stands out for the same reason — 13.67 ms a call, on 25 calls, in
a window where everything else is under a millisecond.
And because cumulative counters are easy to subtract wrongly, the diff
refuses rather than guesses. It will not subtract across a
pg_stat_statements reset, a regrouping, two different labels, or two
machines answering under one label, and it names which one stopped it. A
shape whose counter went backwards — an eviction under cache pressure
can do that — is marked unsubtractable instead of being reported as an
improvement.
The MCP list_top_queries tool reads the same captures, so the agent
looking at your slow endpoint can see which shape is behind it, tagged
with the node that reported it and never averaged across a primary and a
replica.
What else you can do with it
AI-assisted database development
With the MCP server wired up, your assistant gains schema awareness. It stops hallucinating column names, suggests indexes against real statistics, and reads the same numbers you do. When it is wrong, the tools hand it the correction rather than a complaint: the fixed query, the safe migration, the JOIN it was reaching for.
Pre-deploy migration review
Ask the agent to run the DDL through check_migration before it opens
the pull request, and the lock and rewrite analysis comes back as part of
the diff you are already reading. It flags the operations that behave
differently than they read: a column type change that rewrites the table, a
constraint added without NOT VALID, an index built without
CONCURRENTLY, a SET NOT NULL that scans every row. Each one comes
back with the lock it takes and the safer version as SQL.
Watching a fleet
Declare the nodes in dryrun.toml — init writes the block for you,
commented out — and capture them in one pass. Roles are asserted before
anything is read, so a replica's counters never get filed under the
primary, and --check connects to every node and reports what capture
would do without writing anything: the preflight you want in CI before
a cron job runs unattended.
dryrun snapshot capture --all --check
dryrun snapshot capture --all --due --push
Per-node counters are what expose routing imbalances and seq_scan hotspots that a primary-only capture cannot see.
Schema governance across teams
Enforce naming conventions, require timestamps, ban varchar in favor
of text, and flag tables without primary keys. One dryrun.toml keeps
the whole team aligned, and the rules run without a database, so they
run in CI.
The next step: Hindsight
Start with the CLI. It is free and open source, and it answers the questions about your database as it is right now. Most teams never need anything else.
The questions it cannot answer are the ones about time. A snapshot is a point; locally, the limit is how much history you keep and remember to compare. Is this table growing faster than it was last month? Has that index been unused, or did we just add it? Did this bloat start before or after the migration we shipped two weeks ago? Your agent cannot answer them either, for a more basic reason: it forgets your database at the end of every session. Those questions need the series, and the series needs somewhere to live that is not one laptop.
Hindsight is that place. Point the same dryrun snapshot push at a hosted workspace instead of a directory, and the
history accumulates into a standing read on the database. A health
score, a weekly digest of what changed and what it affected, and a feed
of findings: bloat with reclaimable bytes, indexes that stopped taking
scans, growth on track to become a capacity problem, each anchored to
the capture where the condition began.
It keeps dryrun's constraint exactly as it is. Hindsight never connects to your database, runs no collector, and holds no credentials. It stores what the CLI already computed and pushed, for the same reason the CLI works in environments where a vendor connection is never getting approved.
Hindsight is in alpha and free while it is. If you already run
dryrun, adding it is two commands: dryrun remote add hindsight --type http, then dryrun snapshot push --all --remote hindsight. Push from CI
on every deploy and the feed starts earning its place within a couple of
weeks. Read more about Hindsight.