<!-- Canonical: https://lucioduran.com/blog/postgresql-17-incremental-backup-logical-replication
Language: en
Content-SHA256: cddad302da4ceedf302e53caba7b764ad2595e334b1d5e57cbbea5d23259be81
-->

Databases

# PostgreSQL 17: Incremental Backup, Logical Replication Failover, and Operational Improvements

2025-10-1410 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/postgresql-17-incremental-backup-logical-replication>)

## Incremental Backup: Native Support in PG 17

Before PG 17, ` pg_basebackup ` always took a full backup. Every time. A 500GB database meant copying 500GB, whether you changed 10 rows or 10 million since the last backup. The alternatives were third-party tools like pgBackRest (excellent, but another thing to operate) or filesystem-level snapshots (which have their own issues).

PG 17 adds native incremental backup support. Here's how it works:

```bash
# Step 1: Enable WAL summarization (required, do this first!)
# postgresql.conf
summarize_wal = on

# Restart PostgreSQL for the change to take effect
sudo systemctl restart postgresql@17-main

# Step 2: Take the first full backup (this is the baseline)
pg_basebackup -D /backups/full_20260114 \
 --checkpoint=fast \
 --wal-method=stream \
 --manifest-checksums=SHA256 \
 -U replication_user

# Step 3: Take incremental backups (only changed blocks)
pg_basebackup -D /backups/incr_20260115 \
 --incremental=/backups/full_20260114/backup_manifest \
 --checkpoint=fast \
 --wal-method=stream \
 -U replication_user
```

The ` --incremental ` flag points to the manifest file from the previous backup. PostgreSQL compares WAL summaries to determine which 8KB blocks have changed since that manifest was created, and only transfers those blocks.

**Real-world numbers from a representative production database (480GB):**

| Backup type | Size | Duration | Notes |
| --- | --- | --- | --- |
| Full backup | 480GB | 47 min | Baseline |
| Day 1 incremental | 8.2GB | 4.2 min | ~1.7% daily change rate |
| Day 2 incremental | 9.1GB | 4.8 min | Slightly busier day |
| Day 7 incremental | 7.6GB | 3.9 min | Weekend, less activity |
| Weekly synthetic full | 480GB | 12 min | Reconstructed from chain |

That last row is important. You can reconstruct a full backup from an incremental chain using ` pg_combinebackup `:

```bash
# Combine full + incrementals into a synthetic full backup
pg_combinebackup \
 /backups/full_20260114 \
 /backups/incr_20260115 \
 /backups/incr_20260116 \
 /backups/incr_20260117 \
 -o /backups/synthetic_full_20260117

# The synthetic full can be used as a new baseline
# for future incremental chains
```

The reconstruction takes ~12 minutes for a 480GB database because it's mostly sequential reads and writes, no network transfer needed if all backups are on the same storage.

**The WAL summarization gotcha:** The ` summarize_wal ` setting creates summary files in ` pg_wal/summaries/ `. These are small (a few KB each), but they're only created for WAL that's generated *after* the setting is enabled. If you enable it and immediately try an incremental backup, it'll fail because there's no summary covering the period between your previous full backup and now. Enable it first, take a fresh full backup, then start your incremental chain.

## Logical Replication Failover: Finally

PG 17 adds ` failover = true ` on subscriptions and ` sync ` parameter on replication slots. This means logical replication slots can survive a failover to a standby. Before this, if your primary went down and you promoted a standby, all logical replication subscribers had to be re-initialized from scratch. On a 500GB database, that's hours of downtime.

Here's the setup:

```sql
-- On the primary: create a publication
CREATE PUBLICATION my_pub FOR ALL TABLES;

-- On the subscriber: create subscription with failover support
CREATE SUBSCRIPTION my_sub
 CONNECTION 'host=primary-host port=5432 dbname=mydb'
 PUBLICATION my_pub
 WITH (failover = true);
```

On the primary, you'll see the replication slot has ` failover = true `:

```sql
SELECT slot_name, plugin, failover, synced
FROM pg_replication_slots
WHERE slot_name = 'my_sub';

-- slot_name | plugin | failover | synced
-- -----------+---------+----------+--------
-- my_sub | pgoutput| t | f
```

The ` synced ` column becomes ` true ` once the standby has synchronized the slot position. When failover happens, the new primary has the exact LSN position of the subscriber, so replication resumes without data loss or re-sync.

The configuration on the standby that enables this:

```
# postgresql.conf on standby
sync_replication_slots = on
hot_standby_feedback = on
```

Testing with a simulated failover (promoting the standby via ` pg_ctl promote `) shows the subscriber reconnects and resumes replication within 3 seconds. Before PG 17, the same scenario required dropping and recreating the subscription, which meant a full table sync, 2 hours for a comparable dataset.

## JSON\_TABLE: SQL/JSON Done Right

Writing ` jsonb_array_elements ` with lateral joins has long felt like a workaround. PG 17 implements ` JSON_TABLE() ` from the SQL/JSON standard, and it's substantially cleaner:

```sql
-- The old way (PG 16 and below)
SELECT
 e->>'id' AS id,
 e->>'name' AS name,
 e->>'email' AS email
FROM orders o,
 LATERAL jsonb_array_elements(o.metadata->'contacts') AS e
WHERE o.created_at > '2026-01-01';

-- The new way (PG 17)
SELECT t.*
FROM orders o,
 JSON_TABLE(
 o.metadata,
 '$.contacts[*]'
 COLUMNS (
 id INTEGER PATH '$.id',
 name TEXT PATH '$.name',
 email TEXT PATH '$.email'
 )
 ) AS t
WHERE o.created_at > '2026-01-01';
```

But the real power is in nested paths and error handling:

```sql
-- Nested JSON_TABLE with error handling
SELECT t.*
FROM api_responses r,
 JSON_TABLE(
 r.body,
 '$.data.users[*]'
 COLUMNS (
 user_id INTEGER PATH '$.id',
 username TEXT PATH '$.profile.name'
 DEFAULT 'unknown' ON EMPTY
 DEFAULT 'error' ON ERROR,
 NESTED PATH '$.orders[*]' COLUMNS (
 order_id INTEGER PATH '$.id',
 total NUMERIC(10,2) PATH '$.total',
 items_count INTEGER PATH '$.items.size()'
 )
 )
 ) AS t;
```

The ` ON EMPTY ` and ` ON ERROR ` clauses are game-changers for dealing with inconsistent JSON structures. No more ` COALESCE(jsonb_extract_path_text(...), 'default') ` chains.

Performance-wise, ` JSON_TABLE ` isn't magic, it still does the same work under the hood. But the query planner can reason about it better because it sees a single table-returning function instead of a lateral join chain. On a query hitting 50,000 rows with an average of 8 JSON array elements each, benchmarks show a 22% improvement in execution time versus the equivalent ` jsonb_array_elements ` approach.

## pg\_stat\_io: Understanding Your I/O Patterns

This view was added in PG 16, but PG 17 expanded it significantly with better backend type categorization and extend statistics. It has become an essential diagnostic tool.

```sql
-- Top I/O consumers by backend type
SELECT
 backend_type,
 io_object,
 io_context,
 reads,
 read_bytes / (1024*1024*1024.0) AS read_gb,
 writes,
 write_bytes / (1024*1024*1024.0) AS write_gb,
 extends,
 fsyncs
FROM pg_stat_io
WHERE reads > 0 OR writes > 0
ORDER BY reads + writes DESC
LIMIT 20;
```

What this reveals on a representative production server:

```
 backend_type | io_object | io_context | reads | read_gb | writes | write_gb
-------------------+-----------+------------+----------+---------+----------+---------
 autovacuum worker | relation | normal | 14822041 | 112.8 | 2841023 | 21.6
 client backend | relation | normal | 4201882 | 32.0 | 891204 | 6.8
 client backend | relation | bulkread | 3102441 | 23.6 | 0 | 0.0
 background writer | relation | normal | 0 | 0.0 | 1204812 | 9.2
 checkpointer | relation | normal | 0 | 0.0 | 982104 | 7.5
```

Autovacuum was doing 3.5x more reads than all client queries combined. That is not necessarily wrong, autovacuum is doing its job, but it indicates the ` autovacuum_vacuum_cost_delay ` was too aggressive and vacuum was scanning tables that did not need it.

After tuning:

```sql
-- Per-table autovacuum settings for large, mostly-static tables
ALTER TABLE historical_events SET (
 autovacuum_vacuum_threshold = 10000,
 autovacuum_vacuum_scale_factor = 0.01,
 autovacuum_analyze_threshold = 5000
);
```

Autovacuum reads dropped by 60%, and client query I/O performance improved measurably because there was less contention on the buffer pool.

## VACUUM Improvements: 8x Fewer Page Reads

PG 17 fundamentally changed how VACUUM handles pages. The previous behavior was to read every page in a table to check for dead tuples, even if the page was already all-frozen (every tuple visible to all transactions). PG 17 tracks frozen-page status in the visibility map and skips those pages entirely.

For a large table with a high frozen-to-total page ratio (common for append-heavy tables like event logs), this is transformative:

```sql
-- Check your table's frozen page ratio
SELECT
 relname,
 pg_size_pretty(pg_relation_size(oid)) AS table_size,
 n_tup_ins AS inserts_since_vacuum,
 n_tup_upd AS updates_since_vacuum,
 n_dead_tup AS dead_tuples,
 last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'events'
ORDER BY pg_relation_size(oid) DESC;
```

On a representative ` events ` table (89GB, 95% frozen pages), VACUUM time dropped from 18 minutes to 2.2 minutes. The I/O reduction was even more dramatic, from 89GB of reads to under 5GB.

## The Migration Path: PG 15/16 → PG 17

This migration has been performed multiple times across different environments. Here is the playbook that works:

### Option A: pg\_upgrade (simplest, brief downtime)

```bash
# 1. Install PG 17 alongside existing version
sudo apt install postgresql-17

# 2. Stop both clusters
sudo systemctl stop postgresql@16-main
sudo systemctl stop postgresql@17-main

# 3. Run pg_upgrade in check mode first
sudo -u postgres pg_upgrade \
 --old-datadir=/var/lib/postgresql/16/main \
 --new-datadir=/var/lib/postgresql/17/main \
 --old-bindir=/usr/lib/postgresql/16/bin \
 --new-bindir=/usr/lib/postgresql/17/bin \
 --check

# 4. If check passes, run the actual upgrade
sudo -u postgres pg_upgrade \
 --old-datadir=/var/lib/postgresql/16/main \
 --new-datadir=/var/lib/postgresql/17/main \
 --old-bindir=/usr/lib/postgresql/16/bin \
 --new-bindir=/usr/lib/postgresql/17/bin \
 --link # Use hardlinks, 10x faster

# 5. Start PG 17 and run the post-upgrade script
sudo systemctl start postgresql@17-main
sudo -u postgres /var/lib/postgresql/17/main/analyze_new_cluster.sh
```

The ` --link ` option is critical, it creates hardlinks instead of copying data files, reducing upgrade time from hours to minutes for large databases. The old cluster's data files become invalid after this (they share pages with the new cluster), so keep a proper backup.

For a 480GB database, typical upgrade times:

- With ` --link `: 4 minutes (mostly catalog operations)
- Without ` --link `: 3 hours 20 minutes (copying 480GB)

### Option B: Logical replication (zero downtime)

```bash
# 1. Set up PG 17 as a fresh instance
# 2. Create the schema on PG 17 (pg_dump --schema-only)
pg_dump -h pg16-host -d mydb --schema-only | psql -h pg17-host -d mydb

# 3. Create publication on PG 16
psql -h pg16-host -d mydb -c "CREATE PUBLICATION upgrade_pub FOR ALL TABLES;"

# 4. Create subscription on PG 17
psql -h pg17-host -d mydb -c "
 CREATE SUBSCRIPTION upgrade_sub
 CONNECTION 'host=pg16-host port=5432 dbname=mydb'
 PUBLICATION upgrade_pub;"

# 5. Wait for initial sync (check pg_stat_subscription)
# 6. When caught up, point application to PG 17
# 7. Drop subscription and publication
```

This approach has zero downtime but requires more operational care, you need to handle sequences, large objects, and any DDL changes during the migration window.

### The PG 17-specific gotcha

If you're using logical replication on PG 16 and upgrading to PG 17, be aware that PG 17 changed how replication slot LSN tracking works with the new failover feature. If your replication slots were created without ` failover = true ` (the default), they'll work fine after upgrade. But if you try to add ` failover = true ` to existing slots post-upgrade, you need to ensure ` sync_replication_slots = on ` on all standbys first, or the slot will appear healthy but not actually be synchronized.

## Limitations and Caveats

Not everything in PG 17 is a win for every workload:

- **The MERGE improvements** are nice but still lag behind MySQL's INSERT ON DUPLICATE KEY UPDATE in raw performance for high-concurrency upsert patterns.
- **SQL/JSON path language** is powerful but the error messages when you get a path expression wrong are cryptic. Debugging ` JSON_TABLE ` queries takes patience.
- **The new COPY option DEFAULT** is useful but has a surprising interaction with GENERATED ALWAYS columns, you need to explicitly specify column lists to avoid errors.

These are minor complaints in the context of a genuinely solid release. PG 17 is the kind of upgrade that doesn't require you to change anything but makes everything work a little better. And the incremental backup feature alone justifies the upgrade for anyone managing databases over 100GB.

The recommendation: if on PG 15, start planning the migration now. If you're on PG 16, the upgrade is straightforward and the operational improvements are worth it. If you're still on PG 14 or below, you really need to move, PG 14 hits end-of-life in November 2026.

## Frequently asked questions

### How significant are the performance improvements in PostgreSQL 17?

For most workloads, expect 5-15% improvement in query throughput, mainly from VACUUM optimization (reads 8x fewer pages for mostly-frozen tables), improved sort algorithms for queries with LIMIT, and better hash join handling. The biggest wins are operational, incremental backup reduces backup storage by 80-90% and backup time proportionally.

### Is it safe to upgrade from PostgreSQL 15 or 16 to 17?

Yes, with caveats. pg\_upgrade works reliably for 16→17 based on production experience. For 15→17, test thoroughly, some changes to logical replication slot behavior can break existing replication setups. The pg\_createsubscriber approach for logical replication-based upgrades is the safest path for zero-downtime migrations.

### What's the biggest operational improvement in PG 17?

Incremental backup, by a wide margin. For a 500GB database that changes 2% daily, the daily backup went from 500GB (full) to ~12GB (incremental). Combined with WAL archiving, this makes point-in-time recovery practical even for multi-terabyte databases on cloud storage.

## Tips

1. **Incremental backup requires WAL summarization enabled:** Set ` summarize_wal = on ` in postgresql.conf BEFORE taking your first full backup. Without it, ` pg_basebackup --incremental ` fails silently and takes a full backup instead. Check ` pg_wal_summary_stats ` view to verify summaries are being generated.
2. **Use ` pg_createsubscriber ` for zero-downtime PG16→17 upgrades:** Instead of pg\_dump/restore, convert a physical standby to a logical subscriber with ` pg_createsubscriber --pgdata /var/lib/postgresql/17/main -d 'host=pg16-primary' `. This gives you a live replica you can promote when ready.
3. **` pg_stat_io ` reveals hidden I/O patterns:** Query ` SELECT backend_type, io_object, io_context, reads, writes, extends FROM pg_stat_io WHERE reads > 0 ORDER BY reads DESC ` to find which backend processes generate the most I/O. This can reveal that autovacuum is doing 3x more reads than client queries, a tuning goldmine.
4. **The new ` JSON_TABLE() ` eliminates most JSON-to-rows gymnastics:** Instead of ` jsonb_array_elements ` with lateral joins, use ` SELECT * FROM JSON_TABLE(data, '$.items[*]' COLUMNS (id INT PATH '$.id', name TEXT PATH '$.name')) `. It's SQL-standard, and the query planner handles it better than the equivalent jsonb functions.

- postgresql
- databases
- backup
- replication
- migration
- pg17

[Back to Blog](<https://lucioduran.com/blog>)
