A backup job that dies halfway through a pipe can leave behind a file that reports success, passes its integrity check, and lists its contents correctly — while containing almost none of your data.
I did not want to take that on faith, so I killed a pg_dump on purpose and measured every check I could think of. Three of them passed. Two of those three are the ones people reach for precisely because they are supposed to catch this.
The rig
A throwaway cluster, built for the test and destroyed afterwards, so nothing touched a real database:
initdb -D /tmp/pgtest/data -U test --auth=trust -E UTF8 pg_ctl -D /tmp/pgtest/data -o "-p 55432 -k /tmp/pgtest" start psql -d app -c "CREATE TABLE t(id serial primary key, payload text);" psql -d app -c "INSERT INTO t(payload) SELECT repeat(md5(g::text), 40) FROM generate_series(1,100000) g;" That is 100,000 rows of roughly 1.3 KB of text each, dumping in about a quarter of a second. Slow enough to interrupt reliably, fast enough to iterate.
Then the failure, which is the ordinary one — pg_dump dies, gzip keeps going:
pg_dump -d app 2>/dev/null | gzip > app.sql.gz & sleep 0.06 pkill -KILL -f "^pg_dump -d app" Check 1: the exit code — passes
pipeline exit status: 0 A shell pipeline reports the status of its last command. gzip finished cleanly, because the pipe closed and it had nothing left to read. So the pipeline returns 0, and a script written as
pg_dump app | gzip > app.sql.gz continues happily to its next line, which is usually "backup succeeded" followed by a rotation step that deletes yesterday's copy.
Check 2: gzip -t — passes
gzip -t app.sql.gz → PASS The archive is valid gzip. It is genuinely, correctly compressed. The compression stream has a proper EOF, because gzip was never killed — only its input died. gzip -t verifies the compression, not the payload, and the compression is perfect.
The file was 81,068 bytes against 2,753,525 bytes for the complete dump. 2.9% of the data, zero complaints from the integrity check.
Check 3: pg_restore -l — also passes
This is the one that surprised me. My instinct was that the custom format would be safer, because pg_restore -l reads the table of contents and would obviously notice if anything were missing.
It does not. For -Fc, the TOC is written near the start of the file, before the data blocks. A file truncated during the data phase still has a complete, readable TOC.
full -Fc dump: 2,739,118 bytes, 7 TOC entries truncated dump: 163,840 bytes (6.0%) pg_restore -l truncated.dump → exit 0, 7 entries listed Seven for seven. pg_restore -l will tell you an archive contains what it never managed to store.
What actually catches it is trying to use the file rather than inspect it:
pg_restore -f /dev/null truncated.dump → pg_restore: error: could not read from input file: end of file → exit 1 So the "professional" check and the naive one fail identically. If you swapped gzip -t for pg_restore -l believing you had upgraded your validation, you bought the same blind spot in a better suit.
Aside: pipefail is bash-only, and cron does not use bash
The standard advice is set -o pipefail. It works, and it is also a portability trap:
$ sh -c 'set -o pipefail' sh: 1: set: Illegal option -o pipefail $ ls -l /bin/sh lrwxrwxrwx 1 root root 4 Jan 5 2023 /bin/sh -> dash On Debian and Ubuntu, /bin/sh is dash. A script with #!/bin/sh that you tested by hand in an interactive bash session will either die on that line or — worse, if the exit status is not checked — run with the protection silently absent. Cron runs #!/bin/sh scripts under dash, so the environment where the backup actually runs is the one where the guard does not exist.
Either declare #!/usr/bin/env bash, or do not rely on pipelines for correctness at all.
The fix: stop using a pipeline
pg_dump can compress its own output. That removes the entire class of problem, because there is no second process whose exit status can mask the first:
pg_dump -d app -Z6 -f app.sql.gz Measured against the same kill:
| Approach | Exit status after the kill | gzip -t |
|---|---|---|
pg_dump piped into gzip | 0 | PASS |
pg_dump -Z6 -f app.sql.gz | 137 | FAIL |
Two independent signals instead of none, and no pipefail needed.
The reason gzip -t starts working is subtle and useful: when pg_dump compresses internally and is killed, the resulting gzip stream is never finalised — it is missing the CRC32 and ISIZE trailer. In the pipeline version, gzip was never killed, so it wrote a complete, correct trailer around incomplete input. Same truncated payload, opposite integrity verdict.
What actually detects truncation
Ranked by cost, from the measurements above:
| Check | Detects truncation | Cost |
|---|---|---|
Exit status (no pipefail) | No | 0 |
gzip -t | No | full decompress |
pg_restore -l | No | cheap, unreliable |
| Size vs rolling median | Yes | ~0 |
-- PostgreSQL database dump complete footer | Yes | full decompress |
pg_restore -f /dev/null | Yes | slow, conclusive |
The cheapest real guard needs no decompression at all: compare the size against recent history. 81 KB where the last thirty runs were 2.7 MB is not a shape you need to open the file to distrust. A ratio check against a rolling median catches this class of failure for free, and it catches the ones that produce a different exit code from the one you anticipated.
The footer check is the best cheap-and-conclusive option for plain-format dumps, because pg_dump writes -- PostgreSQL database dump complete as its last meaningful line. grep -q for it after decompressing is slower than a size check and far more definitive.
The general shape
This is the same failure as a backup service that exits 0 because it ran, and a monitoring system that is green because nobody defined what red means. The check exists, it runs, it returns success, and it is answering a different question than the one you asked it.
gzip -t answers "is this file valid gzip". pg_restore -l answers "does the header describe some tables". Neither answers "is the data in here".
The only question worth asking is the one that requires touching the data: does the file decompress to a complete dump, and does the restore actually run.
This started as a comment on My Backup Exited 0 for Three Weeks and Nothing Paged Me, where the failure class is the same: a signal that reports the process worked, while the outcome quietly did not. That post covers the freshness side — how a backup can be stale while every timestamp looks recent. This one covers the completeness side.
