{"id":816,"date":"2026-10-31T05:00:42","date_gmt":"2026-10-31T05:00:42","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/postgres-restore-drill-pg-dump-green-exit-code-proves-little\/"},"modified":"2026-10-31T05:00:42","modified_gmt":"2026-10-31T05:00:42","slug":"postgres-restore-drill-pg-dump-green-exit-code-proves-little","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/postgres-restore-drill-pg-dump-green-exit-code-proves-little\/","title":{"rendered":"Postgres Restore Drill: What a Green pg_dump Exit Code Proves"},"content":{"rendered":"<p>Short version for the impatient: a <code>pg_dump<\/code> that exits with code 0 tells you the dump finished. It doesn&rsquo;t tell you the dump restores, and it says nothing about whether the restored database has your data in it. The only way to know is to restore it somewhere disposable and look.<\/p>\n<p>I&rsquo;ve been thinking about this since two Postgres announcements landed on postgresql.org in the same week. <a href=\"https:\/\/www.postgresql.org\/about\/news\/backupdrill-open-source-supabase-postgres-backups-with-automated-restore-drills-3413\/\" rel=\"nofollow noopener\" target=\"_blank\">BackupDrill<\/a> is an open-source CLI for Supabase projects that restores each backup into a throwaway container. <a href=\"https:\/\/www.postgresql.org\/about\/news\/safegrd-postgresql-backups-that-are-restored-and-checked-on-a-schedule-3414\/\" rel=\"nofollow noopener\" target=\"_blank\">SafeGrd<\/a> does a similar thing as a service. Two separate projects shipping the same idea in a week tells me a lot of people are tired of finding out about broken backups on the worst possible day. I haven&rsquo;t used either one yet. What follows is the DIY version I&rsquo;d build first, so you can judge what the tools add.<\/p>\n<h2 id=\"what-a-green-pg_dump-actually-proves\">What a green pg_dump actually proves<\/h2>\n<p>Not much. When <code>pg_dump<\/code> exits cleanly, you know it connected, read the catalogs, streamed table data, and wrote an archive to disk. The <a href=\"https:\/\/www.postgresql.org\/docs\/current\/app-pgdump.html\" rel=\"nofollow noopener\" target=\"_blank\">pg_dump documentation<\/a> is clear that it takes a consistent snapshot, which is great, but consistent isn&rsquo;t the same as restorable.<\/p>\n<p>Here are the ways a successful dump still fails you. The archive can be truncated if the disk filled up and your script ignored the error from a pipe. The dump can come from a role that couldn&rsquo;t see some schemas, so those schemas are silently missing. A dump from a newer major version can&rsquo;t be restored by an older <code>pg_restore<\/code>, so a staging box on an old version can tell you nothing about a production archive. And if your cron job redirects errors to a log nobody reads, a run that dumped zero rows looks identical to a good one.<\/p>\n<p>Notice the pattern. Every one of those failures happens at dump time and surfaces at restore time, which is months later. That gap is the whole problem.<\/p>\n<p>Here&rsquo;s what BackupDrill says its drill checks, according to the announcement: the archive checksum, that <code>pg_restore<\/code> completed, that post-data objects like indexes, constraints and triggers were created, that every table is present, and that tables which held data aren&rsquo;t empty. It also records how long the restore took. I like that list because it&rsquo;s specific. It goes past &ldquo;the command returned 0&rdquo; and asks what a restored database should look like.<\/p>\n<h2 id=\"a-restore-drill-in-about-twenty-lines\">A restore drill in about twenty lines<\/h2>\n<p>You don&rsquo;t need a product for the first version. You need Docker, a dump file in custom format, and a script that fails loudly. Custom format (<code>pg_dump -Fc<\/code>) matters here, because <code>pg_restore<\/code> can list and selectively restore from it, and plain SQL dumps can&rsquo;t do that.<\/p>\n<pre><code class=\"language-bash\">#!\/usr\/bin\/env bash\n# restore-drill.sh \/path\/to\/backup.dump\nset -euo pipefail\n\nDUMP=&quot;${1:?path to a pg_dump -Fc file}&quot;\nNAME=&quot;drill-$$&quot;\n\ndocker run -d --rm --name &quot;$NAME&quot; -e POSTGRES_PASSWORD=drill postgres:17 &gt;\/dev\/null\ntrap 'docker stop &quot;$NAME&quot; &gt;\/dev\/null' EXIT\n\nuntil docker exec &quot;$NAME&quot; pg_isready -U postgres &gt;\/dev\/null 2&gt;&amp;1; do sleep 1; done\nsleep 3   # the image runs a temporary server first; see the gotcha below\n\ndocker exec &quot;$NAME&quot; createdb -U postgres drill\n\nstart=$(date +%s)\ndocker exec -i &quot;$NAME&quot; pg_restore -U postgres -d drill \\\n  --exit-on-error --no-owner &lt; &quot;$DUMP&quot;\necho &quot;restore took $(( $(date +%s) - start ))s&quot;\n\ndocker exec &quot;$NAME&quot; psql -U postgres -d drill -At -f - &lt;&lt;'SQL'\nselect 'tables', count(*) from pg_tables where schemaname = 'public';\nselect 'indexes', count(*) from pg_indexes where schemaname = 'public';\nselect 'triggers', count(*) from pg_trigger where not tgisinternal;\nSQL\n<\/code><\/pre>\n<p>A few choices in there deserve a defense. <code>--exit-on-error<\/code> makes <code>pg_restore<\/code> stop at the first failure, instead of its default of printing errors and carrying on, which is how a half-restored database ends up looking fine. <code>--no-owner<\/code> avoids a flood of errors about roles that exist in production and not in the throwaway container. You lose the ownership check, and I think that&rsquo;s an acceptable trade for a drill. The <a href=\"https:\/\/www.postgresql.org\/docs\/current\/app-pgrestore.html\" rel=\"nofollow noopener\" target=\"_blank\">pg_restore reference<\/a> lists both flags if you want to read the details.<\/p>\n<p>Now the gotcha I promised. The official <code>postgres<\/code> Docker image starts a temporary server to run its init scripts, shuts it down, then starts the real one. <code>pg_isready<\/code> can report success against the temporary server, and your next command then lands in the gap between the two. The <code>sleep 3<\/code> is ugly. A cleaner fix is to retry <code>createdb<\/code> in a loop, and I&rsquo;d do that in anything that runs unattended.<\/p>\n<p>Match the major version of the container to the server you dump from. Restoring a dump from Postgres 17 into a 15 container is the kind of mismatch a drill should catch, and that&rsquo;s the point, but then the failure tells you about your drill and not about your backup.<\/p>\n<h2 id=\"counts-are-not-enough\">Counts are not enough<\/h2>\n<p>The script above prints table, index and trigger counts, and those numbers only help if you know what they should be. So record the expected values at dump time and compare. The cheapest version is to write a small manifest next to every dump:<\/p>\n<pre><code class=\"language-bash\">psql &quot;$DATABASE_URL&quot; -At -c &quot;\n  select 'orders', count(*) from orders\n  union all select 'customers', count(*) from customers\n  union all select 'invoices', count(*) from invoices\n&quot; &gt; backup-2026-10-29.manifest\n<\/code><\/pre>\n<p>After the drill restores, run the same query against the container and <code>diff<\/code> the two files. Use exact <code>count(*)<\/code> for the tables that matter and skip <code>pg_stat_user_tables.n_live_tup<\/code>, which is an estimate and will drift from the real number. The numbers won&rsquo;t match if rows were written between the dump and the manifest, so generate both back to back on a quiet system, or accept a tolerance and say so in the script.<\/p>\n<p>Table counts catch missing data. They don&rsquo;t catch wrong data. For the one or two tables a business can&rsquo;t lose, I&rsquo;d add a single spot check that mirrors something a human cares about, like the sum of invoice totals for last month. If that sum is off by a cent after restore, you want to know before a client asks.<\/p>\n<p>Also compare sequences. <code>pg_dump<\/code> records sequence values, so <code>last_value<\/code> should match between source and restore. If it doesn&rsquo;t, you have a partial restore that still passes every row count and then fails on the first insert with a duplicate key error. It&rsquo;s a small query, and I&rsquo;d rather run it in a drill than discover it in production.<\/p>\n<h2 id=\"where-the-tools-fit\">Where the tools fit<\/h2>\n<p>Once you have the script above, the interesting question is what BackupDrill and SafeGrd would add, and the answer is mostly scheduling, storage and alerting. BackupDrill&rsquo;s announcement describes backups streamed to any S3-compatible bucket (S3, R2, Backblaze B2, Wasabi) with a checksummed manifest, plus a hosted service that runs drills on a schedule and sends email alerts when one fails. SafeGrd describes age encryption on your host before upload and Object Lock on the bucket, which protects backups from deletion, and that&rsquo;s a real concern the script above ignores.<\/p>\n<p>I&rsquo;d think hard about scope before adopting either. BackupDrill covers Supabase-hosted Postgres only for now, per its own announcement, and dumps the <code>public<\/code> schema by default. If you run Postgres on a VPS, that rules it out today. SafeGrd supports Postgres 13 and later and lists Supabase, Neon and RDS from a connection string, which is broader. In both cases you&rsquo;re trusting a third party with the one job where being wrong is expensive, so read what leaves your host and who holds the keys. SafeGrd says the backup data doesn&rsquo;t go through its servers, and that&rsquo;s the right claim to want. I&rsquo;d still test it with a database I can afford to lose.<\/p>\n<p>If you already run your own backups on a small server, I wrote up retention and restore testing in my <a href=\"https:\/\/abrarqasim.com\/blog\/restic-backup-small-vps-postgres-dump-retention-restore-test\" rel=\"noopener\">restic and Postgres dump post<\/a>. The drill in this post slots in after that pipeline, as the step that proves the files are usable.<\/p>\n<h2 id=\"making-it-a-habit\">Making it a habit<\/h2>\n<p>A drill that runs once teaches you very little, so schedule it. On a small box a systemd timer or a cron entry is plenty. I do this kind of operational hardening for clients, and you can see the sort of work on <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio<\/a>. The pattern is always the same: the backup job and the drill are separate jobs, and the drill alerts you when it fails, not when it passes. A passing drill that emails you every night trains you to ignore it.<\/p>\n<p>Two decisions to make up front. How old a backup can be before you consider it a failure? And how long a restore can take before you consider it a failure? The second one is the number people skip, and it&rsquo;s the one that matters during an outage. If your restore takes 40 minutes, your recovery objective is at least 40 minutes, whatever the plan on paper says.<\/p>\n<p>This week, take your most recent dump and run the script above against it on your laptop. Time the restore, write the number in your runbook, and note the first thing that goes wrong. Something will.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>BackupDrill and SafeGrd both restore backups on a schedule. Here is the DIY restore drill I would build first with pg_dump, pg_restore and Docker, and what to check.<\/p>\n","protected":false},"author":2,"featured_media":815,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"BackupDrill and SafeGrd both restore backups on a schedule. Here is the DIY restore drill I would build first with pg_dump, pg_restore and Docker, and what to check.","rank_math_focus_keyword":"pg_dump","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[237],"tags":[887,80,888,238,8],"class_list":["post-816","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-databases","tag-backups","tag-devops","tag-pg-dump","tag-postgresql","tag-self-hosting"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/816","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=816"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/816\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/815"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=816"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=816"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=816"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}