Skip to content

When is RDS PostgreSQL better than Aurora?

Last updated

View as Markdown

Quick answer: RDS is better when your database fits inside the 64 TiB that the AWS storage documentation, read on 2026-09-19, gives for most DB instance classes, a minute of failover is acceptable, and you need something on Aurora’s incompatibility list. Aurora is worth it for storage growth you cannot predict, failover in seconds, and readers that must be close to the writer. The deciding factor is rarely raw speed, because the thing that makes a query slow is usually the query.

Aurora is often presented as “RDS but better”, and the correction usually offered is that Aurora is the same PostgreSQL on different storage. Both claims are misleading in ways that cost money.

Aurora PostgreSQL is PostgreSQL-compatible. It uses the PostgreSQL planner and executor, so indexing, statistics and query design carry over unchanged. It also has its own supported-extension matrix, release calendar, parameter set, metrics and billing model. The storage layer explains much of the difference. It does not explain all of it, and “it is just the storage” is the assumption that leads teams to discover an unsupported extension during a migration.

The other half of the framing that needs fixing is the RDS side. “RDS for PostgreSQL” is not one architecture. It is at least three, and they differ in the things this comparison covers.

Five things you are actually choosing between

Section titled “Five things you are actually choosing between”
deploymentwriterreadable replicasreplication
RDS Single-AZone instanceexternal read replicas onlyasynchronous physical
RDS Multi-AZ DB instanceone instance, one standbynone, the standby does not serve readssynchronous to the standby
RDS Multi-AZ DB clusterone writertwo readers, across three AZssemisynchronous, native
Aurora provisionedone writerup to 15 Aurora Replicasshared cluster volume
Aurora serverlessone writerup to 15shared cluster volume

The row most often missing from comparisons is the Multi-AZ DB cluster, which is the one that makes “only Aurora gives you readable high-availability replicas” false. Read on 2026-09-19, the Multi-AZ DB cluster documentation says: “A Multi-AZ DB cluster has a writer DB instance and two reader DB instances in three separate Availability Zones in the same AWS Region.”

On the replication model, the same Multi-AZ DB cluster documentation, read on 2026-09-19, says: “Multi-AZ DB cluster deployments use semisynchronous replication, which requires acknowledgment from at least one reader DB instance in order for a change to be committed. It doesn’t require acknowledgment that events have been fully executed and committed on all replicas.”

So a commit waits for one reader to acknowledge receipt. That is not a guarantee that either reader has applied it, which matters for the consistency question below.

What Aurora gives you, from AWS documentation

Section titled “What Aurora gives you, from AWS documentation”

Every row below was read from AWS documentation on 2026-09-19 and is not measured here. Each row links the page it came from. These are service limits subject to engine version and Region, not properties of PostgreSQL.

RDS for PostgreSQLAurora PostgreSQL
maximum storage64 TiB, most instance classes256 TiB on PG 17.5+, 16.9+, 15.13+; 128 TiB below
storage growthyou resize, or you enable autoscalinggrows with the data, automatically
readable replicas2 on a Multi-AZ DB cluster, plus external read replicasup to 15 Aurora Replicas
replica lagvaries; flow control can bound it on a Multi-AZ DB cluster“usually considerably less than 100 milliseconds” (performance and scaling)
failoverdepends on deployment type“typically restored in less than 60 seconds, and often less than 30 seconds” (high availability)
serverlessno equivalentAurora serverless, minimum capacity 0 ACUs on PG 16.3, 15.7, 14.12, 13.15 and higher

Maximum storage. Read on 2026-09-19, the Amazon RDS DB instance storage page lists “PostgreSQL 64 TiB” as the maximum most DB instance classes scale to, with smaller ceilings on burstable classes: 32 TiB on db.t4g.medium and db.t3.medium, 16 TiB on db.t4g.small, 6 TiB on db.t4g.micro. The Aurora quotas page gives the Aurora ceiling by version: “256 TiB maximum: Aurora PostgreSQL versions: 17.5 and all higher versions, 16.9 and higher, 15.13 and higher”, and “128 TiB maximum: All earlier Aurora PostgreSQL versions”.

Storage growth. Read on 2026-09-19, the Aurora performance and scaling page says “Aurora storage automatically scales with the data in your cluster volume”, and that on all supported Aurora PostgreSQL versions the volume also shrinks when you drop data. This is the difference people mean when they say “Aurora is elastic”, and it remains an operational difference below 64 TiB.

Readable replicas. Read on 2026-09-19, the Aurora high availability page says “you can create up to 15 read-only Aurora Replicas”.

The sentence right after it on that same Aurora high availability page, read on 2026-09-19, says: “Aurora Replicas use asynchronous replication to support high availability without affecting primary instance performance.” Asynchronous, in AWS’s own words.

Replica lag. Read on 2026-09-19, the Aurora performance and scaling page says each replica “returns the same data from the cluster volume with minimal replica lag, usually considerably less than 100 milliseconds after the primary instance has written an update”. “Usually considerably less” is not a bound. For RDS Multi-AZ DB clusters the Multi-AZ page describes flow control, which is covered below.

Failover. Read on 2026-09-19, the Aurora high availability page says: “A failure event results in a brief interruption, during which read and write operations fail with an exception. However, service is typically restored in less than 60 seconds, and often less than 30 seconds.” Two things in that sentence get dropped when it is quoted: read and write operations fail with an exception, and the number is database promotion, not application recovery.

If the cluster has no Aurora Replica, the same page, read on 2026-09-19, says a new primary is created instead, which “typically takes less than 10 minutes”.

Serverless. Read on 2026-09-19, the auto-pause page gives the eligibility for scaling to zero: “If you’re using Aurora PostgreSQL, the database engine must be running at least version 16.3, 15.7, 14.12, or 13.15”, the instance class must be db.serverless, and minimum capacity must be set to 0 ACUs. The idle interval before pausing is configurable, minimum 300 seconds, default 300 seconds, maximum 86,400. Resuming is not free: “the typical time to resume might be approximately 15 seconds”, and after more than 24 hours paused, “the resume time can be 30 seconds or longer”.

Aurora does not give you read-after-write consistency

Section titled “Aurora does not give you read-after-write consistency”

This is the most common way the low-lag number gets misused. An Aurora Replica has its own compute and its own page cache. It does not see the writer’s memory. Read on 2026-09-19, the Aurora PostgreSQL replication page is explicit about what the lag metric measures: “The ReplicaLag metric for an Aurora Replica indicates the lag for the page cache of the Aurora Replica compared to that of the writer DB instance.”

AWS publishes a lag metric because readers lag. The routing rule is therefore a rule, not an inference from a typical number:

  • Reads that must see the caller’s own write go to the writer endpoint.
  • Reads that tolerate bounded staleness go to the reader endpoint.
  • If you need a cross-request guarantee, implement it in the application’s transaction routing. Do not derive it from the performance page’s “usually considerably less than 100 milliseconds”, read on 2026-09-19.

The same applies to an RDS Multi-AZ DB cluster. Semisynchronous means one reader acknowledged the change, not that the reader you are about to query has applied it.

Both services run the PostgreSQL planner. The most common reason a query is slow is the query, and no storage layer fixes that.

Measured on 2026-09-19 against PostgreSQL 17.11 in a stock postgres:17.11 container on an 8 vCPU Linux 6.17.0-1022-azure host under Docker 29.1.3, default settings including shared_buffers at 128MB. The table is 2,000,000 rows, 158 MB total, 115 MB heap, of which 2,000 rows match the predicate:

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM bench WHERE status = 'pending';

Without an index, three consecutive runs on 2026-09-19 on that PostgreSQL 17.11 postgres:17.11 container gave execution times of 115.459 ms, 97.066 ms and 98.775 ms. The plan:

Finalize Aggregate (actual time=90.109..97.019 rows=1 loops=1)
Buffers: shared hit=12290 read=2416
-> Gather (actual time=89.969..97.007 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (actual time=85.285..85.287 rows=1 loops=3)
-> Parallel Seq Scan on bench (actual time=0.116..85.107 rows=667 loops=3)
Filter: (status = 'pending'::text)
Rows Removed by Filter: 666000
Execution Time: 97.066 ms

The usual shorthand gets this plan wrong: “no usable index” does not mean a sequential scan. Here it is a parallel sequential scan with two launched workers. A predicate with no usable access path can also come out as a bitmap heap scan, a partition-pruned scan or a hash join feeding an aggregate, depending on selectivity, table size, the visibility map and work_mem. The planner picks the cheapest estimated plan from more options than “the index or a seq scan”.

Adding one index, which on 2026-09-19 on that PostgreSQL 17.11 postgres:17.11 container took 1.446 seconds and occupies 13 MB:

CREATE INDEX bench_status_idx ON bench (status);

Three runs then gave 0.316 ms, 0.269 ms and 0.374 ms, same container, same 2026-09-19, same PostgreSQL 17.11:

Aggregate (actual time=0.255..0.255 rows=1 loops=1)
Buffers: shared hit=6
-> Index Only Scan using bench_status_idx on bench (actual time=0.016..0.156 rows=2000 loops=1)
Index Cond: (status = 'pending'::text)
Heap Fetches: 0
Execution Time: 0.269 ms

Execution time fell from 97.066 ms to 0.269 ms, and the buffers touched fell from 12,290 hits and 2,416 reads to 6, on 2026-09-19 on PostgreSQL 17.11 in that postgres:17.11 container. Two caveats apply to those numbers:

It demonstrates the value of a fitting index for that query on that table. It is not a ceiling on what infrastructure can be worth. A workload bounded by memory, by CPU parallelism, by read latency or by recovery time can gain very large factors from a different instance class or a different storage layer. The claim the measurement supports is narrower and still useful: before you compare engines, find out whether your slow query has a plan problem, because that is free to fix and neither engine fixes it for you.

Cache hit rate does not tell you whether storage is your problem

Section titled “Cache hit rate does not tell you whether storage is your problem”

This diagnostic is the one most likely to send you to the wrong answer. The counter-example below was measured on 2026-09-19 on the same PostgreSQL 17.11 postgres:17.11 container described above.

Three thousand single-row inserts, each its own transaction, run twice against the same table with nothing else changed but synchronous_commit. Measured on 2026-09-19 against PostgreSQL 17.11 in that same stock postgres:17.11 container, Linux 6.17.0-1022-azure under Docker 29.1.3:

synchronous_commitwall clock for 3,000 commitsheap blocks readheap blocks hithit rate
on13.143 s03,030100.00%
off0.284 s03,030100.00%

Commit throughput differed by a factor of 46, entirely in the storage layer, with a perfect cache hit rate on both runs. The rate query many articles recommend is:

SELECT sum(heap_blks_read) AS heap_read,
sum(heap_blks_hit) AS heap_hit,
round(100.0 * sum(heap_blks_hit)
/ NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0), 2) AS hit_pct
FROM pg_statio_user_tables;

It reported 100.00 in both rows of that table. It is a table-access aggregate, cumulative since the last stats reset, and it does not see WAL flushes, checkpoint writes, temp file spills, index reads counted elsewhere, or anything outside user table access. A high number means table reads were served from memory during the window. It does not mean the storage layer is not what is holding you up.

Use it as context. Diagnose with per-query buffers from EXPLAIN (ANALYZE, BUFFERS), with wait events, and with read and write latency and queue depth from CloudWatch or Performance Insights.

The sequential scan counters are still worth checking, for the different question of whether a plan is the problem:

SELECT relname,
seq_scan,
seq_tup_read,
idx_scan,
seq_tup_read / NULLIF(seq_scan, 0) AS rows_per_seq_scan
FROM pg_stat_user_tables
WHERE seq_scan > 0
ORDER BY seq_tup_read DESC
LIMIT 20;

An RDS read replica is a PostgreSQL streaming standby that replays WAL. An Aurora Replica attaches to the same cluster volume as the writer, with its own compute and its own page cache. That difference is real, but it does not remove query cancellation.

The cancellation happens on both, for different reasons

Section titled “The cancellation happens on both, for different reasons”

Measured on 2026-09-19 on a streaming standby of that PostgreSQL 17.11 primary, another stock postgres:17.11 container on the same Linux 6.17.0-1022-azure host under Docker 29.1.3, with max_standby_streaming_delay at 5s (the PostgreSQL default is 30s) and hot_standby_feedback off. A REPEATABLE READ transaction on the standby held a snapshot across a pg_sleep(25) while the primary deleted and vacuumed the rows it had read:

BEGIN
count
--------
200000
ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
ERROR: current transaction is aborted, commands ignored until end of transaction block
ROLLBACK

hot_standby_feedback = on prevents it, at the cost of holding back cleanup on the primary.

Now the part usually reported as an Aurora advantage. Read on 2026-09-19, the Aurora PostgreSQL replication page documents the same message: “When a read-only query is canceled due to a recovery conflict, you might see one or more of the following error messages in the database error log: Canceling statement due to conflict with recovery.”

The same Aurora PostgreSQL replication page, read on 2026-09-19, lists the conditions under which an Aurora Replica restarts: when it cannot stream data from the writer during replication recovery, when a second recovery starts while one is in progress, “when your DB instance is nearing the transaction ID wraparound”, and “when the replication process is blocked under certain circumstances”.

So it is wrong to say neither behaviour exists on Aurora. What Aurora lacks is the specific PostgreSQL standby mechanism: a replica replaying WAL that removes row versions an open snapshot still needs. Your application still has to retry a cancelled read on either service.

Lag is shaped by settings, not only by write volume

Section titled “Lag is shaped by settings, not only by write volume”

A bulk insert raises replica lag, which makes “lag follows your write volume” an easy conclusion and a wrong one. The test below produces lag from a setting, with essentially no write volume at all.

Measured on 2026-09-19 on that same PostgreSQL 17.11 primary and standby pair, both stock postgres:17.11 containers. recovery_min_apply_delay was set to 30s on the standby, then a single row was inserted on an otherwise idle primary:

time since insertrow visible on standbypg_last_xact_replay_timestamp() lag
3 sno12 s
10 sno19 s
20 sno29 s
33 syes33 s
40 syes40 s

A parameter produced thirty seconds of lag from one row of write volume. Lag is shaped by WAL generation, network throughput, replay speed, replica sizing, conflicts, long-running transactions and configuration, and several of those are settings you choose.

On the managed services the same is true in both directions. Read on 2026-09-19, the Multi-AZ DB cluster documentation describes flow control for RDS for PostgreSQL Multi-AZ DB clusters: a background worker on the writer “adds a delay at the end of a transaction” when lag exceeds a threshold, and “to control the lag threshold, use the parameter flow_control.target_standby_apply_lag”. That is an RDS setting that trades write throughput for bounded replica lag, and it is missing from most comparisons.

Compatibility is often the actual deciding question

Section titled “Compatibility is often the actual deciding question”

For a lot of teams this decides it before size or lag are discussed, and it is the section most comparisons leave out. Aurora PostgreSQL publishes its own extension support matrix and its own release calendar, separate from the RDS for PostgreSQL release calendar.

Make the checklist before comparing anything else:

  • The exact PostgreSQL major and minor version you need, and the upgrade path.
  • Every extension you use, with its version, checked against Aurora’s matrix.
  • Parameter group settings you rely on, and which of them are static.
  • Logical replication and change-data-capture tooling, including slots.
  • Anything that depends on superuser, the file system, or a background worker.
  • Backup, point-in-time recovery, cross-Region disaster recovery, migration.
  • The observability integrations your existing runbooks already reference.

One caveat on the way this is usually framed. RDS for PostgreSQL is closer to upstream PostgreSQL than Aurora in storage and replication architecture, so more of the PostgreSQL documentation applies to it directly. It is not upstream PostgreSQL. There is no superuser, no host access, no arbitrary extension install and no unrestricted background worker on either service.

The framing “RDS is predictable, Aurora is not” is too simple in both halves.

On the Aurora side, Standard charges per I/O request and I/O-Optimized does not. AWS’s guidance, read on 2026-09-19, from its own article on estimating the saving, is that “cost savings provided by Aurora I/O-Optimized can be significant when I/O spending accounts for 25% or more of total Aurora database spending”. The same article estimates that in advance from CloudWatch: “You can use CloudWatch metrics to get storage and I/O usage details for your Aurora cluster and generate cost estimates”, using VolumeReadIOPs, VolumeWriteIOPs and VolumeBytesUsed. It can therefore be modelled before you move, then checked against the first real bill.

On the RDS side, the bill is not one number either. Storage type and size, provisioned IOPS and throughput on gp3, the Multi-AZ topology, external read replicas, backup retention beyond the free allowance, cross-AZ data transfer, reservations and savings plans, and Region all move it.

The gp3 threshold is the one that surprises people. Read on 2026-09-19, the Amazon RDS DB instance storage page says: “On gp3 storage volumes, Amazon RDS provides a baseline storage performance of 3000 IOPS and 125 MiB/s.” It then says the baseline rises at a size threshold: “when the storage size for gp3 volumes reaches a certain threshold, the baseline storage performance increases. This is because of volume striping, where the storage uses four volumes instead of one.”

The same page, read on 2026-09-19, says the AWS threshold for PostgreSQL is 400 GiB, and does not vary by PostgreSQL version: 20 to 399 GiB gets a 3,000 IOPS and 125 MiB/s baseline with provisioned IOPS “Not applicable”, and 400 GiB and above gets 12,000 IOPS and 500 MiB/s with 12,000 to 64,000 IOPS provisionable. So below the threshold you cannot buy more, and crossing it is a step change in performance that arrives with a storage resize. It is the kind of thing people attribute to the engine.

Model compute, storage, I/O, high-availability readers, backup, disaster recovery and network on both sides, rather than comparing the Aurora I/O line item with an RDS instance price.

Availability is a property of your application

Section titled “Availability is a property of your application”

AWS’s failover number is database promotion. Your users experience application recovery, which is longer and which you have to measure. The requirements:

  • Connect through the writer and reader endpoints, never an instance endpoint, and understand the DNS behaviour in front of them.
  • Bound connection pool lifetime and test what your pool does with a stale connection.
  • Retry only idempotent operations. In-flight transactions will fail.
  • Test the cases separately: planned maintenance, writer loss, reader loss, AZ impairment, and a point-in-time restore.
  • Write the target down as an objective in your own words, for example that the application resumes successful writes within ninety seconds of writer loss, and test against it. That is a number you choose, not one anybody measured for you.

Your database fits. The 64 TiB that the RDS storage page gives for most PostgreSQL DB instance classes, read on 2026-09-19 and stated for every PostgreSQL version rather than per version, is a large database. Capacity headroom is not the whole storage question, though: growth behaviour, durability topology and I/O profile still differ well below the ceiling, so decide on those rather than on whether you will hit a number.

A minute of failover is acceptable, confirmed by whoever owns the application rather than assumed.

You need something Aurora does not carry. An extension, a parameter, a replication workflow, a version on a schedule that suits you.

Predictable cost matters more to you than elasticity, and you have modelled both sides rather than comparing an instance price to an I/O line.

Storage growth you cannot predict, because the volume grows and shrinks without a resize operation.

Failover in seconds rather than a minute, when you have checked that a minute is too long.

Read scaling past what a Multi-AZ DB cluster’s two readers give you, up to 15 replicas on the same volume.

Genuinely intermittent workloads, where Aurora serverless scaling to zero is the feature. For steady traffic it is not cheaper.

Ask for “a production-ready Postgres on AWS” and the generated Terraform is Aurora, with a writer and two readers, because that is the shape of the reference architecture. It is a reasonable default and it is not a decision. The RDS Multi-AZ DB cluster almost never appears, which is why so many teams believe readable high-availability replicas are an Aurora-only feature. The cost difference shows up a month later, attached to a workload that never needed the storage layer it is paying for.

  1. Build the compatibility checklist first. If something on it is missing from Aurora’s matrix, the rest of the comparison is academic.
  2. Find the dominant bottleneck per workload class. Capture plans and buffers for the slow queries, then wait events, then latency and queue depth. Do not stop at a cache hit rate.
  3. Get the failover objective from the application owner, as a number, for application recovery rather than database promotion.
  4. Write down the staleness rule for each read path: writer endpoint, reader endpoint, or application-level routing.
  5. Model the full cost on both sides, including the I/O estimate from CloudWatch if Aurora is in the running.
  • Is Aurora always faster than RDS?

    No, and the comparison is not one number. Aurora replaces the storage layer, so it is faster at what storage decides: growing past an instance volume, promoting a replica, and keeping a reader close to the writer. It does not change the query planner. Measured on 2026-09-19 against PostgreSQL 17.11 in a stock postgres:17.11 container on an 8 vCPU Linux 6.17.0 host under Docker 29.1.3, default settings including shared_buffers at 128MB: one index on a 2 million row, 158 MB table took a count from 97.066 to 115.459 ms down to 0.269 to 0.374 ms, and that index costs nothing on either service.

  • What does Aurora actually give me?

    Per AWS documentation read on 2026-09-19: a cluster volume that grows on its own to 256 TiB on Aurora PostgreSQL 17.5, 16.9 and 15.13 and higher and 128 TiB below that, against 64 TiB for an RDS for PostgreSQL instance; up to 15 Aurora Replicas that read the same cluster volume; a failure event that AWS says is typically restored in less than 60 seconds and often less than 30; and Aurora serverless, which RDS for PostgreSQL has no equivalent of. It also has its own extension list, its own release calendar and its own billing model, so it is not RDS plus storage.

  • When is RDS the better choice?

    When your database fits inside the 64 TiB that the RDS storage documentation, read on 2026-09-19, gives as the ceiling for most DB instance classes; when a failover of a minute is acceptable to whoever owns the application; and when you need an extension, a parameter or an operational workflow that Aurora's own compatibility matrix does not carry. RDS for PostgreSQL is closer to upstream PostgreSQL in storage and replication architecture, which makes its behaviour easier to predict from the PostgreSQL documentation. It is not upstream PostgreSQL: there is no superuser, no host access and no arbitrary extension install on either service.

  • Do RDS read replicas behave differently from Aurora replicas?

    Yes, but not in the way the difference is usually stated. An RDS read replica is a PostgreSQL streaming standby that replays WAL and can cancel your query. Measured on 2026-09-19 against PostgreSQL 17.11 in a stock postgres:17.11 container on Linux 6.17.0 under Docker 29.1.3, with max_standby_streaming_delay at 5s (the PostgreSQL default is 30s), a standby query was killed with 'canceling statement due to conflict with recovery'. AWS documents the same error text on Aurora Replicas, so the cancellation is not something Aurora removes. What Aurora removes is the WAL replay mechanism behind it.

  • Should I use Aurora I/O-Optimized?

    AWS's guidance, read on 2026-09-19, is that the saving is significant when I/O spending is 25 percent or more of total Aurora spending. That is not a number you can only read off a bill you already have: the same AWS article estimates it in advance from the CloudWatch metrics VolumeReadIOPs, VolumeWriteIOPs and VolumeBytesUsed. Model it before you move, then check it against the first bill.