firedrill
Postgres restore verification

You don't have backups.
You have hopes.

firedrill takes a Postgres backup and actually restores it into a disposable, version-matched container — then proves the result is usable. It fails your build the day a backup stops being restorable, not the day you need it.

A verification that could not run never reports as passing

Inspection record

PASS
restored in 2.4s · 0 findings · tier full
fetchok · sha256 verified
inspectok · custom v1.15.0
targetok · postgres:16
restoreok · exit 0
structureok · 8 objects
volumeok · 2,000 rows
semanticsok · 1 check
integrityok · 1 sequence

A rung that could not run reads n/a, never a tick

The failure is never “we had no backup”.

It is always one of these, discovered at the worst possible moment. Every one is invisible to a backup job and obvious to a backup restore. That gap is the entire product.

What went wrongWhy nobody noticed
pg_dump exited 0 but a full disk truncated the outputexit code 0
The dump was of a replica that had stopped replicatingit ran nightly, without error
An extension (postgis, pgvector, citext) is absent on the restore hostnever restored anywhere else
Roles named by OWNER TO do not exist on the targetpg_restore warns and continues
The glibc/ICU collation differs, so text indexes sort differentlywrong rows, no error at all
A sequence sits below max(id)first insert after failover collides
Restore takes 14 hours; the stated RTO is 1 hournever measured

A ladder, and every rung reports for itself.

Each rung is independently reportable, and a rung that cannot run says so rather than passing. That distinction is the whole design: a tool that says OK because it silently skipped the restore is worse than no tool, because it manufactures confidence.

Fetch

Local path, presigned URL, or S3. Size and checksum are verified before anything is restored — a dump that arrives truncated but plausible never reaches a container.

Restore

Into a disposable Postgres of the dump's own major version, read out of the archive header in pure Python. pg_restore's stderr is classified; warnings are findings, not noise.

Structure

The restored catalog against a committed reference — one line per object, sorted, diffable in review.

Volume

Row counts against a floor, or against the last known-good restore. A table that lost 90% of its rows restores perfectly.

Semantics

Your own smoke queries. “There is at least one order from the last 7 days” catches a stale-replica dump that every structural check passes.

Integrity

The checks only a restore can make: sequences ahead of max(id), and collation — the silent one that returns wrong rows with no error.

Time

Every stage timed against a stated RTO budget, and measured against the last good run. RTO becomes a trend you have measured, not a number you claimed.

Things that were measured, not assumed.

Each of these changed the design. They are here because reasoning about Postgres gets you a plausible answer, and a restore tool cannot ship a plausible answer.

Collation · PG 16

musl reports no collation version at all

Not a different one — none. So an alpine target cannot tell you whether text indexes sort like production's, and neither can Postgres. That is two findings, not one.

postgres:16         2.41
postgres:16-alpine  (empty)
Catalog · PG 16 vs 18

PG18 makes NOT NULL a catalog row

A schema reference taken on 16 called every not-null column “drift” on 18. The column line already records not-null, so those rows are a duplicate representation — excluded, and one reference is now portable across majors.

+ constraint|public.customer.customer_email_not_null|n
+ constraint|public.customer.customer_id_not_null|n
Sampling · PG 16

A table-scoped data restore leaves setval behind

2,000 rows arrive and the sequence stays at 1. Checking sequences under the sample tier would report a failure about firedrill's own sampling rather than about the backup — the most damaging kind of false positive.

rows 2000  ·  sequence 1
Exit codes · the tool itself

docker info exits 0 with no server version

On a CI runner with an unreachable daemon. The availability probe trusted the exit code and called a dead daemon usable — the exact bug this tool exists to complain about, in its own front door.

rc=0  ServerVersion=""
Exit codes · PG 16 & 18

The plan said warnings with exit 0. It was exit 1.

Measured on both majors, custom format, in every broken case. The exit code is still insufficient — one bit never says which failure — so stderr classifies and the code is a backstop.

pg_restore: warning: errors ignored on restore: 1
exit 1
Field test · pagila

13 sequences, 0 checked, report green

pagila links every sequence through the column DEFAULT, with no OWNED BY anywhere — and pg_get_serial_sequence resolves none of them. The sequence check examined nothing and said so as “0 sequence(s)”, which reads like “fine”. Found by restoring databases this project did not write.

present 13  ·  linked 0  →  now 9 of 13
Field test · pagila, re-run

A materialized view that ships permanently empty

pagila's own schema defines rental_by_category as a materialized view. Neither pagila-schema.sql nor pagila-data.sql ever refreshes it, so a stock, unmutated restore genuinely holds zero rows there — a query against it raises on every fresh load, healthy or not. Found the same way as the sequence bug above: restoring a database this project did not write, not by reasoning about one it did.

MATVIEW_UNPOPULATED  1 materialized view(s) hold no data
Toolchain · Windows runners

bash -n exits 1 with empty stderr

Because bash there is the WSL stub. A failure with no message at all is the tell. Probe a tool before trusting its exit code — the same lesson, twice, in one project.

bash -n <<< ':'   →   rc=1, stderr=""
Mutation matrix · pagila, re-run

Eleven ways to break a stranger's schema, eleven caught

The synthetic corpus is written by the same person as the checks — precise, and unable to find the case its author did not think of. This breaks a real, unrelated 70-table schema one way at a time instead: drop a trigger, a function, a view, an index, a column; empty a table; walk a sequence back; backdate a year of rows; loosen a constraint; enable row-level security. Every mutation produced a finding, and the one restore nobody touched stayed silent.

11 mutation(s)  ·  11 caught  ·  healthy: silent

Every fixture below restores with zero errors.

That is measured, not claimed. pg_restore is perfectly happy with all of them — which is the entire argument for the ladder. Each is asserted in both directions, because a false positive costs exactly what a false negative costs: a tool that cries wolf gets muted, and a muted tool is worse than none because it still looks like coverage.

BackupWhat is wrong with itCaught by
healthynothing— exit 0
truncated_datacut off mid-restoreARCHIVE_TRUNCATED
truncated_headerunreadable headerARCHIVE_UNREADABLE · no container started
missing_roleOWNER TO a role that is absentROLE_ABSENT
missing_extensionhstore absent from the target imageEXTENSION_ABSENT
empty_databasea dump of the wrong databaseEMPTY_RESTORE
missing_indexone index gone; data intactSTRUCTURE_MISSING
volume_drop99% of rows deleted before the dumpVOLUME_BELOW_MINIMUM
stale_replicaschema and counts perfect, data a year oldSEMANTICS_FAILED
sequence_behindsetval below max(id)SEQUENCE_BEHIND
collation (musl target)text indexes sort differentlyCOLLATION_MISMATCH

A nightly restore drill, in ten lines.

No Postgres client on the host: the archive header is parsed in pure Python and every database operation happens inside the target container, which is what makes the version matching real.

Locally · v0.1.0 on PyPI

$ pip install firedrill
$ firedrill run backups/nightly.dump

  [ok  ] fetch      13,572 bytes
  [ok  ] restore    exit 0
  [FAIL] semantics  1 check
  [n/a ] structure  no reference configured

  FAIL — recent orders exist:
       expected > 0, got 0

In CI

- uses: MaXiMo000/firedrill@v0
  with:
    config: firedrill.yml
    rto: 45m
    history: firedrill-history.json

Fails the build when the drill fails and when it could not run at all — “we did not verify” must never be quieter than “we verified and it was fine”. Publishes the report before failing, so a red build is an actionable one.

What it will never do.

Never take backupspgBackRest and WAL-G are excellent. A tool that both takes and verifies its own backups is grading its own homework.
Never write to productionUnder any flag, ever. The only restore target is one it created itself and destroys in a finally.
Never print your dataSmoke queries return shapes — counts, booleans, comparisons. The config schema cannot express a check whose result is echoed.
No SaaS, no agent, no AIIt reports. A human decides.