Aurora PostgreSQL Storage Architecture Explained

Aurora PostgreSQL doesn't write pages to disk the way Postgres does. It ships write-ahead log records to a distributed, log-structured storage layer and lets that layer build pages on demand. That one design choice explains almost every "why does this metric look wrong" question that shows up when a Postgres-trained team inherits an Aurora cluster — checkpoints that barely move, replicas that lag differently than streaming replication ever did, and a failover that finishes before your monitoring even alerts.

📖 Read the full guide: Aurora PostgreSQL Storage Model: What Actually Changes

▶ Watch the video walkthrough: Aurora's storage model: how it differs from real Postgres
https://www.youtube.com/watch?v=GTFHE5KJSag

If you're troubleshooting one of those symptoms right now, skip to the audit script below. If you want the reasoning first, keep reading.

How Aurora's Storage Layer Differs From Vanilla Postgres

Standard Postgres treats storage as dumb: the database engine computes full pages, runs them through the buffer manager, and eventually fsyncs them to whatever filesystem sits underneath. Aurora inverts that. The compute node — your writer — sends WAL records outward to six storage nodes across three availability zones, and the storage layer itself is responsible for turning those log records into pages, replaying them, and serving them back on read.

This is the core of the aurora vs postgresql differences that actually matter operationally:

  • No full-page writes get shipped as data. Only log records cross the network to storage, which is why Aurora write throughput on the same instance class often beats self-managed Postgres on network-attached disk.
  • The buffer cache survives a restart. Because pages are reconstructed from log rather than read fresh from a data file that a crash may have left half-written, Aurora skips the usual "warm the cache back up" penalty after a restart.
  • Storage is the unit of durability, not the instance. Six copies across three AZs means losing a single AZ doesn't touch quorum. This is also why point-in-time recovery and cloning are storage operations, not full copies — copy-on-write against the same volume works at any database size in minutes.

Aurora Checkpoints and pg_stat_bgwriter: What to Actually Watch

The first thing that trips up an experienced Postgres DBA is pg_stat_bgwriter. On self-managed Postgres, buffers_checkpoint and checkpoint frequency are load-bearing signals — they tell you if checkpoint_timeout and max_wal_size are tuned correctly. On Aurora, checkpoints exist, but they're a much smaller operation, because the storage layer is already durable and already replaying log continuously. A checkpoint on Aurora is closer to a bookkeeping marker than the "flush everything dirty to disk" event it is upstream.

That means two things in practice. First, don't tune checkpoint_timeout and max_wal_size on Aurora the way you would on EC2-hosted Postgres — the parameters exist for compatibility, but the workload they're protecting against isn't the same. Second, watch pg_stat_bgwriter for trend, not for absolute values borrowed from a vanilla-Postgres runbook. A sudden change in checkpoint counters usually means something upstream (autovacuum, a bulk load) changed, not that the storage layer is struggling.

Aurora Replica Lag vs Streaming Replication

This is the difference that causes the most confusion. Standard Postgres streaming replication ships WAL to a replica, which applies it and catches up at its own pace — lag is a function of network, replay speed, and replica load. Aurora read replicas don't replay WAL at all. They read from the same shared, distributed storage volume the writer uses, so what you're calling "replica lag" is really cache-invalidation lag: how quickly a reader's local buffer cache learns that a page it's holding was just changed underneath it.

Practically, that's why Aurora replica lag is typically sub-second even under decent write load — there's no independent replay stream to fall behind on. It's also why fifteen readers can sit off one volume: there are no fifteen copies of the data and no fifteen replay streams to keep synchronized, unlike logical replication fanned out across regions, which does cost writer throughput.

Check aurora_replica_status() and the AuroraReplicaLag CloudWatch metric together, not pg_stat_replication, which won't tell the real story here.

Aurora Failover Time: Why It's Fast

Because readers already share the writer's storage volume, promoting a reader to writer doesn't involve catching up on WAL — it just means redirecting connections and letting the new writer establish itself against storage it was already reading from. That's the mechanical reason Aurora failover time is usually measured in seconds rather than the tens of seconds to minutes typical of a streaming-replication promotion. Promotion tiers matter here: if every reader in your cluster sits at the same tier with none flagged as the preferred failover target, Aurora is choosing somewhat arbitrarily among them, which is worth fixing before it fixes itself at 3 a.m.

A 20-Minute Audit for a Cluster You Just Inherited

Run all of this on the writer.

-- 1. What am I actually running?
SELECT version();
SELECT aurora_version();

-- 2. Parameter provenance
SELECT name, setting, unit, context, source
FROM pg_settings
WHERE name IN ('checkpoint_timeout','max_wal_size','full_page_writes',
               'wal_level','wal_buffers','shared_buffers','hot_standby_feedback')
ORDER BY name;

-- 3. Checkpoint counters (use the variant matching your major version, above)

-- 4. Replica topology and lag
SELECT * FROM aurora_replica_status();

-- 5. Replication slots: the money leak
SELECT slot_name, plugin, slot_type, database, active, active_pid, restart_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;

-- Drop what nobody owns. Confirm with the team first, then:
SELECT pg_drop_replication_slot('the_slot_name_here');

-- 6. Wraparound headroom
SELECT datname,
       age(datfrozenxid) AS xid_age,
       current_setting('autovacuum_freeze_max_age')::int AS freeze_max_age,
       round(100.0 * age(datfrozenxid)
             / current_setting('autovacuum_freeze_max_age')::int, 1) AS pct_of_max
FROM pg_database
ORDER BY xid_age DESC;

-- 7. Worst offenders by dead tuples
SELECT schemaname, relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS pct_dead,
       last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 25;

Then leave SQL and check three things in the console: FreeLocalStorage for every instance over the last 30 days, AuroraReplicaLag percentiles, and the promotion tier on each reader. If every instance sits at the same tier and none is a designated tier-0 target, that's your first ticket.

If you'd rather have the slot, lag, and wraparound checks run for you and handed back as a report, the free MyDBA health check covers exactly those three — they're the ones that produce the most 3 a.m. pages.

Verdict: When Aurora's Storage Model Earns Its Price

Aurora earns it when you're read-heavy and want six to fifteen readers without maintaining six to fifteen copies of the data and six to fifteen replay streams. It earns it when you need production-sized clones for CI or staging on a daily cadence, because copy-on-write at any volume size in minutes is genuinely hard to replicate yourself. It earns it when your team has no DBA on pager rotation and the value of never running a pg_basebackup restore drill outweighs the loss of control.

It does not earn it for a single-node, write-latency-sensitive workload where the fsync path is your critical path. A well-tuned Postgres on local NVMe, with an owner who knows what buffers_backend climbing means, will beat it on write latency and cost roughly a tenth as much. I've moved workloads in both directions and been right both times.

Whichever side you land on, stop assuming "PostgreSQL-compatible" means your runbook transfers. Half of it does. The other half is measuring a thing your database isn't doing.

Leave a Comment