Aurora PostgreSQL Storage Model: What Actually Changes
Aurora PostgreSQL Is Not the Postgres You Tune
"PostgreSQL-compatible" accurately describes Aurora's compute layer and misleadingly describes everything below it. The parser, planner, executor and SQL grammar are Postgres. The thing that turns dirty buffers into durable bytes is not. Aurora replaces the storage engine with a distributed, log-structured service shared by every instance in the cluster — and that one substitution quietly invalidates a large slice of what most of us learned about running Postgres in production.
If you want the short version first, there's a companion video covering the same ground in about five minutes: https://www.youtube.com/watch?v=GTFHE5KJSag. Watch that for the mental model, then come back here for the commands, the caveats, and the parts where AWS may have changed something since this was written.
The practical stakes are simple. A DBA who arrives at Aurora with RDS Postgres instincts will spend a quarter on checkpoint tuning that does nothing, build alerts on counters that mean something different, and then get blindsided by bloat because the storage volume grew silently to absorb it.
Two notes on scope. Everything here is about provisioned Aurora PostgreSQL. Aurora Serverless v2 and Aurora Limitless change the compute layer's scaling model on top of everything described here — that's a genuinely separate story with its own quirks, out of scope for this one. And where a behavior is version-dependent, the query is given rather than a fixed answer, because AWS updates this faster than articles do.
How Vanilla PostgreSQL Actually Writes
Worth being precise about the baseline, because the contrast is the whole article.
Vanilla Postgres has two write paths onto one local disk. Path one: WAL records go to pg_wal and are fsynced at commit, per synchronous_commit. That's your durability boundary. Path two: dirty pages in shared_buffers get flushed to the heap and index files, either by the checkpointer during a checkpoint, by the background writer trickling ahead of demand, or by a backend that needs a clean buffer and can't find one.
A checkpoint in community Postgres flushes all dirty shared buffers to the data files and fsyncs them. That's a genuine I/O event, and its size and pacing are governed by checkpoint_timeout, max_wal_size and checkpoint_completion_target. The background writer gets bgwriter_lru_maxpages and friends.
On top of that sits full_page_writes, on by default. The first modification of any page after each checkpoint writes the entire page image into WAL, so a torn 8 KB write on a filesystem with a different atomicity guarantee can be repaired from the log. That's exactly why WAL volume spikes right after a checkpoint and tapers off until the next one. Half the "why did my WAL generation triple for ninety seconds" investigations on self-managed clusters end at that sentence.
Streaming replicas maintain their own complete, independent copy of every data file and replay WAL to keep it current. Replica lag there is redo-throughput lag: the standby can't apply records as fast as the primary generates them.
Hold all of that in mind, because Aurora keeps roughly one item from that list.
The Aurora PostgreSQL Storage Model: The Log Is the Database
Aurora's writer sends redo log records over the network to the storage fleet. That's it. It doesn't ship full data pages, and it doesn't do double-writes or full-page writes to storage the way a conventional engine does. The storage nodes take those log records and materialize data pages themselves, in the background and on demand. Aurora's own framing for this — "the log is the database" — is accurate: log records are the durable, canonical representation, and pages are just a materialized view of them.
The cluster volume is carved into 10 GB segments (protection groups), each replicated six ways across three Availability Zones, two copies per AZ. Writes use a 4-of-6 quorum and reads a 3-of-6 quorum. The practical payoff: you can lose an entire AZ plus one additional copy and still serve reads, and lose an entire AZ without losing write availability. The volume grows automatically in 10 GB increments, up to 128 TiB on current Aurora versions, and it belongs to the cluster rather than to any single instance.
Aurora's published benchmarks report roughly a 7.7x reduction in network I/Os per transaction against a mirrored MySQL configuration on a write-heavy sysbench workload. The mechanism is unglamorous and worth stating plainly: they stopped sending pages and double-writes across the network. That's where the number comes from.
One UPDATE, Two Worlds
Take UPDATE accounts SET balance = balance - 10 WHERE id = 42; on a page already sitting in shared_buffers.
Community Postgres: 1. Backend pins the buffer, writes the new tuple version, marks the buffer dirty. 2. WAL record is built. If this is the first touch of that page since the last checkpoint and full_page_writes is on, the record carries a full 8 KB page image. 3. Commit: WAL is flushed and fsynced to pg_wal on local disk. Client gets its ack. 4. Sometime later — maybe seconds, maybe a full checkpoint_timeout— the checkpointer or bgwriter writes that 8 KB page to the heap file and fsyncs it. That second write is real disk I/O competing with your queries.
Aurora: 1. Backend pins the buffer, writes the new tuple version, marks the buffer dirty. 2. WAL record is built. No full page image. 3. Commit: the record is sent to the storage nodes owning that protection group. Once 4 of 6 acknowledge, the commit is durable and the client gets its ack. 4. Storage nodes coalesce the record into the page — lazily, on their own schedule. The writer instance never performs step 4 of the vanilla path at all.
Step 4 disappearing from the compute node is the source of nearly everything that follows.
What This Does to Aurora Checkpoints
There's no dirty-buffer flush from the writer to a data volume, so Aurora checkpoints aren't an I/O event you tune. checkpoint_timeout, max_wal_size and checkpoint_completion_target still exist as parameters and will accept values, but they no longer control the rate pages land on disk. Pages land in the storage fleet instead, driven by log application there. What used to be your single biggest predictable I/O storm on a busy OLTP instance simply doesn't happen the same way.
The follow-on: the statistics views lie by omission. Run this on Aurora and on a vanilla cluster of the same major version, side by side:
-- PostgreSQL 16 and earlier
SELECT * FROM pg_stat_bgwriter;
-- PostgreSQL 17+
SELECT * FROM pg_stat_checkpointer;
SELECT backend_type, object, context,
reads, writes, extends, fsyncs, hits, evictions
FROM pg_stat_io
ORDER BY writes DESC NULLS LAST;
PostgreSQL 17 split pg_stat_bgwriter: checkpoint counters moved to pg_stat_checkpointer, and per-backend, per-object I/O lives in pg_stat_io (added in PostgreSQL 16). Which of these you get on Aurora depends entirely on the underlying major version, and Aurora's version numbering doesn't map cleanly to open-source PG release timing:
SELECT version();
SELECT aurora_version();
SHOW aurora_version;
Aurora versions the engine layer separately from Postgres, which is why both matter. Two clusters on the same PostgreSQL 15 minor version can run different Aurora versions with different behavior.
Don't build alerting on buffers_checkpoint deltas or checkpoint_write_time on Aurora. On vanilla, those are your early warning for undersized max_wal_size and checkpoint storms. On Aurora they describe a code path that no longer does the work you're inferring from it.
That lesson usually arrives via a 2 a.m. page for "checkpoint write time sustained above threshold" on a cluster that's, by every latency metric that matters, perfectly healthy — because the alert rule was copied wholesale from a self-managed template and measures an activity Aurora's writer doesn't perform in the same shape.
Full-Page Writes and Durability You Don't Own
Because the storage layer applies redo records to pages itself, Aurora doesn't have the local-disk torn-page failure mode that full-page writes were invented to repair. The parameter is effectively managed for you — treat it as fixed rather than a tuning lever, and don't be surprised if it's marked non-modifiable in your parameter group. Confirm on your own cluster before trusting any article's phrasing to still hold on your engine version.
The useful corollary: WAL volume per transaction after a checkpoint isn't spiky the way it is on vanilla, because there are no post-checkpoint full page images inflating it. Any advice you've read about "WAL amplification after checkpoints, so raise checkpoint_timeout" is moot here. Capacity-plan Aurora write throughput off a vanilla WAL-per-transaction measurement and you'll overestimate.
Aurora Replica Lag: Same Volume, a Different Failure Mode
Aurora replicas attach to the same cluster volume as the writer. They don't keep their own copy of the data files, and they don't replay WAL to reconstruct pages on local storage. The writer streams log records to the readers so they can invalidate or update pages already sitting in their buffer caches.
So Aurora replica lag is cache-coherence lag, not redo-throughput lag. AWS documents it as typically tens to hundreds of milliseconds, materially below what a streaming replica shows under write-heavy load. A lagging Aurora replica needs a different runbook: you're not looking at single-threaded redo apply falling behind, you're looking at a reader that isn't keeping up with invalidation traffic, often because it's undersized relative to the writer or saturated by long analytic reads. If a replica falls far enough behind on applying invalidations, Aurora can restart it rather than let it serve stale reads indefinitely.
The instrument to use:
SELECT * FROM aurora_replica_status();
SELECT server_id,
session_id,
durable_lsn,
highest_lsn_rcvd,
cur_replica_lag_in_msec,
last_update_timestamp
FROM aurora_replica_status()
ORDER BY cur_replica_lag_in_msec DESC NULLS LAST;
The lag column is documented as cur_replica_lag_in_msec. Scripts in the wild sometimes use replica_lag_in_msec — run SELECT * FROM aurora_replica_status(); on your own cluster and read the actual column names before committing anything to a monitoring config. The CloudWatch equivalent is AuroraReplicaLag.
pg_stat_replication will look thin or unfamiliar, because the physical replication it's built to describe isn't what's happening between writer and readers.
Aurora Failover: Pointer Swap, Not Replay Marathon
Recovery is fast because the durable log lives in storage and pages are materialized there. A newly promoted writer doesn't have to drag a checkpoint's worth of WAL off local disk and replay it. AWS documents Aurora failover as typically completing within about 30 seconds when a reader exists in the cluster, with cluster and reader endpoint DNS updated to reflect the new roles.
Be honest with stakeholders about what "30 seconds" excludes:
- DNS TTL on the cluster endpoint, plus whatever your runtime caches on top of it. RDS Proxy helps here; connection pools that cache resolved addresses hurt.
- Buffer caches on the promoted instance are cold relative to the old writer's working set — expect ugly p99s for a while.
- Plan caches and prepared statements are gone.
- Application-side retry logic decides whether a 30-second control-plane event is a 30-second outage or a 3-minute one.
Fast failover is a real, significant win. It's not zero impact, and plenty of teams size their error budget as though it were.
The Tooling That Breaks
pg_basebackupcan't clone an Aurora instance to create a physical replica. Aurora provisions replicas through its storage layer, and the replication protocol and superuser access you'd need aren't available.- Physical replication outside the cluster is only available through features like Aurora Global Database. For an external target, logical replication is your route.
- Anything reading
pg_waldirectly, or driving scripts offpg_walfile_name(), assumes a local WAL directory laid out the way you expect. Audit those. - No filesystem or OS access at all. No
strace, no browsing the data directory, nols -la base/mid-incident, nopg_waldumpagainst local storage. Forensics that used to take one shell command now takes a support case. - Extensions are limited to the AWS allowlist for your engine version. Check
SHOW rds.extensions;and version-specific docs before designing anything around one. - No
ALTER SYSTEM. Configuration goes through DB cluster and DB parameter groups, and some parameters are simply marked not modifiable.
Config Card: What Still Matters
POINTLESS TO TUNE ON AURORA
checkpoint_timeout storage applies log; no compute-side flush event
checkpoint_completion_target same
bgwriter_lru_maxpages same
full_page_writes managed; storage-side page materialization
max_wal_size does not govern a local flush cadence
RESTRICTED / MANAGED
ALTER SYSTEM unavailable; use parameter groups
shared_preload_libraries cluster parameter group only, restart required
extensions AWS allowlist (see rds.extensions)
wal_level, archive_* managed by the platform
STILL CRITICAL, TUNE THESE
autovacuum_vacuum_cost_limit bloat control is entirely yours
autovacuum_max_workers ditto
autovacuum_naptime ditto
autovacuum_vacuum_scale_factor per-table overrides on big tables
work_mem / maintenance_work_mem planner and vacuum behavior unchanged
hot_standby_feedback reader reads can still hold back writer vacuum
statement_timeout long transactions still pin the xmin horizon
idle_in_transaction_session_timeout same
default_statistics_target planning is 100% vanilla Postgres
log_min_duration_statement your only real query forensics here
What the Aurora PostgreSQL Storage Model Doesn't Change
This is the section that earns the article its keep.
MVCC semantics, dead tuple accumulation, heap and index bloat, autovacuum behavior and transaction ID wraparound protection are unchanged on Aurora PostgreSQL. The storage layer doesn't remove dead tuples for you. It has no idea what a dead tuple is. It applies redo records to pages; whether those pages are 20% live rows and 80% garbage is entirely between you and autovacuum.
Stated plainly, so there's no ambiguity: vacuum, bloat, and transaction ID wraparound behave identically on Aurora and on vanilla Postgres. If a table is accumulating dead tuples faster than autovacuum reclaims them, or a long-idle transaction is holding back the freeze horizon, Aurora will not notice or fix it for you.
Long-running transactions still hold back the xmin horizon. Reads on Aurora replicas can hold back vacuum on the writer depending on how hot_standby_feedback-equivalent behavior is configured, so a reporting query on the reader can absolutely be the reason your writer's dead tuple count is climbing. It's tempting to assume Aurora's storage model somehow sidesteps this — it doesn't. Check that setting on your own cluster rather than assuming.
Same checks as always, and they work identically on both:
-- Wraparound headroom, per database
SELECT datname,
age(datfrozenxid) AS xid_age,
2000000000 - age(datfrozenxid) AS xids_remaining
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
-- Worst offenders for dead tuples
SELECT schemaname, relname,
n_live_tup, n_dead_tup,
ROUND(n_dead_tup::numeric
/ NULLIF(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 25;
Planner statistics, lock contention, TOAST behavior, index selection: all vanilla. A missing index is a missing index.
Where Aurora Costs You
Three specific ways this bites, and they compound.
On Aurora Standard, I/O requests are billed per million on top of storage. A bloated table that's quadrupled in physical size makes every sequential scan read four times the pages, and on Standard you pay for each of those reads — a badly planned query or a bloated table shows up directly on the invoice, not just in query latency. Aurora I/O-Optimized bundles I/O into a higher instance and storage rate, converting a variable bloat penalty into a fixed one. Neither option removes the bloat.
Storage autoscaling masks the growth. On a self-managed box, bloat announces itself when the filesystem hits 85% and your disk-space alert fires. On Aurora, the volume grows in 10 GB increments and nothing pages anyone. Aurora has shrunk volumes automatically since 2020, but vacuum returns space to the cluster volume's free space, not to a filesystem you can df. You find out at invoice time, or when a query that used to take 200 ms takes nine seconds.
And no filesystem access means the forensic toolkit you'd reach for during a weird incident is gone. Plan for that in your runbooks before you need it.
Aurora vs Vanilla PostgreSQL: Monitoring Translation Table
| Metric | Vanilla meaning | Aurora meaning | Verdict | |---|---|---|---| | buffers_checkpoint | Pages flushed by checkpointer; sizing signal for max_wal_size | Describes a compute-side flush that no longer drives storage I/O | Drop | | checkpoint_write_time | Duration of real disk write work | Not a proxy for storage pressure here | Drop | | WAL bytes generated | Write volume including post-checkpoint full page images | Write volume without FPW inflation; correlates with network traffic to storage, not local disk pressure | Reinterpret | | Replication lag | Redo apply throughput on standby | Cache-coherence lag; use AuroraReplicaLag / aurora_replica_status() | Reinterpret | | Disk free space | Hard ceiling; page at 85% | Autoscaling volume to 128 TiB; no ceiling alert to lean on | Drop (watch growth rate and cost instead) | | temp_bytes | work_mem too low or bad plans | Identical | Keep | | age(datfrozenxid) | Wraparound countdown | Identical | Keep | | Cache hit ratio | Buffer pool effectiveness | Still meaningful for shared_buffers; misses go to shared storage, and on Standard they cost money | Keep, with cost lens | | Deadlocks | Lock ordering bugs | Identical | Keep | | Idle in transaction | xmin horizon held back, vacuum blocked | Identical | Keep |
The pattern is clean: anything about the storage engine's local I/O behavior is now wrong or meaningless, and anything about MVCC, query behavior or application behavior transfers unchanged.
Where This Leaves You
Aurora's design is genuinely clever, and the log-only write path is the good kind of clever: one idea, applied consistently, with the network-I/O reduction falling out as a consequence rather than a bolt-on. You get fast failover, replicas that are cheap because they share the volume, and no checkpoint I/O storms.
You do not get freedom from vacuum, bloat, wraparound, bad queries or bad schema design. Those are all still yours, and on Aurora they're harder to see because the two signals that used to warn you — disk filling up and checkpoint I/O spiking — have either been removed or now mean something else.
Practically: audit your alert rules against the table above, delete the ones measuring a code path that no longer runs, and reinvest that attention in dead tuple ratios, xmin horizon age and replication lag measured through aurora_replica_status() rather than pg_stat_replication.
If you want a second pair of eyes on that, MyDBA's free health check is Aurora-aware: it reads replication lag through the Aurora functions rather than assuming streaming replication, flags inactive replication slots with the exact DROP statement to run, and checks WAL archiver health without needing filesystem access you don't have. It's read-only and takes a few minutes. The bloat and wraparound findings apply identically whether you're on Aurora or a box in a rack — which is rather the point of this whole article.