Reported symptoms
A migration rehearsal restores the production database into a newly built cluster. Four hours. The pipeline reports success.
A verification script compares row counts for all 214 tables between source and target. Every count matches exactly.
The application is pointed at the new cluster at 22:10 and fails to start:
ERROR: permission denied for schema billing
The application role exists and can connect, so authentication is ruled
out and the failure is attributed to an application configuration
problem. Twenty minutes go into connection strings, search_path and the
deployment manifest.
A superuser reads every table without difficulty, which delays things further — the data is demonstrably there.
At 22:40 somebody reads the restore log and finds 47 errors that scrolled past four hours earlier.
Evidence provided
$ a SELECT as the application role, then as postgres-- as billing_app:
ERROR: permission denied for schema billing
-- as postgres:
count
---------
4471029
(1 row)Illustrative output
$ psql -c "\dp billing.invoices" Access privileges
Schema | Name | Type | Access privileges | Column privileges | Policies
---------+----------+-------+-------------------+-------------------+----------
billing | invoices | table | | |
(1 row)Illustrative output
$ grep -A1 'pg_restore: error' restore.log | head -4pg_restore: error: could not execute query: ERROR: role "billing_app" does not exist
Command was: GRANT USAGE ON SCHEMA billing TO billing_app;
pg_restore: error: could not execute query: ERROR: role "billing_app" does not exist
Command was: GRANT SELECT ON TABLE billing.invoices TO billing_app;Illustrative output
pg_restore exited with status 1. The pipeline recorded 0.
The command was pg_restore ... | tee restore.log, and the pipeline
checked $?.
The backup procedure is a single pg_dump. There is no
pg_dumpall --globals-only.
The three affected schemas are the three whose roles were created after the last rehearsal, eight months earlier.
Work the evidence before reading on
- Every row count matched. What did that prove, and what did it not prove?
pg_restoreexited 1 and the pipeline recorded 0. What is the mechanism?- Eight schemas restored correctly and three did not. What distinguishes them?
- The verification connected successfully as
postgres. Why was that worse than useless?
Root cause
The dump was half a backup
pg_dump dumps one database. Roles, tablespaces and cluster-wide
settings live outside any database and are not in it.
So the dump contained GRANT USAGE ON SCHEMA billing TO billing_app; and
had never contained CREATE ROLE billing_app;. On a cluster that already
had the role, the grant worked. On one that did not, it failed.
The data restored perfectly. What did not restore was access.
The pipe swallowed the failure
pg_restore --dbname=restore_check dump.pgdump | tee restore.log
if [ $? -ne 0 ]; then
echo "restore failed" >&2
exit 1
fi
In a shell pipeline, $? is the exit status of the last command.
That is tee. tee succeeded. pg_restore’s status was discarded the
moment the pipe was written.
Counting rows as a superuser cannot detect this
The verification compared row counts for 214 tables. They matched, because the data was fine.
A superuser bypasses the permission system entirely. The counts would have been identical whether the grants existed or not, so the check was structurally incapable of detecting the failure.
Eight months of luck
The eight schemas that restored correctly had roles left over from a rehearsal eight months earlier. The three that failed were created since.
The procedure had been broken the entire time and had been masked by a target cluster that was never actually empty.
Resolution
Restore the globals — the missing half:
pg_dumpall --globals-only -h source-host -f globals.sql
psql -h target-host -f globals.sql
Expect role "postgres" already exists. That is harmless, and it is why
the globals restore cannot be verified by checking for zero errors — and
why it must run before the database restore.
Then re-apply just the grants, rather than repeating four hours of data load:
pg_restore -l production.dump | grep ' ACL ' > acl.list
pg_restore -h target-host -d appdb -L acl.list production.dump > acl-restore.log 2>&1
echo "pg_restore exit: $?"
Note the exit status check with nothing piped in front of it.
Verify as the application role:
PGPASSWORD=... psql -h target-host -U billing_app -d appdb \
-c "SELECT count(*) FROM billing.invoices;"
Verification
The application role runs a real query in every schema, not SELECT 1 and not as a superuser.
\dp shows populated privileges, and a grant diff between source and
target is empty.
Zero pg_restore: error lines, and pg_restore’s own exit status is
zero.
Object counts match by type, and
SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid returns
nothing.
The application starts and serves traffic.
Prevention
Two commands, always:
pg_dumpall --globals-only -f globals.sql
pg_dump -Fd -j 4 -f appdb.dir appdb
Measure the right exit status, using redirection rather than a pipe.
Fail on pg_restore: error in the log as an independent check.
Verify as the application role. This is the specific habit that converts a four-hour rehearsal from a data-transfer test into a restore test.
Compare object counts by type.
Rehearse into a genuinely empty cluster, so that stale leftovers cannot mask an incomplete procedure for eight months.