DEV Community

Philip McClarence
Philip McClarence

Posted on

Aurora PostgreSQL Storage Model: What Actually Changed

Short answer: Aurora PostgreSQL never writes heap or index pages to durable storage — only redo log records — so roughly half of your checkpoint and replication tuning playbook is dead weight. The other half (vacuum, work_mem, connection limits) matters exactly as much as it always did.

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

I've watched teams port a checkpoint-tuning runbook to Aurora and spend a week chasing a parameter that does nothing. They opened a ticket about checkpoint_completion_target and argued over max_wal_size while the real problem was an autovacuum that hadn't finished on a 400 GB table since the last deploy.

The confusion is understandable. Aurora PostgreSQL speaks the wire protocol, runs the same planner, has the same MVCC semantics, and answers SELECT version() in a way that looks familiar. Underneath, the durability layer is different software entirely — and that gap is where most Aurora vs PostgreSQL architecture confusion comes from.

There's a companion video that walks through the architecture visually in about ten minutes: https://www.youtube.com/watch?v=GTFHE5KJSag. This post goes further, with the SQL you'd actually run to check any of it.

TL;DR

  • The Aurora writer sends redo log records to storage. It does not write heap or index pages to durable storage.
  • Storage nodes apply the log and materialize page versions themselves, across six copies in three AZs.
  • Checkpoints stop being an I/O event you can feel, so pg_stat_bgwriter dashboards go quiet and stay quiet.
  • Readers attach to the same volume as the writer, so "replica lag" measures cache application, not WAL replay into a second copy of your data.
  • Roughly half of a classic tuning playbook is inert. The half that still matters (vacuum, wraparound, work_mem, connections) matters exactly as much as it always did.

Aurora vs PostgreSQL Architecture: The Vanilla Write Path

On a self-managed box: a backend dirties a page in shared_buffers, writes a WAL record into pg_wal, and fsyncs that WAL at commit. If this is the first touch of the page since the last checkpoint, full_page_writes=on stuffs a full 8 KB image of the page into the WAL stream, because an 8 KB write isn't atomic against an OS or hardware crash. The checkpointer periodically flushes dirty buffers out to the data files, governed by checkpoint_timeout, max_wal_size, and checkpoint_completion_target. The background writer trickles in between. A physical streaming replica receives the WAL and replays it into its own complete copy of every data file. Ask anyone who's watched iostat spike the moment checkpoint starting: time shows up in the logs — that's a real, feelable I/O event.

Two things to hold onto: there are two full copies of the data, and the checkpoint is a real I/O event with a real cost you tune away from peak traffic.

What the Aurora PostgreSQL Storage Model Does Instead

The Aurora paper's phrase is "the log is the database" (Verbitski et al., SIGMOD 2017). The database tier ships redo log records to the storage tier. Storage nodes apply those records to their own page versions, in the background and on demand when a read needs a version that hasn't been materialized yet.

The cluster volume keeps six copies across three Availability Zones, with a 4-of-6 write quorum and a 3-of-6 read quorum, sliced into 10 GB protection groups. The volume autoscales up to 128 TiB for Aurora PostgreSQL.

Because only log records cross the network, network amplification drops sharply — the paper reports roughly a 7.7x reduction in network IOs per transaction against a mirrored-MySQL configuration in their sysbench comparison. That figure is from the MySQL-flavored benchmark, not a PostgreSQL measurement, so treat it as directional rather than a number to quote in a capacity plan.

What's published: the quorum scheme, the segment size, the log-only write path. What's inference on my part: exactly when a given storage node decides to materialize a page version versus serve it from the log chain. AWS doesn't document that scheduling, and it doesn't matter much operationally, but I'd rather flag it than pretend.

Consequence 1: Aurora Checkpoints Stop Being an Event

If the writer never flushes dirty data pages to durable storage, the parameters that throttle that flush have lost their job. Here's a version-portable query, because PostgreSQL 17 moved the checkpoint counters out of pg_stat_bgwriter into pg_stat_checkpointer, and which columns Aurora exposes depends on the engine version you're running.

DO $$
DECLARE rec record; q text;
BEGIN
  IF EXISTS (SELECT 1 FROM pg_views WHERE viewname = 'pg_stat_checkpointer') THEN
    q := $q$SELECT num_timed, num_requested, write_time, sync_time,
                   buffers_written, stats_reset FROM pg_stat_checkpointer$q$;
  ELSE
    q := $q$SELECT checkpoints_timed   AS num_timed,
                   checkpoints_req     AS num_requested,
                   checkpoint_write_time AS write_time,
                   checkpoint_sync_time  AS sync_time,
                   buffers_checkpoint    AS buffers_written,
                   stats_reset FROM pg_stat_bgwriter$q$;
  END IF;
  FOR rec IN EXECUTE q LOOP
    RAISE NOTICE 'timed=% req=% write_ms=% sync_ms=% buffers=% since=%',
      rec.num_timed, rec.num_requested, rec.write_time,
      rec.sync_time, rec.buffers_written, rec.stats_reset;
  END LOOP;
END $$;
Enter fullscreen mode Exit fullscreen mode

Self-managed box under a steady OLTP load, one week of uptime:

NOTICE:  timed=2016 req=143 write_ms=41883204 sync_ms=98771 buffers=118442901 since=2026-08-02 04:11:07+00
Enter fullscreen mode Exit fullscreen mode

Aurora writer, comparable workload and uptime:

NOTICE:  timed=2016 req=0 write_ms=612 sync_ms=0 buffers=1174 since=2026-08-02 04:09:55+00
Enter fullscreen mode Exit fullscreen mode

The timed count still ticks because the checkpointer process still runs. The buffer and write-time numbers are effectively noise. If you have a Grafana panel plotting buffers_checkpoint rate or checkpoint_write_time as your write-pressure signal, that panel is lying to you — pg_stat_bgwriter on Aurora just doesn't carry the same meaning. Delete it and watch WriteIOPS, WriteLatency, and CommitLatency in CloudWatch instead.

Consequence 2: full_page_writes Isn't Yours

SHOW full_page_writes;
 full_page_writes
------------------
 off
Enter fullscreen mode Exit fullscreen mode

Storage handles page materialization, so torn pages aren't a risk the writer has to insure against by doubling its WAL volume after every checkpoint. Try to set it in a cluster parameter group and you'll find it's not a modifiable parameter. This is also why WAL volume per transaction on Aurora looks different from a vanilla instance running the same workload — no periodic full-page-image spike after each checkpoint.

Consequence 3: Replicas Are Compute, Not Copies

Up to 15 Aurora Replicas attach to the same cluster volume as the writer. No pg_basebackup. No base backup transfer. No second physical copy of your data. Adding a reader is provisioning an instance and pointing it at storage.

The reader still consumes the log stream, but only to keep its own buffer cache and in-memory structures consistent. A page the writer just modified can be served correctly by a reader only after the reader applies the relevant record. That's why read-after-write against a reader endpoint is typically low single-digit milliseconds rather than zero, and why routing a read-your-own-write flow to the reader endpoint produces the occasional bug report you'll spend two days reproducing.

Consequence 4: Measuring Aurora Replica Lag Correctly

pg_stat_replication on an Aurora writer doesn't enumerate Aurora Replicas the way it enumerates streaming standbys, because they aren't streaming standbys. Dashboards built on it silently render an empty panel, which people misread as "no lag."

Use the Aurora function on the writer:

SELECT server_id, session_id, highest_lsn_rcvd,
       cur_replica_lag_in_msec, last_update_timestamp
FROM aurora_replica_status();
Enter fullscreen mode Exit fullscreen mode
   server_id    |              session_id              | highest_lsn_rcvd | cur_replica_lag_in_msec |    last_update_timestamp
----------------+--------------------------------------+------------------+-------------------------+---------------------------
 prod-writer-1  | MASTER_SESSION_ID                    |     412998877123 |                         | 2026-08-09 11:42:18.204+00
 prod-reader-1a | 8f2c1e77-3a4b-4e19-9d02-1c6a4e2f8b33 |     412998877098 |                    3.12 | 2026-08-09 11:42:18.199+00
 prod-reader-1b | 2b91f043-7c55-41aa-8f6e-90b7c1d4a207 |     412998876940 |                   11.48 | 2026-08-09 11:42:18.201+00
Enter fullscreen mode Exit fullscreen mode

On the reader itself, these still work and are worth keeping in your checks:

SELECT pg_last_wal_replay_lsn(), pg_last_xact_replay_timestamp();
Enter fullscreen mode Exit fullscreen mode

For alerting, AuroraReplicaLag and AuroraReplicaLagMaximum in CloudWatch are the metrics to page on. Both are in milliseconds — not the seconds you're used to from pg_stat_replication lag columns — so set thresholds accordingly or you'll never fire.

Consequence 5: Aurora Failover and Recovery

With at least one reader present, failover is a promotion plus a cluster-endpoint DNS change. There's no replay of WAL from a checkpoint into a local heap, because storage already holds durable, materializable pages.

The honest version of the numbers: promotion is fast, and then you wait on DNS TTL, a connection storm from every app pod reconnecting at once, and a cold buffer cache on the new writer serving your worst queries. Failover tiers (0 highest through 15, instance size as tiebreaker) let you control which reader gets promoted. RDS Proxy is the other lever that meaningfully cuts observed downtime, because it absorbs the reconnect stampede.

A single-instance cluster with no reader doesn't get promotion. Aurora recreates or restarts the writer, which takes considerably longer. If your production cluster is one instance, you don't have HA — you have a good backup story.

Aurora vs Vanilla PostgreSQL: Key Differences

Concern Vanilla PostgreSQL Aurora PostgreSQL
Writes to durable storage WAL plus dirty data pages Redo log records only
Checkpoints Real I/O event, tuned via checkpoint_timeout, max_wal_size, checkpoint_completion_target Metadata-ish; counters near-flat
full_page_writes on, doubles WAL after each checkpoint Storage-managed, not modifiable
Replica data Own full copy of every file Same shared cluster volume
Replica build pg_basebackup / base backup transfer Provision an instance, minutes
Lag semantics WAL replay lag, seconds Cache application lag, milliseconds
Failover Manual or Patroni-style promotion + fencing Reader promotion + endpoint DNS, tier-controlled
Backup pg_basebackup, pgBackRest, archive_command Continuous storage-level, PITR to a new cluster

What Still Matters

VACUUM, bloat, and transaction ID wraparound behave identically on Aurora PostgreSQL. MVCC, dead tuples, table and index bloat, autovacuum tuning, and wraparound are engine-layer concerns, not storage-layer ones. Storage magic does not save you from wraparound. I've seen an Aurora cluster inside 12 million transactions of a forced shutdown, and the storage architecture contributed nothing to the fix. Keep an eye on age(datfrozenxid) the same way you would on a self-managed box — "managed" doesn't mean "unmonitored."

Things worth your time:

  • shared_buffers. Aurora's default parameter group sets it as a formula tied to DBInstanceClassMemory, landing around 75%, far above the ~25% heuristic for self-managed. That's deliberate: there's no OS page cache doing useful double-buffering the way there is on a normal box. Don't "fix" it down to 25% out of habit.
  • work_mem, per-connection and per-node, same math as always.
  • max_connections plus a real pooler.
  • Autovacuum: autovacuum_vacuum_cost_limit, autovacuum_max_workers, per-table thresholds on your hot tables.
  • statement_timeout and idle_in_transaction_session_timeout. The second one prevents more incidents than any storage feature.

Backups and PITR

Aurora takes continuous automatic backups of the cluster volume and supports point-in-time restore within the retention window. There's no pg_basebackup, no archive_command, no pgBackRest against the cluster volume.

The catch: restore always creates a new DB cluster. There is no restore-in-place. Your RTO includes provisioning a cluster and cutting over connections, which is not thirty seconds.

Also, PITR doesn't help with the 3 a.m. DELETE that gets discovered on Tuesday if your retention is short, and it's clumsy for single-table recovery. Keep taking logical dumps of the tables you actually care about, and restore one occasionally. I once inherited a cluster where "managed backups" had been the answer for three years and nobody had ever performed a restore. The restore worked. The application's connection string, secret rotation, and parameter group did not.

Migration Gotchas

  • No superuser. The master user gets rds_superuser.
  • Extensions limited to the AWS allowlist, governed by shared_preload_libraries and the rds.allowed_extensions style parameters.
  • No filesystem access: COPY FROM PROGRAM and arbitrary pg_read_file paths are out.
  • Logical replication and pglogical are available with caveats. Physical standbys outside the cluster aren't possible; use logical replication or DMS for cross-system replication.
  • Local temp storage is limited on smaller instance classes. Large sorts and hash joins will hit it.
  • Storage I/O is billed per request on the standard configuration. A plan regression that flips index lookups to sequential scans costs money as well as latency. Aurora I/O-Optimized removes per-request I/O charges in exchange for higher instance and storage rates; run the arithmetic before assuming either is cheaper.

So Is It Real Postgres?

At the SQL, planner, and MVCC layer, yes. It's the actual PostgreSQL code doing query processing, and your EXPLAIN (ANALYZE, BUFFERS) habits transfer intact. At the durability layer, no — and pretending otherwise is how you burn a week on checkpoint_completion_target.

The trade is worth it when you want fast reader provisioning, storage that grows on its own, and failover you don't have to build. It's a poor trade when you have a 200 GB database with modest write volume, a working Patroni setup, and someone competent watching it. That workload does fine on a well-tuned RDS instance or your own hardware for a fraction of the bill.

If it's useful, MyDBA's free health check (https://mydba.dev/?utm_source=devto&utm_medium=platform&utm_campaign=aurora-postgresql-storage-model) reads both topologies and won't hand you checkpoint advice on a cluster where checkpoints don't do anything.


Tags: postgres aws database devops ## Bringing It Back to the Storage Model

None of this is an argument for or against Aurora — it's an argument for tuning the layer that's actually yours to tune. The engine-level knobs (vacuum, work_mem, connection limits, timeouts) do exactly what they've always done, because Aurora didn't touch the engine. The storage-level knobs (checkpoint tuning, full_page_writes, replica lag interpretation) belong to a layer AWS manages for you now, and pretending otherwise just burns engineering time on parameters that can't move the needle. Know which half of your playbook survived the migration, watch the metrics that actually reflect Aurora's architecture, and don't let a familiar SHOW command convince you the storage underneath is familiar too.

pgdba Editorial builds MyDBA, a Postgres monitoring and health-check tool — https://mydba.dev/?utm_source=devto&utm_medium=platform&utm_campaign=aurora-postgresql-storage-model

If you're not sure which of your alerts and dashboards still mean anything on Aurora, run them past MyDBA — it's free and it won't waste your time on advice for a layer you don't control.

Top comments (0)