Per-Partition Backup with pg_dump and Parallel Restore
A partition is an ideal backup unit: it stops changing, it has a natural name, and it is small enough to restore in minutes. This guide dumps partitions individually, stores them with a manifest that makes them findable years later, and restores one in a fraction of the time a whole-cluster recovery would take. It implements the logical layer described in Backup & Restore for Partitioned Databases, part of Shard Migration & Rebalancing Operations.
Prerequisites
Step 1 β Dump one partition, not the table
pg_dump --table accepts a child partition directly:
#!/usr/bin/env bash
set -euo pipefail
PART=events_2025_06
OUT=/var/tmp/${PART}.dump
pg_dump \
--host="${PGREPLICA}" --dbname=app \
--table="public.${PART}" \
--format=custom \
--compress=9 \
--no-owner --no-privileges \
--file="${OUT}"
ls -lh "${OUT}"
-rw-r--r-- 1 postgres postgres 12G Aug 3 02:14 /var/tmp/events_2025_06.dump
Operational note: --table on a partition dumps the child as a standalone table, without the PARTITION OF clause. That is what makes it restorable into a scratch database that has no parent β and it is why reattaching later needs the bounds constraint described in the restore guide.
DBA tip: Do not use --table events expecting to get one partition. It dumps the parent and every child, which is the whole table and defeats the purpose.
Step 2 β Size compression and parallelism deliberately
Two knobs, pulling in opposite directions:
# directory format allows a parallel dump; custom format does not
pg_dump --format=directory --jobs=4 --compress=6 \
--table="public.${PART}" --file=/var/tmp/${PART}.dir --dbname=app
| Setting | Effect | Cost |
|---|---|---|
--compress=9 |
smallest object, cheapest storage | single-threaded, CPU bound; 2β3Γ slower than level 6 |
--compress=6 |
~5% larger, noticeably faster | the usual default choice |
--format=directory --jobs=N |
parallel dump across N connections | needs a directory rather than a file; N connections on the source |
--jobs on restore |
parallel data load and index builds | needs custom or directory format; N CPUs on the target |
Operational note: --jobs on restore requires the custom or directory format. A plain SQL dump restores as a single serial stream no matter how many cores the target has.
SRE tip: Match --jobs to available CPUs on the target, not the source. Restore is where the parallelism pays, and over-subscribing the target slows the index builds it is meant to accelerate.
Step 3 β Write a manifest alongside the object
An archive nobody can find is not an archive:
{
"partition": "events_2025_06",
"parent": "events",
"shard": "shard_3",
"bounds": {"from": "2025-06-01T00:00:00Z", "to": "2025-07-01T00:00:00Z"},
"rows": 418223104,
"checksum": "-8871220394112889",
"server_version": "16.3",
"dump_format": "custom",
"dump_bytes": 12884901888,
"sha256": "3f0b7aβ¦",
"created_at": "2026-08-03T02:14:51Z"
}
aws s3 cp "${OUT}" "s3://archive/events/${PART}.dump"
aws s3 cp "${OUT}.manifest.json" "s3://archive/events/${PART}.manifest.json"
Operational note: Record server_version. A dump written by PostgreSQL 16 cannot be restored by a 15 client, and in five years the version that produced it will not be obvious from anything else.
DBA tip: Keep the manifest as a separate small object rather than embedding it in the dump. Listing manifests to answer βwhat do we still have from 2025?β is then a cheap operation instead of a download of every archive.
Step 4 β Restore one partition in minutes
The restore path is short because the unit is small:
createdb events_recovery
pg_restore \
--dbname=events_recovery \
--no-owner --no-privileges \
--jobs=4 \
/var/tmp/events_2025_06.dump
-- confirm against the manifest before anyone uses it
SELECT count(*) AS rows,
sum(hashtext(t::text)::bigint) AS checksum
FROM events_2025_06 t;
Operational note: Restore into a database named for the incident, not into anything that looks production-adjacent. events_recovery_INC1482 is unambiguous six hours later when someone asks whether it can be dropped.
SRE tip: Automate the extraction query alongside the restore. Most requests are βgive me these rows as CSVβ, and scripting that end to end turns a 40-minute engineer task into a 40-minute unattended one.
Verification
Run the whole path unattended once a week against a random archive:
#!/usr/bin/env bash
# weekly archive verification β picks one archive at random
PART=$(aws s3 ls s3://archive/events/ | grep '.dump$' | shuf -n1 | awk '{print $4}')
aws s3 cp "s3://archive/events/${PART}" /var/tmp/
createdb verify_scratch
pg_restore --dbname=verify_scratch --jobs=4 "/var/tmp/${PART}"
psql -d verify_scratch -tAc \
"SELECT count(*), sum(hashtext(t::text)::bigint) FROM ${PART%.dump} t"
dropdb verify_scratch
418223104|-8871220394112889
Compare both values against the manifest. A mismatch means the archive is corrupt or the manifest is wrong, and both are findings worth an alert rather than a log line.
Failure mode table
| Failure mode | Root cause | SRE mitigation |
|---|---|---|
| Restore takes hours instead of minutes | the dump was written in plain SQL format, so --jobs has no effect and index builds run serially |
always dump in custom or directory format; verify by checking pg_restore --list succeeds on the object |
| Archive cannot be restored on the current fleet | the dump was produced by a server newer than the available client tools | record server_version in the manifest, and keep client tools for every version still represented in the archive |
| Nobody can find the archive for a given month | objects were named by job run rather than by partition, and no manifest was written | name objects after the partition; write a manifest per object; make listing manifests the discovery path |
FAQ
Custom format or directory format?
Directory format if you want a parallel dump, custom format if a single file is easier to store and you only need parallel restore. Both support selective and parallel restore; only directory format supports pg_dump --jobs. For per-partition archives, custom format is usually the better fit because one partition is one object in the store.
Why is restore so much slower than the dump?
Because restoring rebuilds every index, and index builds are CPU and memory bound rather than I/O bound. On a partition with three indexes the rebuild is usually the majority of the restore time. Raising maintenance_work_mem for the restore session and using --jobs to build indexes in parallel are the two changes that matter.
Should the dump run against the primary or a replica?
A replica, whenever one exists. A dump is a long sequential read that competes with live traffic for cache and I/O, and on a replica it costs nothing except replication lag if the dump holds a long snapshot. Set hot_standby_feedback thoughtfully β it prevents query cancellation at the cost of bloat on the primary.
Related
- Backup & Restore for Partitioned Databases β the parent topic and how this layer complements physical backups
- Restoring an Archived Partition into a Live Table β putting a restored partition back under its parent
- Verifying Backup Integrity Across Shards β extending this verification across the whole fleet