Tuning RDS Parameter Groups for PostGIS Workloads
A managed PostgreSQL instance arrives configured for a generic transactional workload: many small queries, short-lived sorts, modest working sets. A PostGIS instance serving tiles and running raster analysis is the opposite on every axis — geometry columns are large, spatial joins sort and hash aggressively, GiST index builds are memory-hungry, and a single ST_Intersects against un-generalized polygons can allocate more working memory than a hundred ordinary queries combined. Tuning the parameter group is therefore not an optimisation; it is the difference between a database that serves a tile fleet and one that spills every spatial sort to disk. This guide extends PostGIS Cluster Provisioning within Geospatial Resource Provisioning, and it begins with the unit mistake that invalidates most tuning attempts before they start.
Units matter and are not obvious. Managed PostgreSQL expresses memory parameters in 8 kB blocks, not bytes and not percentages.
shared_buffers = 2048is 16 MB, not 2 GB. A value written as a percentage string is accepted by the API in some parameter contexts and silently means something else. Every memory value in this guide is in 8 kB blocks unless stated otherwise, and every value you copy from a self-managedpostgresql.conftutorial is not.
Symptom identification and triage
Four observable symptoms map to four distinct parameters, and treating the wrong one is the usual reason tuning “does nothing”.
Spatial sorts spilling to disk. EXPLAIN (ANALYZE, BUFFERS) on a tile query shows Sort Method: external merge Disk: 84MB. The sort did not fit in working memory and was written to temporary files. This is work_mem, and it is per sort node per connection — a single query with three sort nodes can allocate three times the value, which is why raising it globally is dangerous.
Poor cache behaviour under tile load. The buffer cache hit ratio sits well below the high nineties while the working set would comfortably fit in memory. Geometry columns are wide, so a PostGIS working set is larger than the row count suggests. This is shared_buffers.
Sequential scans where an index exists. The planner chooses a sequential scan on a table with a valid GiST index. Frequently this is random_page_cost left at the spinning-disk default of 4.0 on an instance backed by solid-state storage, which makes the planner systematically overestimate the cost of index access.
Index builds and vacuums that take hours. A GiST index rebuild on a large geometry table is dominated by maintenance_work_mem, which defaults low. This one is safe to set generously because it applies only to maintenance operations, of which few run concurrently.
Prerequisites and environment assumptions
Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. A custom parameter group rather than the default one, because the default group cannot be modified and the attempt fails confusingly. Knowledge of the instance’s memory in gigabytes, since every value below is a fraction of it. A representative workload to measure against — tuning against an idle instance produces numbers that are arithmetic rather than evidence.
Understand which parameters require a reboot before you plan. shared_buffers and max_connections are static: applying them schedules a pending change that takes effect at the next reboot, and a team that applies and then measures without rebooting will conclude the change did nothing. work_mem, random_page_cost and maintenance_work_mem are dynamic and take effect immediately.
Step-by-step tuning
-
Set
shared_buffersto about a quarter of instance memory. For a 32 GB instance that is 8 GB, which in 8 kB blocks is8 * 1024 * 1024 / 8 = 1048576. Going much beyond a quarter rarely helps on managed PostgreSQL because the operating system cache is doing useful work with the remainder, and going far beyond it can degrade performance. -
Set
work_memfrom the concurrency budget, not from instinct. The safe ceiling is roughly(instance_memory − shared_buffers) / max_connections / expected_sort_nodes_per_query. For a 32 GB instance with 8 GB of buffers, 200 connections and two sort nodes per query, that is about 60 MB. Spatial queries genuinely need more than a transactional default, but a value that is safe at ten connections will exhaust memory at two hundred. -
Raise
maintenance_work_memgenerously. 2 GB is reasonable on a large instance. This governs index builds,VACUUMandCREATE INDEX, and since few run concurrently the multiplication risk that constrainswork_memdoes not apply. -
Lower
random_page_costto match the storage. On solid-state storage 1.1 is a defensible value; the default 4.0 encodes an assumption about seek latency that has not been true for the storage class most spatial databases run on for a decade. This single change often converts the sequential scans in symptom three into index scans. -
Set
effective_cache_sizeto roughly three quarters of instance memory. It allocates nothing; it tells the planner how much cache it may assume exists, and leaving it at a small default makes the planner pessimistic about index access in exactly the way that hurts spatial queries. -
Apply, reboot if a static parameter changed, then measure. Re-run the query that showed the symptom and confirm the plan changed.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
variable "instance_memory_gb" { type = number }
variable "max_connections" { type = number }
locals {
# UNITS: these parameters are in 8 kB blocks. 1 GB = 131072 blocks.
blocks_per_gb = 131072
shared_buffers_blocks = floor(var.instance_memory_gb * local.blocks_per_gb * 0.25)
# work_mem is per sort node per connection. Two sort nodes per query is a
# conservative assumption for spatial joins; three is not unusual.
work_mem_blocks = floor(
(var.instance_memory_gb * local.blocks_per_gb * 0.75) / var.max_connections / 2
)
maintenance_work_mem_blocks = 2 * local.blocks_per_gb
# effective_cache_size allocates nothing — it is advice to the planner.
effective_cache_size_blocks = floor(var.instance_memory_gb * local.blocks_per_gb * 0.75)
}
resource "aws_db_parameter_group" "postgis" {
name = "postgis-tuned-16"
family = "postgres16"
parameter {
name = "shared_buffers"
value = tostring(local.shared_buffers_blocks)
apply_method = "pending-reboot" # static: nothing changes until a reboot
}
parameter {
name = "work_mem"
value = tostring(local.work_mem_blocks)
apply_method = "immediate"
}
parameter {
name = "maintenance_work_mem"
value = tostring(local.maintenance_work_mem_blocks)
apply_method = "immediate"
}
parameter {
# Not a memory parameter — a plain float, and the one change that most often
# converts a sequential scan into an index scan on SSD-backed storage.
name = "random_page_cost"
value = "1.1"
apply_method = "immediate"
}
parameter {
name = "effective_cache_size"
value = tostring(local.effective_cache_size_blocks)
apply_method = "immediate"
}
parameter {
# Surfaces the slow spatial queries this tuning exists to fix.
name = "shared_preload_libraries"
value = "pg_stat_statements"
apply_method = "pending-reboot"
}
}
Verification
Confirm the values landed in the units you intended, which is the check that catches the block-versus-byte error: SHOW shared_buffers returns a human-readable size, so a configuration you believed was 8 GB and which reports 64MB tells you immediately that the arithmetic was wrong by a factor of 128.
Then confirm the behaviour changed. Re-run the query from the symptom with EXPLAIN (ANALYZE, BUFFERS) and look for Sort Method: quicksort Memory: where external merge Disk: used to be, and for an index scan where a sequential scan used to be. Check pg_stat_statements ordered by total execution time and confirm the query you tuned for has moved down the list. Finally, apply load: a work_mem that is correct for one session and wrong for two hundred looks perfect until the fleet scales, so validate under the concurrency the tile tier actually produces, which is bounded by the pool arithmetic in Vector Tile Service Provisioning.
Preventing recurrence
- Derive the values, never copy them. A parameter group with literal numbers is correct for one instance size and silently wrong after a resize. Computing from
instance_memory_gbkeeps them right. - Assert the units in CI. A plan-contract assertion that memory parameters resolve to integers in a plausible block range catches the percentage-string and byte-count errors before apply, as described in Testing and Validation for Spatial IaC.
- Set per-role overrides instead of raising the global.
ALTER ROLE analytics SET work_memgives the heavy analytical role its memory without multiplying it across the whole tile fleet. - Re-tune after a resize. Changing the instance class without regenerating the parameter group leaves the database configured for the old machine, which is the most common way a scale-up produces no improvement.
Frequently Asked Questions
Why is my `shared_buffers` change having no effect?
It is a static parameter. The change is pending until the instance reboots, and until then the running database uses the old value. Check the parameter group status for pending-reboot and schedule the reboot in a maintenance window.
Can I express these as a percentage of instance memory?
Some managed parameter contexts accept expressions referencing instance memory, but mixing those with block integers in one group is a reliable way to produce a value nobody can interpret later. Compute the integer in the module, where the arithmetic is visible and testable, and keep the group unambiguous.
How high can `work_mem` go for raster analysis?
As high as the concurrency budget allows, which for an analytical role on a dedicated connection can be several hundred megabytes. The rule is unchanged: it is multiplied by sort nodes and by concurrent connections, so grant the high value to the specific role that needs it rather than to every session.
Does lowering `random_page_cost` risk making bad plans?
It shifts the planner toward index access, which is usually correct on solid-state storage and is what you want for spatial predicates. If a specific query regresses, the honest fix is to look at its statistics and its index rather than to restore a cost model that describes hardware you are not running.
Related
- PostGIS Cluster Provisioning — the parent topic covering cluster shape and extensions
- Provisioning Read Replicas for PostGIS Tile Queries — where tuned read capacity is added alongside these settings
- Zero-Downtime PostGIS Major Version Upgrades — carrying a tuned parameter group across a version change
- Vector Tile Service Provisioning — the concurrency budget that bounds work memory