Bar chart of pg_dump file size for a 1 GB PostgreSQL 18 database: plain SQL 596.9 MiB, custom with no compression 622.5 MiB, lz4 281 MiB, gzip (default) 172.4 MiB, zstd 176.7 MiB, zstd:19 140.6 MiB

A small app's database is easy to back up: one pg_dump a night and a few copies somewhere else. The choices are the format (plain SQL, custom or directory) and the compression (none, gzip, lz4 or zstd), and they change the file size by 4x and the time by more than 60x. We dumped and restored the same app database at 10 MB, 100 MB and 1 GB on PostgreSQL 18, three times each, with every format and method.

Short version: for a 1 GB database, pg_dump -Fc -Z zstd made a 176.7 MiB file in 6.0 seconds. The default -Fc (gzip) made a file 2% smaller in 18.4 seconds, three times longer. lz4 was as fast as zstd but 60% bigger, and zstd:19 saved another 20% for 398 seconds of CPU. Restore time hardly depends on the compression: 14 to 15 seconds for 1 GB with one job, 11 seconds with -j 4. Keeping 23 copies (a week of dailies, a month of weeklies, a year of monthlies) of that 1 GB app costs about 6 cents a month on object storage.

How we measured

The database is a synthetic app with three tables: users (email, name, plan), orders (status, amount, a note on 40% of rows, foreign key to users) and events (kind, timestamp and a small jsonb payload), with a primary key on each and three more indexes. events has ten rows per user and holds about 80% of the data, which is the usual shape: one log-like table outgrows the rest. Strings repeat the way real data does (plans, statuses, paths, browser names) next to random hex values that don't compress. We scaled it by the number of users:

DatabaseTablesIndexesTables + indexespg_database_size()Rows
10 MB6.86 MiB2.89 MiB9.75 MiB17.5 MiB50,400
100 MB67.4 MiB28.0 MiB95.4 MiB103.1 MiB504,000
1 GB718.8 MiB297.5 MiB1,016.3 MiB1,024.0 MiB5,376,000

We label the sizes by tables plus indexes, the space your data takes. pg_database_size() also counts the system catalogs every database carries, 7.4 MiB even when empty (see what an empty PostgreSQL 18 database costs), which is why the 10 MB database reports 17.5 MiB.

text
machine     Intel Core i5-12500H laptop, Docker Desktop 29.6 on WSL2 (Linux 5.15),
            6 CPUs and 11.7 GiB of memory visible to containers, NVMe disk
server      postgres:18 image (PostgreSQL 18.6), every setting default
pg_dump     version 18.6, same container, Unix socket, dump on the container disk
timing      bash `time`: wall clock, plus user + sys CPU of pg_dump / pg_restore / psql
            (server CPU not included); `sync` before each step
restore     into a new empty database each time, then row counts checked against the source
runs        3, each in a fresh container; tables show the median

Every dump and restore ran on the same machine as the server, so network time is not in these numbers. Over a network the dump takes longer, see below.

Dump size and time for 1 GB

Formatpg_dump optionsFile size (MiB)vs plainDump time (s)pg_dump CPU (s)
plain SQL-Fp596.91.00x6.12.4
plain SQL, gzip-Fp -Z gzip172.23.47x17.517.3
plain SQL, zstd-Fp -Z zstd176.73.38x5.54.7
custom, none-Fc -Z 0622.50.96x6.42.9
custom, lz4-Fc -Z lz4281.02.12x6.13.2
custom, gzip (default)-Fc172.43.46x18.418.0
custom, gzip:9-Fc -Z gzip:9170.43.50x38.338.3
custom, zstd-Fc -Z zstd176.73.38x6.05.2
custom, zstd:19-Fc -Z zstd:19140.64.24x398.2396.7
directory, zstd, 1 job-Fd -Z zstd -j 1176.63.38x5.64.6
directory, zstd, 4 jobs-Fd -Z zstd -j 4176.63.38x4.65.2
Bar chart of pg_dump time for a 1 GB database: directory zstd with 4 jobs 4.6 s, custom zstd 6.0 s, plain SQL 6.1 s, custom lz4 6.1 s, custom gzip 18.4 s, custom gzip:9 38.3 s, custom zstd:19 398.2 s
Median of 3 runs, PostgreSQL 18.6 with defaults, pg_dump on the same machine.

What the table shows:

  • A dump is smaller than the database. The plain SQL file is 596.9 MiB for 1,016 MiB of tables and indexes: indexes are not dumped (only their CREATE INDEX line, rebuilt on restore), and table pages carry row headers and free space that the text form doesn't.
  • zstd is the best default. At its default level (3) it compressed 3.38x, nearly the 3.46x of gzip, and finished in 6.0 s against 18.4 s. pg_dump spent 5.2 s of CPU on it against 18.0 s for gzip, so on a busy server the gap is real CPU, not just waiting.
  • Compression costs nothing in time with zstd or lz4. Both took the same 6.0 to 6.1 s as an uncompressed plain dump: reading rows from the server is the slow part, not compressing them. Only gzip was slower than writing the file raw.
  • The extremes don't pay. gzip:9 took twice as long as default gzip for a 1% smaller file. zstd:19 made the smallest file, 140.6 MiB, but needed 398 s: 66 times longer for 36 MiB less.
  • Parallel dump helps little for one big table. pg_dump's -j works only with the directory format and dumps one table per job. With 4 jobs the dump took 4.6 s against 5.6 s with one, because events is 80% of the data and one job still has to dump it alone.
  • Custom without compression is slightly bigger than plain SQL (622.5 against 596.9 MiB), so there is no reason to pick -Z 0.

10 MB and 100 MB

Smaller databases scale down almost linearly. At 100 MB:

Formatpg_dump optionsFile size (MiB)vs plainDump time (s)pg_dump CPU (s)
plain SQL-Fp55.01.00x0.570.15
plain SQL, gzip-Fp -Z gzip15.83.47x1.71.8
plain SQL, zstd-Fp -Z zstd16.23.40x0.540.44
custom, none-Fc -Z 057.40.96x0.650.21
custom, lz4-Fc -Z lz425.92.13x0.610.29
custom, gzip (default)-Fc15.83.47x1.81.7
custom, gzip:9-Fc -Z gzip:915.73.51x3.63.5
custom, zstd-Fc -Z zstd16.23.40x0.570.43
custom, zstd:19-Fc -Z zstd:1913.04.23x37.437.3
directory, zstd, 1 job-Fd -Z zstd -j 116.23.40x0.570.44
directory, zstd, 4 jobs-Fd -Z zstd -j 416.23.40x0.490.49

And at 10 MB, everything but zstd:19 finished in under half a second:

Formatpg_dump optionsFile size (MiB)vs plainDump time (s)pg_dump CPU (s)
plain SQL-Fp5.411.00x0.130.06
plain SQL, gzip-Fp -Z gzip1.553.48x0.260.20
plain SQL, zstd-Fp -Z zstd1.583.41x0.120.08
custom, none-Fc -Z 05.650.96x0.130.06
custom, lz4-Fc -Z lz42.552.12x0.120.06
custom, gzip (default)-Fc1.563.47x0.240.20
custom, gzip:9-Fc -Z gzip:91.543.51x0.420.36
custom, zstd-Fc -Z zstd1.583.42x0.110.07
custom, zstd:19-Fc -Z zstd:191.314.11x2.72.6
directory, zstd, 1 job-Fd -Z zstd -j 11.583.42x0.120.08
directory, zstd, 4 jobs-Fd -Z zstd -j 41.583.42x0.120.09

At these sizes the format choice is about convenience, not time. The compression ratio stayed the same from 10 MB to 1 GB (3.4x for zstd and gzip, 2.1x for lz4), so a dump of your own data will scale the same way: measure one, multiply.

Restore time: the format barely matters

DumpRestored with10 MB (s)100 MB (s)1 GB (s)
plain SQLpsql -f0.191.416.8
plain SQL, gzipgzip -dc | psql0.171.414.7
custom, nonepg_restore0.171.314.6
custom, lz4pg_restore0.171.214.1
custom, gzippg_restore0.181.214.5
custom, zstdpg_restore0.181.314.8
custom, zstdpg_restore --single-transaction0.161.214.5
custom, zstdpg_restore -j 40.141.011.6
directory, zstdpg_restore -j 40.140.9411.0

A restore spends its time loading rows with COPY and rebuilding indexes, and the server does that work, not the decompression. For 1 GB, every single-job restore from a custom dump took 14.1 to 14.8 seconds whatever the compression; pg_restore itself used 0.6 to 2.7 s of CPU. The plain SQL file through psql was the slowest, 16.8 s. --single-transaction, which makes the restore all-or-nothing, cost nothing (14.5 s).

Parallel restore (pg_restore -j 4) cut 1 GB to 11.6 s from a custom file and 11.0 s from a directory: it loads the three tables and builds the indexes at the same time, but events and its indexes still dominate. It needs a custom or directory dump (not plain SQL, not a pipe) and can't be combined with --single-transaction. On a database with many similar-sized tables the gain is bigger; with one big table, as most small apps have, expect about a quarter off.

Over the network

If your database is hosted, you run pg_dump with its connection string from your own machine or a CI job. Two things change. First, pg_dump compresses on the client: the server sends rows uncompressed (PostgreSQL 18 has no protocol compression), so a 1 GB database sends about 600 MB over the wire however small the file ends up. At 100 Mbit/s that is at least 50 seconds; it, not the compression, sets the dump time. Second, pg_dump must be at least as new as the server: pg_dump 16 refuses to dump a PostgreSQL 18 server. Install the version 18 client (postgresql-client-18 on Debian and Ubuntu from the PostgreSQL apt repository) and check pg_dump --version.

bash
# password in ~/.pgpass (host:port:database:user:password, chmod 600), not in the command
pg_dump -d "postgresql://app_user@db.example.com:5432/app" -Fc -Z zstd -f app-$(date +%F).dump

What to keep and how often

For a small app, a nightly dump with a simple rotation covers the usual accidents: a bad migration found the next morning, a deleted customer found a week later, a question about last quarter's data. Seven daily, four weekly and twelve monthly copies are 23 files. This script does it with nothing but pg_dump and find, from cron at night:

bash
#!/bin/sh
# backup-app.sh: nightly dump, 7 daily + 4 weekly + 12 monthly copies
set -eu
DB="postgresql://app_user@db.example.com:5432/app"
DIR=/backups/app
mkdir -p "$DIR/daily" "$DIR/weekly" "$DIR/monthly"
f="$DIR/daily/app-$(date +%F).dump"
pg_dump -d "$DB" -Fc -Z zstd -f "$f.part"
pg_restore --list "$f.part" > /dev/null   # fails on a broken or cut-off file
mv "$f.part" "$f"
if [ "$(date +%u)" = 7 ];  then cp "$f" "$DIR/weekly/";  fi   # Sundays
if [ "$(date +%d)" = 01 ]; then cp "$f" "$DIR/monthly/"; fi   # 1st of the month
find "$DIR/daily"   -name '*.dump' -mtime +6   -delete
find "$DIR/weekly"  -name '*.dump' -mtime +27  -delete
find "$DIR/monthly" -name '*.dump' -mtime +364 -delete

# crontab -e:
# 30 2 * * * /usr/local/bin/backup-app.sh >> /var/log/backup-app.log 2>&1

Then copy the folder off the machine (rclone sync /backups/app remote:app-backups to any S3-compatible bucket), because a backup on the same disk as the database is not a backup. And once a month restore the newest dump into a scratch database: createdb app_check && pg_restore -j 4 -d app_check app-2026-10-28.dump, then count rows in your main tables. By the table above, that takes 11 seconds per GB, and it is the only test that proves the file is good.

What it costs to store

The worked example: 23 copies at the sizes we measured, priced at Cloudflare R2 ($0.015 per GB-month, 10 GB free) and Backblaze B2 ($6.95 per TB-month, first 10 GB free), before the free allowances:

What you keep (23 copies)One dumpStoredCloudflare R2 ($0.015/GB)Backblaze B2 ($6.95/TB)
1 GB app, plain SQL625.9 MB14.40 GB$0.216$0.100
1 GB app, custom gzip (default)180.8 MB4.16 GB$0.062$0.029
1 GB app, custom zstd185.3 MB4.26 GB$0.064$0.030
1 GB app, custom zstd:19147.5 MB3.39 GB$0.051$0.024
100 apps of 100 MB, custom zstd16.9 MB38.98 GB$0.585$0.271

For one 1 GB app the format decides between 22 and 6 cents a month on R2, and the zstd copies fit in the free 10 GB. The difference matters at scale and in transfer time more than on the bill: 100 apps of 100 MB, dumped with zstd, keep 39 GB, about 59 cents a month on R2 or 27 cents on B2, and Contabo's object storage sells 250 GB with free traffic for €2.49 a month, room for six such fleets. zstd:19 would save 0.9 GB per 1 GB app, 1.3 cents a month on R2, for six and a half minutes of CPU every night: not worth it.

Checklist

  1. Dump with pg_dump -Fc -Z zstd: as fast as no compression, under a third of the size, and pg_restore can pick single tables out of it.
  2. Use the directory format with -j only when your data is spread over several big tables.
  3. Use a pg_dump client at least as new as the server (18 for PostgreSQL 18).
  4. Write to a temporary name, check it with pg_restore --list, then rename, so a failed night never replaces a good file.
  5. Keep 7 daily, 4 weekly and 12 monthly copies, and copy them off the database machine.
  6. Restore the newest dump into a scratch database once a month and count rows. Use pg_restore -j 4 when you need it fast.
  7. Expect a restore to take about 11 to 15 seconds per GB on a small server, most of it loading rows and rebuilding indexes.

Backing up a database on systemi

Every systemi app gets its own PostgreSQL 18 database and a connection string in the console, so the commands above work as they are: point pg_dump at your app's connection string from your laptop or a scheduled job. One database per app also means each dump holds exactly one app (why one database per app is cheap), and a restore never touches another app's data.

Sources: our own tests on 28 October 2026 (3 runs, scripts and raw output kept with the article's change request); PostgreSQL 18 documentation, pg_dump (-Z methods gzip/lz4/zstd, custom and directory formats compressed with gzip by default, -j only with the directory format, no dumping from newer servers) and pg_restore (-j only with custom and directory archives from a regular file, not with --single-transaction); prices as listed on 28 October 2026: Cloudflare R2 pricing ($0.015 per GB-month standard, 10 GB-month free), Backblaze B2 pricing ($6.95 per TB-month, first 10 GB free), Contabo Object Storage (from €2.49 a month, 250 GB per slice per Contabo's launch post, free traffic).

systemi gives every app its own PostgreSQL 18 database, with the connection string for pg_dump ready in the console.

Read the databases guide