Skip to content
BytePatterns

DynamoDB vs RDS vs Aurora: Which Database on AWS

10 min readBytePatterns

DynamoDB vs RDS vs Aurora: hashed partitions, one instance with a standby and read replicas, or one shared volume under up to 15 replicas, plus a toy model.

DynamoDB vs RDS vs Aurora in one line: DynamoDB is a key-value store that spreads items over partitions by hashing a key, RDS runs a managed relational engine on one instance with a standby and read replicas, and Aurora is a MySQL- and PostgreSQL-compatible engine whose instances share one replicated storage volume. The useful question is not "SQL or NoSQL" but what the unit of scale is in each, because that decides which queries stay cheap as the data grows. Everything below comes from the AWS documentation listed at the end, as of October 2026.

ServiceUnit of scaleCheap queries
DynamoDBpartitions, chosen by hashing the partition keythe access patterns you designed keys for
RDSone writer instance, plus read replicas with their own copiesany SQL, up to one instance's size
Auroraone writer and up to 15 replicas on a shared cluster volumeany SQL, with faster failover and read scaling

The problem it solves

An orders service needs two kinds of reads. The app asks "show order o#01234 for this customer" thousands of times a second. The finance team asks "revenue per city last month" once a day, and next quarter it will ask something nobody has thought of yet.

The first question suits a key-value store, the second suits SQL. The choice is which question you will make expensive, and how each service grows past one machine. The broader trade-off is in SQL vs NoSQL; this article is about the three AWS services specifically.

The intuition

DynamoDB scales by partitions. The partition key goes through an internal hash function whose output picks the partition, and an optional sort key orders items under one partition key. A GetItem or Query names the key, so DynamoDB goes straight to where those items live. There is no JOIN operator, and the documentation recommends denormalizing instead; a question the keys do not cover becomes a Scan, which reads every item. AWS describes its performance as single-digit milliseconds at any scale, but each partition has its own throughput ceiling, so one hot key can throttle while the table has room; that failure is the subject of DynamoDB hot partitions. Capacity is on-demand or provisioned, and global tables replicate a table across Regions.

RDS scales by instance. It runs MySQL, PostgreSQL, MariaDB, Oracle, SQL Server or Db2 on a DB instance. More write capacity means a bigger instance class. A Multi-AZ DB instance deployment keeps a synchronous standby in another zone for failover; that standby serves no reads. Read replicas are updated asynchronously and do serve reads, each with its own copy of the data. Storage belongs to the instance, with a maximum that depends on the engine.

Aurora separates compute from storage. Its data lives in a cluster volume with six copies across three Availability Zones, and the writer and up to 15 Aurora Replicas all read that same volume. Adding a replica copies no table data, replica lag is usually well under 100 milliseconds, and promoting a replica on failover is much faster than recreating the primary. The volume grows on its own, up to 256 TiB on specific engine versions. Aurora Serverless v2 scales each instance in Aurora capacity units, each about 2 GiB of memory with matching CPU and networking, in steps as small as 0.5 ACU.

Watch it run

The lesson animation hashes order writes onto three DynamoDB partitions. Distinct customer keys spread evenly; a sort key keeps one customer's orders together, so "this customer's orders, newest first" is one query. Then every write of a flash sale uses one key, one partition throttles while the others idle, and key suffixes spread the load at the cost of one query per suffix. Everything is cheap exactly when it follows the key.

RDS vs DynamoDB

Step 1 of 10

Every DynamoDB item has a partition key. Three customers are about to write orders.

The same interactive animation as the lesson — step through it with the controls.

The code

The finance question in SQL. On RDS or Aurora it runs as written, with or without an index:

SELECT c.city, SUM(o.total) AS revenue
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.month = '2026-09'
GROUP BY c.city
ORDER BY c.city;

Next, a toy model, not DynamoDB itself, built on the lesson's partition picture: 100 customers and 2,000 orders in a key-value layout hashed onto four partitions, with each customer's profile and orders under one partition key. Count the items each question reads:

import random
import sqlite3
import zlib

random.seed(6)
CITIES = ["Austin", "Berlin", "Lagos", "Lima", "Osaka"]
customers = {"c#%d" % i: CITIES[i % 5] for i in range(100)}
orders = [{"customer_id": "c#%d" % random.randrange(100), "order_id": "o#%05d" % n,
           "month": random.choice(["2026-08", "2026-09"]), "total": random.randint(5, 200)}
          for n in range(2000)]

# Key-value layout: the partition key is hashed to pick one of 4 partitions;
# under each partition key, items are kept by sort key.
PARTITIONS = 4
table = [{} for _ in range(PARTITIONS)]          # partition -> {pk: {sk: item}}

def partition_of(pk):
    return zlib.crc32(pk.encode()) % PARTITIONS  # toy stand-in for the internal hash

def put(pk, sk, item):
    table[partition_of(pk)].setdefault(pk, {})[sk] = item

for cid, city in customers.items():
    put(cid, "PROFILE", {"city": city})
for o in orders:
    put(o["customer_id"], o["order_id"], o)

def get_order(customer, order_id):
    """GetItem: hash the key, go to one partition, read one item."""
    return table[partition_of(customer)][customer].get(order_id), 1

def customer_orders(customer):
    """Query: one partition key, every item under it, in sort-key order."""
    items = table[partition_of(customer)][customer]
    return [items[sk] for sk in sorted(items) if sk != "PROFILE"], len(items)

def revenue_per_city_scan(month):
    """No key covers 'city' or 'month', so read every item in every partition."""
    touched, city_of, sums = 0, {}, {}
    rows = [(pk, sk, it) for part in table for pk, items in part.items()
            for sk, it in items.items()]
    for pk, sk, it in rows:
        touched += 1
        if sk == "PROFILE":
            city_of[pk] = it["city"]
    for pk, sk, it in rows:
        if sk != "PROFILE" and it["month"] == month:
            sums[city_of[pk]] = sums.get(city_of[pk], 0) + it["total"]
    return dict(sorted(sums.items())), touched

some = orders[1234]
print(get_order(some["customer_id"], some["order_id"]))
mine, read = customer_orders("c#42")
print(len(mine), "orders for c#42, items read:", read)
by_city, touched = revenue_per_city_scan("2026-09")
print(by_city, "items read:", touched)
# ({'customer_id': 'c#30', 'order_id': 'o#01234', 'month': '2026-08', 'total': 53}, 1)
# 18 orders for c#42, items read: 19
# {'Austin': 18564, 'Berlin': 21206, 'Lagos': 20574, 'Lima': 21187, 'Osaka': 18738} items read: 2100

The same data in sqlite3, standing in for a relational engine. The join and aggregate work the first time, and a missing index is a one-line fix after the fact:

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE customers (customer_id TEXT PRIMARY KEY, city TEXT)")
db.execute("CREATE TABLE orders (order_id TEXT PRIMARY KEY, customer_id TEXT, month TEXT, total INT)")
db.executemany("INSERT INTO customers VALUES (?, ?)", customers.items())
db.executemany("INSERT INTO orders VALUES (:order_id, :customer_id, :month, :total)", orders)

REVENUE = """SELECT c.city, SUM(o.total) FROM orders o
             JOIN customers c ON c.customer_id = o.customer_id
             WHERE o.month = ? GROUP BY c.city ORDER BY c.city"""
print(dict(db.execute(REVENUE, ("2026-09",)).fetchall()) == by_city)
plan = lambda: [row[3] for row in db.execute("EXPLAIN QUERY PLAN " + REVENUE, ("2026-09",))]
print(plan())
db.execute("CREATE INDEX orders_month ON orders (month)")   # an afterthought, one line
print(plan())
# True
# ['SCAN o', 'SEARCH c USING INDEX sqlite_autoindex_customers_1 (customer_id=?)', 'USE TEMP B-TREE FOR GROUP BY']
# ['SEARCH o USING INDEX orders_month (month=?)', 'SEARCH c USING INDEX sqlite_autoindex_customers_1 (customer_id=?)', 'USE TEMP B-TREE FOR GROUP BY']

The key-value way to answer the finance question is to plan for it: copy the city onto each order and keep a secondary index by month. Reads drop from every item to that month's orders, but every order now costs two writes:

# The key-value answer: plan the pattern up front. Copy the city onto each
# order and keep a secondary index keyed by month (a toy global secondary index).
gsi, writes = {}, 0
for o in orders:
    item = dict(o, city=customers[o["customer_id"]])
    gsi.setdefault(item["month"], []).append(item)
    writes += 2                                       # base table + index

def revenue_per_city_gsi(month):
    items, sums = gsi.get(month, []), {}
    for it in items:
        sums[it["city"]] = sums.get(it["city"], 0) + it["total"]
    return dict(sorted(sums.items())), len(items)

fast, read = revenue_per_city_gsi("2026-09")
print(fast == by_city, "items read:", read, "writes:", writes)
# True items read: 981 writes: 4000

The complexity

Using n for the total items and k for the items under one key:

  • DynamoDB: GetItem reads 1 item and Query reads about k, at any table size. An unplanned question is a Scan over all n. Each new access pattern costs an index and extra writes.
  • RDS: any query the planner can serve from an index is cheap, and ad-hoc ones still work. Write throughput and storage are bounded by one instance; past that you shard yourself, as in database sharding.
  • Aurora: the same SQL model, with reads spread over replicas on one shared volume. Writes still go to one writer.

Where it goes wrong

  • Designing DynamoDB tables like SQL tables. One table per entity and joins in application code turn every page into several round trips. Start from the access patterns.
  • A low-cardinality partition key. Status or date as the key sends most traffic to a few partitions.
  • Reading from the RDS Multi-AZ standby. In a Multi-AZ DB instance deployment it serves no reads. Use read replicas, and expect replication lag on them.
  • Expecting Aurora to scale writes. Replicas scale reads; one writer takes every write.

When it shows up in interviews

Almost every design question reaches "which database?". Strong answers name the access patterns first, then the service: DynamoDB for key lookups with known patterns at large scale, RDS or Aurora when joins, transactions across entities or ad-hoc reporting matter. The follow-up is usually about failover and lag, covered in database replication, or about a hot key. The RDS vs DynamoDB lesson walks the partition model step by step.

How to say it in an interview

"I start from the queries. If the hot path is lookups by a known key at high scale, DynamoDB: the partition key is hashed to a partition, so a GetItem or Query goes straight to that key's items whatever the table size, but every new access pattern needs an index, and anything else is a scan. If I need joins, transactions or ad-hoc reporting, a relational engine. RDS gives me a synchronous Multi-AZ standby and asynchronous read replicas. Aurora shares one six-copy volume across three zones under up to 15 replicas, so failover and read scaling are faster. In both, writes go to one writer."

Frequently asked questions

What is the difference between DynamoDB and RDS?

DynamoDB is a key-value store: items are spread over partitions by hashing the partition key, and queries must name the key or scan the table. RDS runs a relational engine such as PostgreSQL or MySQL on one instance, supports joins and ad-hoc SQL, and scales reads with replicas and writes with a bigger instance.

When should I use Aurora instead of RDS?

Choose Aurora when you want MySQL or PostgreSQL compatibility with storage that is shared, six-way replicated across three zones and grows automatically, up to 15 low-lag replicas, and faster failover. Plain RDS also offers other engines, such as Oracle, SQL Server and MariaDB, and a simpler single-instance model.

Is DynamoDB faster than Aurora?

For a lookup by key, DynamoDB goes straight to the item in single-digit milliseconds whatever the table size. Aurora can be just as quick on an indexed lookup, and it can answer queries DynamoDB would need a scan for. Speed depends on the query matching the data layout, not only the service.

Sources