Test a Full PostgreSQL Restore for a Django App
Prove your Django backups work by restoring them regularly: automate a restore into a scratch database, measure restore time against your RTO, verify row counts and run Django checks and smoke tests, test point-in-time recovery, and record the results.
Problem statement
You have nightly backups and PITR enabled, and you have never restored one end to end. When you finally need it, you discover the dumps were empty for weeks, the decryption key is missing, the restore takes eight hours instead of one, or the restored database fails migrations because an extension is not installed. A backup is only proven by a successful restore that the application can use.
- Automate a weekly restore of the latest backup into a scratch database or instance.
- Measure how long the restore takes and compare it with your recovery time objective (RTO).
- Verify data: row counts for key tables, the latest timestamps (data age gives real RPO), and
python manage.py checkandmigrate --planagainst the restored database. - Run smoke tests: log in, load key pages, run a few queries through Django.
- Test point-in-time recovery to a specific timestamp at least quarterly, and record every result.
Step-by-step solution
1. Automate the restore
For pg_dump backups:
#!/usr/bin/env bash
set -euo pipefail
LATEST=$(aws s3 ls s3://shop-backups/pg/ | sort | tail -1 | awk '{print $4}')
START=$(date +%s)
dropdb --if-exists restore_test && createdb restore_test
aws s3 cp "s3://shop-backups/pg/$LATEST" - \
| age -d -i /etc/backup/age-key.txt \
| pg_restore --no-owner --no-privileges --jobs 4 --dbname restore_test
echo "restore took $(( $(date +%s) - START )) seconds for $LATEST"
For managed databases, restore a snapshot or point in time into a new instance with the provider's CLI (for example aws rds restore-db-instance-to-point-in-time), then run the same verification. Delete the test instance afterwards.
2. Verify the data
SELECT 'orders' AS t, count(*), max(created_at) FROM orders_order
UNION ALL SELECT 'users', count(*), max(date_joined) FROM accounts_user
UNION ALL SELECT 'payments', count(*), max(created_at) FROM payments_payment;
Compare counts with production (within expected growth since the backup) and check that the newest rows are as recent as your RPO promises. An unexpectedly old max(created_at) means backups stopped capturing new data.
3. Check Django against the restored database
DATABASE_URL=postgres://.../restore_test python manage.py check --database default
DATABASE_URL=postgres://.../restore_test python manage.py migrate --plan
DATABASE_URL=postgres://.../restore_test python manage.py shell -c "from accounts.models import User; print(User.objects.count())"
migrate --plan should show no pending migrations for the code version that matches the backup. Errors about missing extensions (pg_trgm, postgis) or roles reveal steps your recovery runbook must include.
4. Run smoke tests
Point a staging instance of the app at the restored database (with outbound email and payments disabled) and run a short automated smoke suite: log in as a test user, load the dashboard, view an order, run a search. This catches problems like missing media files referenced by the database or broken sequences.
5. Test PITR and record results
Quarterly, restore to a specific timestamp (for example, five minutes before a known event) and confirm the data matches that moment. Keep a log: date, backup used, restore time, data age, issues found, and fixes made. Auditors and incident reviews will ask for it.
Explanation
Backups fail silently in many ways: a credential expires and uploads stop, a dump job writes a zero-byte file, encryption keys are lost, or WAL archiving breaks and PITR coverage ends. Only restoring exercises every step: retrieval, decryption, restore, schema compatibility, and application use. Measuring duration and data age turns RTO and RPO from targets into numbers you have observed, which is what recovery planning needs.
Edge cases / notes
- Privacy. Restored production data in a test environment is still production data; restrict access and destroy it after the test.
- Large databases. Restores of very large databases may need parallel
pg_restore --jobs, physical backups (pgBackRest), or snapshot restores to meet RTO. - Media files. Test restoring media backups too; a database referencing missing files is a partial recovery.
Next steps
- Build the backups first with Back Up and Restore PostgreSQL for Django.
- Practice point-in-time recovery with PostgreSQL Point-in-Time Recovery for Django.
- Write the plan in Django Disaster Recovery Plan.
FAQ
How often should I test restores?
Automate a weekly restore of the latest backup, with deeper drills monthly or quarterly.
What should a restore test check?
That the restore completes, how long it takes, how recent the data is, and that Django and the app work against it.
Is a successful backup job enough?
No. Only a restore proves the backup is complete and usable.
Where should the test restore run?
In an isolated scratch database or instance with restricted access, deleted after the test.