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.

Restore testing loopFetch backupdump or snapshotRestorescratch databaseVerifycounts, checks, smokeRecordduration, RPO, issuesA scheduled restore turns hope about backups into measured recovery.
  1. Automate a weekly restore of the latest backup into a scratch database or instance.
  2. Measure how long the restore takes and compare it with your recovery time objective (RTO).
  3. Verify data: row counts for key tables, the latest timestamps (data age gives real RPO), and python manage.py check and migrate --plan against the restored database.
  4. Run smoke tests: log in, load key pages, run a few queries through Django.
  5. 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.

check and what it provesProvesFail meansRestore completesbackup is readablecorrupt or missingRestore durationRTO is realisticplan is wrongNewest row timestampRPO is metbackups stalledDjango check, migrate --planschema matches codemissing extensionSmoke testsapp works on ithidden dependencyEach check catches a different way backups silently faildjango-deployment.com
Record the duration every time; it grows with the database.

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.

recurring drillsWeeklylatest dump, scriptedMonthlysmoke tests on restoreQuarterlyPITR to a timestampYearlyfull DR game dayAfter big changesnew extension, versionFrequent small tests plus occasional full drills keep recovery honestdjango-deployment.com
Alert when the weekly restore job fails, just like a failed backup.

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

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.

Learning path · step 10 of 13

Keeping it running

Keep going