Blog

DynamoDB vs RDS: Choosing Between NoSQL and Relational Databases on AWS

October 20, 2026 · Pushpjeet Cholkar

Last updated:

Short answer: Use Amazon DynamoDB when you know your access patterns up front and need consistent single-digit millisecond reads and writes at any scale, with no servers or maintenance windows. Use Amazon RDS when your data is relational, you need joins and ad hoc SQL, or your application already runs on MySQL, PostgreSQL, MariaDB, Oracle, SQL Server or Db2.

DynamoDB vs RDS at a glance

Amazon DynamoDB Amazon RDS
Database type NoSQL: key-value and document Relational (SQL)
Engines One service; no engine versions Db2, MariaDB, Microsoft SQL Server, MySQL, Oracle, PostgreSQL (Aurora is the MySQL- and PostgreSQL-compatible engine in the same family)
What you manage Tables, keys, indexes, capacity mode; no servers, versions or maintenance windows DB instance class, storage, engine version upgrades, maintenance windows, parameter groups, query tuning
Query model GetItem, Query (one partition key), Scan; PartiQL as a SQL-compatible syntax Full SQL: joins, subqueries, aggregates, ad hoc queries
Joins Not supported; denormalize instead Yes
Schema Only the primary key is fixed; items can have different attributes Defined tables, columns, constraints, foreign keys
Keys Partition key (up to 2,048 bytes), optional sort key (up to 1,024 bytes) Primary keys, unique keys, foreign keys
Secondary indexes Global secondary indexes (default quota 20 per table, add any time); local secondary indexes (up to 5, only at table creation) Engine indexes on any columns, added with CREATE INDEX
Transactions ACID; up to 100 unique items and 4 MB per transaction, single Region and account The engine’s ACID transactions (BEGIN / COMMIT / ROLLBACK)
Read consistency Eventually consistent by default; strongly consistent on request for tables and LSIs; GSIs and streams eventually consistent only Primary returns committed data; read replicas replicate asynchronously and can lag
Item / storage size 400 KB per item; no practical limit on table size Up to 64 TiB per instance for Db2, MariaDB, MySQL, PostgreSQL; Oracle and SQL Server up to 256 TiB with additional storage volumes
Scaling Adds partitions automatically; each partition serves up to 3,000 read units and 1,000 write units per second Vertical (change instance class), storage autoscaling, read replicas (up to 15 for MySQL, MariaDB and PostgreSQL)
High availability Data replicated across three Availability Zones by default; global tables for multi-Region, multi-active Multi-AZ DB instance (one standby, not readable) or Multi-AZ DB cluster (two readable standbys, MySQL and PostgreSQL)
Backups Point-in-time recovery to any second in the last 1-35 days; on-demand backups Automated backups kept 1-35 days with point-in-time restore; manual snapshots
Pricing model On-demand (per request) or provisioned capacity, plus storage and optional features Instance hours, storage, provisioned IOPS, backup storage, data transfer
Typical uses Carts, sessions, user profiles, game state, IoT and event data, serverless back ends ERP, CRM, finance and reporting systems, existing SQL applications, complex queries

Limits and defaults from AWS documentation, checked October 20, 2026. Quotas change, so confirm them in the linked docs before you design around one.

What is Amazon DynamoDB?

Amazon DynamoDB is a serverless, fully managed, distributed NoSQL database. AWS describes it as delivering single-digit millisecond performance at any scale, and it supports both key-value and document data models.

“Serverless” is literal here. You don’t choose instance sizes, patch software or plan upgrades: DynamoDB has no major, minor or patch versions and no maintenance windows. You create a table, define its primary key and choose a capacity mode. Access is controlled through IAM, so there are no database user names or passwords to rotate. Data is encrypted at rest by default and replicated across three Availability Zones in the Region.

Facts worth remembering:

What is Amazon RDS?

Amazon Relational Database Service (RDS) runs a relational database engine for you. You pick an engine (IBM Db2, MariaDB, Microsoft SQL Server, MySQL, Oracle Database or PostgreSQL), a DB instance class and storage, and RDS handles provisioning, patching, backups, failure detection and repair. Your SQL, drivers and tools work largely as they would against the same engine elsewhere.

The shared responsibility line is worth knowing. AWS runs the infrastructure and database software; you own schema design and query tuning. A missing index or a bad execution plan is still yours to fix.

Facts worth remembering:

How do DynamoDB and RDS differ in practice?

How is data modeled: tables and joins, or items and keys?

My career started in relational databases, where the habit is to normalize first and write the queries later. DynamoDB asks for the opposite order, and that reversal is the biggest adjustment for anyone coming from SQL.

In RDS, you normalize: customers, orders, order lines and products each get a table, linked by foreign keys. You decide the questions later, because SQL can join and aggregate any combination at read time. The cost is that every join is work the database does on every query, which is why indexes and execution plans matter.

In DynamoDB, AWS’s design guidance starts with “identify the specific query patterns that the system must satisfy”. You then shape the keys so each pattern is a single, cheap request:

If you need a query your keys don’t support, your options are a new index, a Scan of the whole table, or another data store. The worked example below shows each of these.

What are GSIs and LSIs, and how do they compare with SQL indexes?

In RDS you add an index with CREATE INDEX on whatever columns a slow query needs, and the optimizer decides whether to use it. In DynamoDB you query an index explicitly by name, and there are two kinds:

Global secondary index (GSI) Local secondary index (LSI)
Key Any partition key and optional sort key Same partition key as the table, different sort key
When you create it At table creation or any time later; can be deleted Only at table creation; can’t be added or deleted later
Read consistency Eventually consistent only Eventually or strongly consistent
Size limit None per partition key value 10 GB per partition key value (table items plus index entries)
Throughput Its own capacity, separate from the table Uses the table’s capacity
Attributes you can read Only projected attributes Can fetch non-projected attributes from the table, at extra cost
How many Default quota of 20 per table Up to 5 per table

A GSI is effectively a second copy of the items, keyed differently and kept up to date by DynamoDB. That makes “get orders by status” possible, but it also means every write to the table can be a write to the index too.

Do both support transactions?

Yes. RDS gives you the engine’s own ACID transactions, with constraints and foreign keys enforced inside them.

DynamoDB has TransactWriteItems and TransactGetItems, which provide all-or-nothing changes across items and tables, with these limits:

A condition such as stock >= :qty inside the transaction gives you the same protection a CHECK constraint or a guarded UPDATE gives in SQL. Pass a ClientRequestToken so a retried call has the same effect as one call (the token is valid for 10 minutes).

How does read consistency work?

DynamoDB reads from tables and LSIs are eventually consistent unless you set ConsistentRead to true. A successful write is durably persisted, but an eventually consistent read straight after it might not include it yet. Strongly consistent reads return the latest committed data, can have higher latency, and cost twice as much: one read unit covers one strongly consistent read or two eventually consistent reads of up to 4 KB. Reads from GSIs and streams are always eventually consistent.

Across Regions, global tables default to multi-Region eventual consistency (MREC), with last-writer-wins conflict resolution. Multi-Region strong consistency (MRSC) replicates writes synchronously to at least one other Region and supports strongly consistent reads in any Region. It needs exactly three Regions (three replicas, or two replicas and a witness) and adds write latency.

RDS reads on the primary see committed data, with isolation behavior set by the engine. Read replicas use asynchronous replication, so a replica can return stale data, a common surprise in “write, then read your own write” flows. In a Multi-AZ DB instance deployment, replication to the standby is synchronous, but the standby can’t serve reads.

How does each one scale?

DynamoDB scales out. It adds physical partitions as data and traffic grow, and there is no practical limit on table size. The design constraint is per partition: each one delivers up to 3,000 read units and 1,000 write units per second. A partition key with few distinct values, such as a status code or today’s date, sends traffic to a few partitions and gets throttled. High-cardinality keys (user ID, device ID) spread it out. On-demand tables also have a default per-table quota of 40,000 read and 40,000 write request units per second, which you can raise.

RDS scales up, and out for reads only:

What are DynamoDB capacity modes?

DynamoDB has two throughput modes per table:

The units: one RCU is one strongly consistent read per second (or two eventually consistent) of up to 4 KB; one WCU is one write per second of up to 1 KB. Larger items consume more units, rounded up. You can switch a table from provisioned to on-demand up to four times in a 24-hour rolling window, and from on-demand to provisioned at any time.

RDS has no equivalent: you pay for the DB instance by the hour (billed per second, with a 10-minute minimum) whether it is busy or idle.

How do high availability, Multi-AZ and read replicas compare?

DynamoDB replicates every table across three Availability Zones with no configuration. For multiple Regions, global tables are multi-active: every replica accepts reads and writes, so there is no primary to fail over.

RDS gives you three building blocks, and they do different jobs:

Option What it is Serves reads? Purpose
Multi-AZ DB instance One synchronous standby in another zone No Automatic failover, typically 60-120 seconds
Multi-AZ DB cluster One writer and two readers in three zones, semisynchronous replication Yes Failover plus read capacity and lower write latency than a Multi-AZ DB instance; MySQL and PostgreSQL only
Read replica Asynchronous copy, same or another Region Yes Read scaling, reporting, disaster recovery by promotion

The classic exam trap: a Multi-AZ DB instance standby is not a read replica. It exists for failover and can’t take queries.

Maintenance also differs. RDS applies operating system updates on Multi-AZ by patching the standby first and failing over. A database engine version upgrade, though, takes both the primary and standby down until it completes. DynamoDB has no engine versions to upgrade.

How do backups and restores compare?

DynamoDB point-in-time recovery (PITR) takes continuous backups with per-second granularity. The recovery period is 35 days by default and configurable from 1 to 35 days. It is not retroactive: the earliest restore point is when you turned it on. Backups don’t consume table capacity or affect performance. On-demand backups keep full copies for long-term retention, and AWS Backup can schedule and copy them across accounts and Regions. A restore always creates a new table, and some settings, such as auto scaling, streams and TTL, must be reapplied to it.

RDS automated backups take a daily snapshot and capture transaction logs, kept for 1 to 35 days. You can restore to any second in that window, usually up to the last five minutes. Like DynamoDB, a restore creates a new DB instance and leaves the original alone, so applications must be pointed at the new endpoint.

How is each one priced?

This post doesn’t quote prices; they vary by Region and change. What matters for design is the model:

The pattern that follows: spiky or low, unpredictable traffic tends to favor DynamoDB on-demand, because idle time costs nothing for throughput. Steady, heavy relational workloads that keep an instance busy can favor RDS. Model both with your real request counts in the AWS Pricing Calculator, using DynamoDB pricing and RDS pricing.

Worked example: one orders schema, modeled both ways

Take a small e-commerce back end with customers, products, orders and order lines. The application has four needs:

  1. Show a customer’s orders, newest first, with totals.
  2. Show the lines of one order.
  3. List all shipped orders for the warehouse screen.
  4. Place an order atomically: save it and decrement stock, or do nothing if any item is out of stock.

Finance then asks a fifth question nobody planned for: revenue by product.

The relational model (RDS)

Four normalized tables, linked by foreign keys:

customers(customer_id PK, name)
products(product_id PK, name, price_cents, stock CHECK (stock >= 0))
orders(order_id PK, customer_id FK, order_date, status)        index on (customer_id, order_date)
order_lines(order_id FK, product_id FK, qty, price_cents)      PK (order_id, product_id)

Every question, including the unplanned one, is a SQL query with joins.

The DynamoDB model (single table)

One table with generic key names PK and SK, and one GSI (GSI1PK, GSI1SK) for the status screen:

PK SK Other attributes Serves
CUSTOMER#c1 PROFILE name Customer profile
CUSTOMER#c1 ORDER#2026-09-28#o1001 status, items, total_cents, GSI1PK = STATUS#SHIPPED, GSI1SK = 2026-09-28#o1001 Need 1 (query by customer, sorted by date); need 3 through GSI1
ORDER#o1001 LINE#p1 product_name, qty, price_cents Need 2
PRODUCT#p3 PRODUCT name, price_cents, stock Need 4 (condition on stock)

The date sits inside the sort key, so sorting by sort key sorts by date. The order total and the product name are copied into the order items at write time, which is the denormalization DynamoDB expects.

Run both models locally

The script below builds both models from the same data. The relational side uses Python’s built-in sqlite3. The DynamoDB side is a teaching simulation in plain dictionaries: items grouped by partition key and sorted by sort key, with item sizes and read/write units calculated from the rules in the DynamoDB Developer Guide. It uses only the standard library and makes no AWS calls, so it costs nothing to run.

"""Same e-commerce orders, two models: relational SQL (sqlite3) vs a DynamoDB-style
single table (plain Python dicts). Standard library only; nothing here calls AWS.

The DynamoDB part is a local teaching simulation. Item sizes and read/write units
follow the rules in the DynamoDB Developer Guide ("DynamoDB item sizes and formats",
"DynamoDB read and write operations"), so treat the numbers as close estimates.
Run: python3 dynamodb_vs_sql_orders.py
"""
import math
import sqlite3

CUSTOMERS = [("c1", "Ana Lopez"), ("c2", "Ben Okafor")]
PRODUCTS = [("p1", "USB-C cable", 1200, 40), ("p2", "Laptop stand", 4500, 5),
            ("p3", "Webcam", 6000, 2)]                      # id, name, price (cents), stock
ORDERS = [("o1001", "c1", "2026-09-28", "SHIPPED"), ("o1002", "c1", "2026-10-05", "PLACED"),
          ("o1003", "c2", "2026-10-06", "SHIPPED")]
LINES = [("o1001", "p1", 2), ("o1001", "p2", 1), ("o1002", "p3", 1),
         ("o1003", "p1", 3), ("o1003", "p3", 1)]
PRICE = {p[0]: p[2] for p in PRODUCTS}
NAME = {p[0]: p[1] for p in PRODUCTS}

# ---------------------------------------------------------------- 1. Relational model
db = sqlite3.connect(":memory:")
db.execute("PRAGMA foreign_keys = ON")
db.executescript("""
CREATE TABLE customers (customer_id TEXT PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE products  (product_id TEXT PRIMARY KEY, name TEXT NOT NULL,
                        price_cents INTEGER NOT NULL, stock INTEGER NOT NULL CHECK (stock >= 0));
CREATE TABLE orders    (order_id TEXT PRIMARY KEY,
                        customer_id TEXT NOT NULL REFERENCES customers(customer_id),
                        order_date TEXT NOT NULL, status TEXT NOT NULL);
CREATE TABLE order_lines (order_id TEXT REFERENCES orders(order_id),
                          product_id TEXT REFERENCES products(product_id),
                          qty INTEGER NOT NULL, price_cents INTEGER NOT NULL,
                          PRIMARY KEY (order_id, product_id));
CREATE INDEX orders_by_customer ON orders(customer_id, order_date);
""")
db.executemany("INSERT INTO customers VALUES (?, ?)", CUSTOMERS)
db.executemany("INSERT INTO products VALUES (?, ?, ?, ?)", PRODUCTS)
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", ORDERS)
db.executemany("INSERT INTO order_lines VALUES (?, ?, ?, ?)",
               [(o, p, q, PRICE[p]) for o, p, q in LINES])
db.commit()

print("=== Relational (sqlite3): normalized tables + JOIN at read time")
print("Ana's orders, newest first (1 SQL query, 2 JOINs):")
for row in db.execute("""
    SELECT o.order_id, o.order_date, o.status, SUM(l.qty) AS items,
           SUM(l.qty * l.price_cents) AS total_cents
    FROM customers c
    JOIN orders o      ON o.customer_id = c.customer_id
    JOIN order_lines l ON l.order_id = o.order_id
    WHERE c.name = 'Ana Lopez'
    GROUP BY o.order_id ORDER BY o.order_date DESC"""):
    print(f"  {row[0]}  {row[1]}  {row[2]:<8} items={row[3]}  total={row[4] / 100:.2f}")

print("Ad hoc question nobody planned for: revenue by product (still 1 query):")
for name, revenue in db.execute("""
    SELECT p.name, SUM(l.qty * l.price_cents) FROM order_lines l
    JOIN products p ON p.product_id = l.product_id
    GROUP BY p.name ORDER BY 2 DESC"""):
    print(f"  {name:<13} {revenue / 100:.2f}")


def place_order_sql(order_id, customer_id, day, lines):
    """One ACID transaction: insert the order, its lines, and decrement stock."""
    try:
        with db:  # commits on success, rolls back on any exception
            db.execute("INSERT INTO orders VALUES (?, ?, ?, 'PLACED')", (order_id, customer_id, day))
            for product_id, qty in lines:
                db.execute("INSERT INTO order_lines VALUES (?, ?, ?, ?)",
                           (order_id, product_id, qty, PRICE[product_id]))
                db.execute("UPDATE products SET stock = stock - ? WHERE product_id = ?",
                           (qty, product_id))
        return "committed"
    except sqlite3.IntegrityError as err:
        return f"rolled back ({err})"


print("Transaction: Ben orders 1 cable + 3 webcams (only 2 webcams in stock):")
print("  ->", place_order_sql("o1004", "c2", "2026-10-20", [("p1", 1), ("p3", 3)]))
saved = db.execute("SELECT COUNT(*) FROM orders WHERE order_id = 'o1004'").fetchone()[0]
stock = dict(db.execute("SELECT product_id, stock FROM products"))
print(f"  order saved: {bool(saved)}; cable stock still {stock['p1']}, webcam stock still {stock['p3']}")

# ---------------------------------------------------- 2. DynamoDB-style single table


def attr_size(name, value):
    """Approximate DynamoDB attribute size in bytes (documented sizing rules)."""
    return len(name.encode()) + value_size(value)


def value_size(value):
    if isinstance(value, bool) or value is None:
        return 1
    if isinstance(value, (int, float)):
        digits = len(str(abs(value)).replace(".", "").strip("0")) or 1
        return math.ceil(digits / 2) + 1
    if isinstance(value, str):
        return len(value.encode())
    if isinstance(value, dict):   # Map: 3 bytes + 1 byte per element
        return 3 + sum(attr_size(k, v) + 1 for k, v in value.items())
    if isinstance(value, list):   # List: 3 bytes + 1 byte per element
        return 3 + sum(value_size(v) + 1 for v in value)
    raise TypeError(type(value))


def item_size(item):
    return sum(attr_size(k, v) for k, v in item.items())


def read_units(total_bytes, consistent=False):
    """Query/Scan: total size rounded up to the next 4 KB; eventually consistent = half."""
    units = math.ceil(total_bytes / 4096) or 1
    return units if consistent else units / 2


class Table:
    """Items live in partitions (by PK) and are kept sorted by SK inside each one."""

    def __init__(self, pk="PK", sk="SK"):
        self.pk, self.sk, self.partitions = pk, sk, {}

    def put(self, item):
        self.partitions.setdefault(item[self.pk], {})[item[self.sk]] = dict(item)

    def query(self, pk_value, sk_prefix="", newest_first=False):
        part = self.partitions.get(pk_value, {})
        keys = sorted((k for k in part if k.startswith(sk_prefix)), reverse=newest_first)
        items = [part[k] for k in keys]
        return items, read_units(sum(item_size(i) for i in items))

    def scan(self):
        items = [i for part in self.partitions.values() for i in part.values()]
        return items, read_units(sum(item_size(i) for i in items))


table = Table()
for cid, name in CUSTOMERS:
    table.put({"PK": f"CUSTOMER#{cid}", "SK": "PROFILE", "name": name})
for pid, name, price, stock in PRODUCTS:
    table.put({"PK": f"PRODUCT#{pid}", "SK": "PRODUCT", "name": name,
               "price_cents": price, "stock": stock})
for oid, cid, day, status in ORDERS:
    lines = [(p, q) for o, p, q in LINES if o == oid]
    table.put({"PK": f"CUSTOMER#{cid}", "SK": f"ORDER#{day}#{oid}", "status": status,
               "items": sum(q for _, q in lines),
               "total_cents": sum(q * PRICE[p] for p, q in lines),   # denormalized at write time
               "GSI1PK": f"STATUS#{status}", "GSI1SK": f"{day}#{oid}"})
    for pid, qty in lines:
        table.put({"PK": f"ORDER#{oid}", "SK": f"LINE#{pid}", "product_name": NAME[pid],
                   "qty": qty, "price_cents": PRICE[pid]})

# A global secondary index is another copy of the items, keyed differently.
gsi1 = Table("GSI1PK", "GSI1SK")
for part in table.partitions.values():
    for item in part.values():
        if "GSI1PK" in item:
            gsi1.put(item)

print("\n=== DynamoDB-style single table: keys designed for each access pattern")
print(f"Table holds {sum(len(p) for p in table.partitions.values())} items "
      f"in {len(table.partitions)} partitions (PK values)")
items, ru = table.query("CUSTOMER#c1", "ORDER#", newest_first=True)
print(f"AP1 Ana's orders, newest first: Query PK=CUSTOMER#c1, SK begins_with ORDER# "
      f"-> {len(items)} items, {ru} read units")
for i in items:
    print(f"  {i['SK']:<26} {i['status']:<8} items={i['items']}  total={i['total_cents'] / 100:.2f}")
items, ru = table.query("ORDER#o1001", "LINE#")
print(f"AP2 Lines of o1001: Query PK=ORDER#o1001 -> {len(items)} items, {ru} read units")
items, ru = gsi1.query("STATUS#SHIPPED", newest_first=True)
print(f"AP3 Shipped orders: Query GSI1 PK=STATUS#SHIPPED -> {[i['GSI1SK'] for i in items]}, "
      f"{ru} read units (GSI reads are eventually consistent)")

items, ru = table.scan()
revenue = {}
for i in items:
    if i["SK"].startswith("LINE#"):
        revenue[i["product_name"]] = revenue.get(i["product_name"], 0) + i["qty"] * i["price_cents"]
print(f"Ad hoc revenue by product: no key fits, so Scan the whole table -> "
      f"read {len(items)} items, {ru} read units, then aggregate in code:")
for name, cents in sorted(revenue.items(), key=lambda kv: -kv[1]):
    print(f"  {name:<13} {cents / 100:.2f}")


def place_order_dynamo(order_id, customer_id, day, lines):
    """TransactWriteItems-style: check every condition first; write all or nothing."""
    for pid, qty in lines:   # ConditionExpression: stock >= :qty on each product item
        if table.partitions[f"PRODUCT#{pid}"]["PRODUCT"]["stock"] < qty:
            return f"TransactionCanceled (ConditionalCheckFailed on PRODUCT#{pid}); nothing written"
    writes = [{"PK": f"CUSTOMER#{customer_id}", "SK": f"ORDER#{day}#{order_id}", "status": "PLACED",
               "items": sum(q for _, q in lines), "total_cents": sum(q * PRICE[p] for p, q in lines),
               "GSI1PK": "STATUS#PLACED", "GSI1SK": f"{day}#{order_id}"}]
    writes += [{"PK": f"ORDER#{order_id}", "SK": f"LINE#{p}", "product_name": NAME[p], "qty": q,
                "price_cents": PRICE[p]} for p, q in lines]
    for pid, qty in lines:
        product = dict(table.partitions[f"PRODUCT#{pid}"]["PRODUCT"])
        product["stock"] -= qty
        writes.append(product)
    for item in writes:
        table.put(item)
    units = sum(2 * math.ceil(item_size(i) / 1024) for i in writes)  # transactional = 2 per 1 KB
    return f"committed {len(writes)} items in one transaction, about {units} write units"


print("Transaction: Ben orders 1 cable + 3 webcams (only 2 webcams in stock):")
print("  ->", place_order_dynamo("o1004", "c2", "2026-10-20", [("p1", 1), ("p3", 3)]))
print("Retry with 1 cable + 1 webcam:")
print("  ->", place_order_dynamo("o1004", "c2", "2026-10-20", [("p1", 1), ("p3", 1)]))

# ------------------------------------------------------------ 3. Same idea at scale
big = Table()
for n in range(10_000):                       # 10,000 customers, 5 orders each, 2 lines per order
    cid = f"c{n:05d}"
    big.put({"PK": f"CUSTOMER#{cid}", "SK": "PROFILE", "name": f"Customer {n}"})
    for k in range(5):
        oid, day = f"o{n:05d}{k}", f"2026-10-{10 + k:02d}"
        big.put({"PK": f"CUSTOMER#{cid}", "SK": f"ORDER#{day}#{oid}", "status": "SHIPPED",
                 "items": 3, "total_cents": 6900})
        for pid, qty in (("p1", 2), ("p2", 1)):
            big.put({"PK": f"ORDER#{oid}", "SK": f"LINE#{pid}", "product_name": NAME[pid],
                     "qty": qty, "price_cents": PRICE[pid]})
q_items, q_ru = big.query("CUSTOMER#c04242", "ORDER#", newest_first=True)
s_items, s_ru = big.scan()
s_bytes = sum(item_size(i) for i in s_items)
print(f"\n=== At scale: {len(s_items):,} items")
print(f"Query one customer's orders: {len(q_items)} items read, {q_ru} read units")
print(f"Scan for an ad hoc report:   {len(s_items):,} items read, {s_ru:,.1f} read units, "
      f"{math.ceil(s_bytes / (1024 * 1024))} Scan pages (1 MB limit per call)")

# ------------------------------------------------------- 4. The 400 KB item size limit
line = {"sku": "p1", "product_name": "USB-C cable", "qty": 2, "price_cents": 1200}
header = {"PK": "CUSTOMER#c1", "SK": "ORDER#2026-09-28#o1001", "status": "SHIPPED"}
per_line = value_size(line) + 1
fit = (400 * 1024 - item_size(header) - len("lines") - 3) // per_line
print(f"\n400 KB check: one embedded order line is about {per_line} bytes, so a single order "
      f"item could hold about {fit:,} lines before hitting the 400 KB item limit.")
print("Unbounded lists (every order a customer ever placed) belong in separate items, not one.")

Real output (Python 3.13.5, SQLite 3.46.1, run on October 20, 2026):

=== Relational (sqlite3): normalized tables + JOIN at read time
Ana's orders, newest first (1 SQL query, 2 JOINs):
  o1002  2026-10-05  PLACED   items=1  total=60.00
  o1001  2026-09-28  SHIPPED  items=3  total=69.00
Ad hoc question nobody planned for: revenue by product (still 1 query):
  Webcam        120.00
  USB-C cable   60.00
  Laptop stand  45.00
Transaction: Ben orders 1 cable + 3 webcams (only 2 webcams in stock):
  -> rolled back (CHECK constraint failed: stock >= 0)
  order saved: False; cable stock still 40, webcam stock still 2

=== DynamoDB-style single table: keys designed for each access pattern
Table holds 13 items in 8 partitions (PK values)
AP1 Ana's orders, newest first: Query PK=CUSTOMER#c1, SK begins_with ORDER# -> 2 items, 0.5 read units
  ORDER#2026-10-05#o1002     PLACED   items=1  total=60.00
  ORDER#2026-09-28#o1001     SHIPPED  items=3  total=69.00
AP2 Lines of o1001: Query PK=ORDER#o1001 -> 2 items, 0.5 read units
AP3 Shipped orders: Query GSI1 PK=STATUS#SHIPPED -> ['2026-10-06#o1003', '2026-09-28#o1001'], 0.5 read units (GSI reads are eventually consistent)
Ad hoc revenue by product: no key fits, so Scan the whole table -> read 13 items, 0.5 read units, then aggregate in code:
  Webcam        120.00
  USB-C cable   60.00
  Laptop stand  45.00
Transaction: Ben orders 1 cable + 3 webcams (only 2 webcams in stock):
  -> TransactionCanceled (ConditionalCheckFailed on PRODUCT#p3); nothing written
Retry with 1 cable + 1 webcam:
  -> committed 5 items in one transaction, about 10 write units

=== At scale: 160,000 items
Query one customer's orders: 5 items read, 0.5 read units
Scan for an ad hoc report:   160,000 items read, 1,316.0 read units, 11 Scan pages (1 MB limit per call)

400 KB check: one embedded order line is about 54 bytes, so a single order item could hold about 7,584 lines before hitting the 400 KB item limit.
Unbounded lists (every order a customer ever placed) belong in separate items, not one.

What the output shows

  1. Planned access patterns are one cheap request in both models. SQL answers need 1 with one query and two joins. DynamoDB answers it with one Query on one partition, reading only the 2 items it returns, because the data was stored the way the screen reads it.
  2. Unplanned questions are where the models split. SQL answered “revenue by product” with one more query. In DynamoDB no key fits, so the only option is a Scan of every item, followed by aggregation in your own code.
  3. Scale makes that difference concrete. With 160,000 items, the customer Query still reads 5 items for 0.5 read units. The Scan reads all 160,000 items, about 1,316 read units, across 11 pages of 1 MB. Scan cost depends on the items evaluated, not on what a filter returns, so a filter expression doesn’t make it cheaper.
  4. Both models protect the order transaction. In SQLite the CHECK (stock >= 0) constraint failed and the whole transaction rolled back: no order row, stock unchanged. In the DynamoDB model, the stock condition failed and nothing was written. On retry, 5 items committed together. With transactional writes costing 2 units per 1 KB, that was about 10 write units.
  5. The 400 KB limit shapes the design. One embedded order line is about 54 bytes, so an order item could hold roughly 7,584 lines. That is fine for one order’s lines, but a list that grows forever, like every order a customer has ever placed, belongs in separate items. For files or large documents, store the object in S3 and keep its key in the item (the S3 vs EBS vs EFS guide covers when S3 is the right store).

One honest caveat about this design: STATUS#SHIPPED is a low-cardinality GSI partition key. AWS’s partition key guidance rates status codes as a poor choice because a few values attract most traffic. At real order volumes you would spread it, for example with write sharding (STATUS#SHIPPED#0 to #9), or narrow it by date. The demo keeps it simple so the pattern stays readable.

The same calls with boto3 (not executed)

These are the real DynamoDB API calls for needs 1, 3 and 4. They are not executed for this article, because they need an AWS account, credentials and a real table, and DynamoDB charges for requests. Their parameter shapes were checked offline against the DynamoDB API model that ships with botocore 1.43.111, without creating a client or making any network call.

"""NOT EXECUTED against AWS. Shown in the post as a reference for the real API calls.

Needs your own credentials, Region and a table named "shop" with string keys PK and SK
and a GSI named "GSI1" (keys GSI1PK, GSI1SK). DynamoDB charges for these requests.
"""
import boto3

client = boto3.client("dynamodb", region_name="us-east-1")

# Access pattern 1: a customer's orders, newest first (one partition, one request).
resp = client.query(
    TableName="shop",
    KeyConditionExpression="PK = :pk AND begins_with(SK, :prefix)",
    ExpressionAttributeValues={":pk": {"S": "CUSTOMER#c1"}, ":prefix": {"S": "ORDER#"}},
    ScanIndexForward=False,          # descending sort key order = newest first
    ReturnConsumedCapacity="TOTAL",
)

# Access pattern 3: shipped orders through the GSI (always eventually consistent).
resp = client.query(
    TableName="shop",
    IndexName="GSI1",
    KeyConditionExpression="GSI1PK = :s",
    ExpressionAttributeValues={":s": {"S": "STATUS#SHIPPED"}},
)

# Place an order: header + line + stock decrement, all or nothing.
client.transact_write_items(
    ClientRequestToken="o1004-2026-10-20",   # makes retries of the same call idempotent
    TransactItems=[
        {"Put": {
            "TableName": "shop",
            "Item": {"PK": {"S": "CUSTOMER#c2"}, "SK": {"S": "ORDER#2026-10-20#o1004"},
                     "status": {"S": "PLACED"}, "total_cents": {"N": "6000"},
                     "GSI1PK": {"S": "STATUS#PLACED"}, "GSI1SK": {"S": "2026-10-20#o1004"}},
            "ConditionExpression": "attribute_not_exists(PK)",
        }},
        {"Put": {
            "TableName": "shop",
            "Item": {"PK": {"S": "ORDER#o1004"}, "SK": {"S": "LINE#p3"},
                     "product_name": {"S": "Webcam"}, "qty": {"N": "1"},
                     "price_cents": {"N": "6000"}},
        }},
        {"Update": {
            "TableName": "shop",
            "Key": {"PK": {"S": "PRODUCT#p3"}, "SK": {"S": "PRODUCT"}},
            "UpdateExpression": "SET stock = stock - :q",
            "ConditionExpression": "stock >= :q",
            "ExpressionAttributeValues": {":q": {"N": "1"}},
        }},
    ],
)

Per the API reference: ScanIndexForward=False returns items in descending sort key order, so newest first; the GSI query is always eventually consistent; and if stock >= :q fails, the whole transaction is canceled with a TransactionCanceledException and none of the three writes happen.

Which one should you use? A decision guide

Your situation Pick Why
Known, key-based access patterns at high or spiky scale (carts, sessions, profiles, game state) DynamoDB Single-digit millisecond reads and writes per key; scales out by partitions
Serverless application on Lambda and API Gateway DynamoDB No instances or connections to manage; on-demand bills per request
Unpredictable traffic that is often idle DynamoDB on-demand Throughput costs scale down to zero
Many-to-many relationships, joins, ad hoc reporting RDS SQL answers questions you didn’t plan for
Existing application on MySQL, PostgreSQL, MariaDB, Oracle, SQL Server or Db2 RDS Same engine, same SQL, drivers and tools
Strong referential integrity enforced by the database RDS Foreign keys and constraints
Multi-Region active-active writes DynamoDB global tables Every replica accepts writes; MRSC for strongly consistent multi-Region reads
Read-heavy relational workload RDS with read replicas, or a Multi-AZ DB cluster Readers offload the primary
MySQL or PostgreSQL that has outgrown RDS storage or needs more replicas Aurora Cluster volume grows automatically; up to 15 Aurora Replicas
Analytics on operational DynamoDB data DynamoDB plus zero-ETL to Amazon Redshift, or export to S3 Keeps reporting load off the table

Many real systems use both: RDS for the order and billing records that finance queries, and DynamoDB for sessions, carts or high-volume event data. In a typical three-tier web application, the load balancer from ALB vs NLB sits in front, the application tier in the middle, and one or both of these databases behind it.

This choice is in the AWS Solutions Architect Associate exam guide: task 3.3 lists “determining an appropriate database type (for example, Amazon Aurora, Amazon DynamoDB)” and “relational compared with non-relational”. Watch for the clue words: “key-value”, “serverless”, “single-digit millisecond”, “joins”, “existing Oracle or SQL Server”, “read replicas”, “Multi-AZ”. The AWS Solutions Architect course practices these scenarios. If the SQL side is your gap, with joins, execution plans and indexes, the Oracle SQL course works through those in labs.

Where does Aurora fit?

Amazon Aurora is a relational engine, compatible with MySQL and PostgreSQL, managed through Amazon RDS. It is still SQL with joins, so the DynamoDB vs RDS trade-offs above apply to it unchanged. What differs from RDS for MySQL or PostgreSQL is the storage layer:

Don’t confuse the names: an RDS Multi-AZ DB cluster is not an Aurora DB cluster. The first is three RDS instances with their own storage; the second is Aurora’s shared-volume design.

Common mistakes with DynamoDB and RDS

  1. Copying a normalized schema into DynamoDB. One table per entity plus joins in application code gives you many round trips and none of the benefits. List the access patterns first, then design keys.
  2. Choosing a low-cardinality partition key. Status codes and dates concentrate traffic on a few partitions, each capped at 3,000 read and 1,000 write units per second. Use high-cardinality keys or write sharding.
  3. Relying on Scan for routine queries. It reads the whole table, 1 MB per page, and a filter expression doesn’t reduce what you pay. Add a GSI for any query you run regularly.
  4. Planning to add an LSI later. LSIs exist only if you create them with the table. If you aren’t sure, plan for a GSI.
  5. Expecting strongly consistent reads from a GSI. They are eventually consistent only. Read from the table or an LSI when you need read-after-write.
  6. Storing unbounded lists in one item. The 400 KB limit includes attribute names. Split growing collections into separate items under one partition key.
  7. Sending reads to a Multi-AZ standby. A Multi-AZ DB instance standby can’t serve reads. Use read replicas or a Multi-AZ DB cluster.
  8. Assuming RDS read replicas are current. Replication is asynchronous. Send read-your-own-write queries to the primary.
  9. Picking DynamoDB for a reporting-heavy workload because “it scales”. If most questions are ad hoc aggregates, a relational database, or DynamoDB plus an analytics store, will serve you better.

Frequently asked questions

What is the main difference between DynamoDB and RDS?

DynamoDB is a serverless NoSQL key-value and document database: you design partition and sort keys around known queries, and there are no joins. RDS runs a relational engine such as MySQL, PostgreSQL or Oracle on a DB instance you size, giving you full SQL with joins and ad hoc queries.

Is DynamoDB faster than RDS?

For lookups by key, DynamoDB is designed for consistent single-digit millisecond performance at any scale, and that latency doesn’t grow with table size. RDS can also answer indexed lookups quickly on a well-sized instance, and it is the better fit when a query needs joins or aggregation across many rows.

Does DynamoDB support joins and transactions?

DynamoDB has no JOIN operator, so related data is stored together or denormalized at write time. It does support ACID transactions through TransactWriteItems and TransactGetItems, with up to 100 unique items and 4 MB per transaction, in one AWS account and Region.

Can DynamoDB do strongly consistent reads?

Yes, on tables and local secondary indexes, by setting ConsistentRead to true. Strongly consistent reads use twice the read capacity of eventually consistent reads, which are the default. Global secondary indexes and DynamoDB Streams support eventually consistent reads only.

Is Amazon Aurora the same as Amazon RDS?

Aurora is a MySQL- and PostgreSQL-compatible relational engine managed through Amazon RDS, so it uses the same console, CLI and API. It differs in storage: a shared cluster volume copied across three Availability Zones that grows automatically, with up to 15 Aurora Replicas reading from it.

Is DynamoDB cheaper than RDS?

It depends on the workload. DynamoDB on-demand charges per request plus storage, so idle time costs nothing for throughput. RDS charges for instance hours, storage and backups whether the database is busy or idle. Spiky key-value traffic often favors DynamoDB; steady relational load can favor RDS.

Sources

All checked on October 20, 2026.


Learn AWS architecture with guided practice. For live, instructor-led preparation for the AWS Solutions Architect Associate exam, with hands-on labs, see the AWS Solutions Architect course. If you are in the United States, see online AWS and AI/ML training in the USA. For more comparisons and labs, browse the AWS tutorials hub.

Newsletter

Enjoyed this post?

Get new posts, AI/ML tutorials, AWS batch dates and Oracle tips by email. No spam, unsubscribe anytime.

I’m interested in

Double opt-in: you’ll get a confirmation email first. Privacy policy