Zero-Downtime PostGIS Major Version Upgrades
A major version upgrade of a spatial database is two upgrades wearing one name. The PostgreSQL engine moves — 15 to 16, say — and the PostGIS extension moves with it, and the extension carries its own compatibility surface: function signatures, the raster module’s packaging, and index operator classes that may require rebuilding. An in-place upgrade will do both, take the database offline for the duration, and leave you with indexes the planner distrusts. Achieving it without downtime means running both versions simultaneously and moving traffic, which is a different procedure with a different set of hazards. This guide extends PostGIS Cluster Provisioning within Geospatial Resource Provisioning.
Choosing the upgrade path
Two paths exist and the choice is determined by how much downtime the tile tier can absorb, not by preference.
In-place upgrade. The managed service upgrades the instance, taking it offline for a window that scales with database size — typically minutes for tens of gigabytes, longer for terabytes of geometry. Simple, one command, fully supported, and unavailable to you if the platform serves interactive maps during business hours across time zones.
Logical replication cut-over. Provision a new cluster at the target version, replicate the data into it logically, let it catch up, then switch the application’s endpoint. Downtime shrinks to the length of the switch, seconds rather than minutes, at the cost of a substantially more involved procedure and a period where two clusters both hold the data.
The spatial complications apply to both. PostGIS must be upgraded within the database after the engine moves, with ALTER EXTENSION postgis UPDATE, and until that runs the extension’s function definitions may not match the installed library. Spatial indexes should be reindexed after a major upgrade because operator-class internals can change between versions, and statistics must be regenerated because the planner starts with none — a freshly upgraded cluster whose statistics have not been rebuilt will choose sequential scans over your GiST indexes and look dramatically slower than the cluster it replaced.
Prerequisites and environment assumptions
Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. A recent snapshot, verified by an actual restore rather than by its existence in a console list. Confirmation that the target engine version ships a PostGIS version compatible with your schema — a check that has to be done against the release notes for both, since a PostGIS major move can deprecate function signatures your views depend on.
Before anything else, inventory what depends on PostGIS behaviour. Materialised views over spatial functions, functions using deprecated signatures, and any application code pinning a PostGIS version string all need review. SELECT * FROM postgis_extensions_upgrade() and the deprecation notes are the reference; the audit is unglamorous and it is where the surprises are.
Step-by-step cut-over upgrade
-
Provision the target cluster from code. Same instance class, same subnet group, the tuned parameter group regenerated for the new engine family — a parameter group is family-specific, so
postgres15settings do not carry topostgres16and the new group must be created deliberately rather than inherited. -
Create the publication and subscription. Logical replication copies table data; it does not copy schema, sequences’ current values, or large objects. Create the schema on the target first, including the PostGIS extension at the target version, then subscribe.
-
Let it catch up and watch the lag. Replication lag must reach and hold near zero before you consider switching. On a large geometry table the initial copy can take hours, and the copy is the part that surprises teams who scheduled a one-hour window.
-
Freeze writes briefly and confirm zero lag. Stop write traffic — a read-only period of seconds — and confirm the subscription has consumed everything. This is the only true downtime in the procedure.
-
Switch the endpoint. Update the DNS record or the connection secret that the renderers and pipelines read, so consumers reconnect to the new cluster. Managing this through the secret rather than through hardcoded hostnames is what makes it a one-line change, which is the pattern in Rotating PostGIS Credentials in Terraform Without Downtime.
-
Reset sequences, reindex spatially, and analyze. Logical replication does not advance sequences on the target; a missed reset produces duplicate key errors on the first insert. Then
REINDEXthe spatial indexes and runANALYZEacross the spatial tables before releasing traffic, or the first minutes on the new cluster will look like a performance regression.
-- On the SOURCE cluster.
CREATE PUBLICATION spatial_pub FOR ALL TABLES;
-- On the TARGET cluster, after creating the schema and the extension.
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE SUBSCRIPTION spatial_sub
CONNECTION 'host=old.cluster.internal dbname=gis user=replicator sslmode=verify-full'
PUBLICATION spatial_pub;
-- Watch until this is empty or near zero before switching.
SELECT slot_name,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS lag
FROM pg_replication_slots;
-- After the switch, on the TARGET. Sequences are NOT replicated: skipping this
-- produces duplicate key errors on the first insert.
SELECT setval('parcels_id_seq', (SELECT max(id) FROM parcels));
-- Operator-class internals can change across a major version. Rebuild spatial
-- indexes concurrently so reads continue while it runs.
REINDEX INDEX CONCURRENTLY parcels_geom_idx;
-- The new cluster starts with no statistics at all.
ANALYZE parcels;
ANALYZE roads;
# The parameter group is family-specific: postgres15 settings do not carry
# forward, so the tuned group is regenerated for the new family rather than
# reused. See the tuning guide for how these values are derived.
resource "aws_db_parameter_group" "postgis_16" {
name = "postgis-tuned-16"
family = "postgres16"
parameter {
name = "shared_buffers"
value = tostring(local.shared_buffers_blocks) # 8 kB blocks
apply_method = "pending-reboot"
}
parameter {
name = "random_page_cost"
value = "1.1"
apply_method = "immediate"
}
}
resource "aws_db_instance" "postgis_16" {
identifier = "gis-postgis-16"
engine = "postgres"
engine_version = "16.4"
parameter_group_name = aws_db_parameter_group.postgis_16.name
# Logical replication requires the WAL to carry enough detail; on managed
# PostgreSQL this is enabled through the parameter group on the SOURCE.
skip_final_snapshot = false
lifecycle { prevent_destroy = true }
}
Verification
Confirm both versions moved, not just one: SELECT version() for the engine and SELECT postgis_full_version() for the extension, and check that the reported GEOS and PROJ versions are the ones you expected — a PostGIS built against a different PROJ can change reprojection results at the last decimal places, which matters for anything that compares geometries for equality.
Then confirm the data and the plans. Row counts on the largest spatial tables must match the source. A representative tile query must produce an index scan, not a sequential scan; if it does not, ANALYZE has not finished. Fetch a tile through the real endpoint and compare it byte-for-byte with one captured before the upgrade — identical bytes prove the whole chain, from geometry through the encoder, is unchanged. Finally confirm writes work, which is the check that catches the unset sequence.
Preventing recurrence
- Rehearse on a restored snapshot. The entire procedure, including timings, on a copy of production. The initial copy duration is the number that invalidates most upgrade plans, and it is only knowable by measuring.
- Keep the old cluster stopped, not destroyed, for a full business cycle. A weekly reconciliation job that nobody remembered will exercise a code path the first day did not.
- Encode the post-upgrade steps as a script, not a checklist. Sequence resets, reindexes and analyzes are exactly the steps a tired operator skips at 2am.
- Track version currency as a scheduled task. Major upgrades are painful in proportion to how many versions are skipped; upgrading one version behind is routine, upgrading four is a project.
Frequently Asked Questions
Do I have to reindex spatial indexes after a major upgrade?
Treat it as required unless the release notes explicitly say the operator classes are unchanged. Rebuilding is cheap relative to the cost of discovering, days later, that a GiST index is producing correct but slow results. Use REINDEX CONCURRENTLY so reads continue during the rebuild.
Why is the new cluster slower immediately after the switch?
Almost always missing statistics. A freshly populated cluster has none, so the planner cannot estimate selectivity and falls back to sequential scans. Run ANALYZE across the spatial tables and re-measure before drawing any conclusion about the upgrade.
Can I upgrade a read replica first and promote it?
Managed services generally do not permit a replica to run a different major version from its primary, so the promote-an-upgraded-replica pattern is not available. Logical replication into a new cluster is the supported route to the same outcome.
What if the application depends on a deprecated PostGIS function?
Fix it before the upgrade, not during. Deprecated signatures should be found by the dependency audit in the prerequisites; discovering one during the cut-over turns a switch you can reverse into an incident you cannot, because the old cluster has by then diverged.
Related
- PostGIS Cluster Provisioning — the parent topic on cluster shape, extensions and backups
- Tuning RDS Parameter Groups for PostGIS Workloads — regenerating the tuned group for the new engine family
- Provisioning Read Replicas for PostGIS Tile Queries — rebuilding read capacity after the cut-over
- Rotating PostGIS Credentials in Terraform Without Downtime — switching the endpoint through the secret rather than by hostname