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:
- Primary key: either a partition key alone, or a partition key plus a sort key. Every item must have it; everything else is optional and can differ between items.
- Item size: up to 400 KB, counting attribute names as well as values. Nested attributes go up to 32 levels deep.
- Reads:
GetItem(one item by full key),BatchGetItem(up to 100 items, 16 MB),Query(all items with one partition key, optionally filtered by sort key),Scan(the whole table).QueryandScanreturn at most 1 MB per call, and you page withLastEvaluatedKey. - Extras: DynamoDB Streams for change data capture, time to live (TTL) to delete expired items without consuming write throughput, global tables for multi-Region replication, and zero-ETL integrations with Amazon Redshift and with Amazon OpenSearch Service (through OpenSearch Ingestion) for analytics and search.
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:
- Storage: General Purpose SSD (gp2, gp3) or Provisioned IOPS SSD (io1, io2 Block Express). Up to 64 TiB for Db2, MariaDB, MySQL and PostgreSQL; Oracle and SQL Server can reach 256 TiB with additional storage volumes. Storage autoscaling can grow it for you.
- High availability: a Multi-AZ DB instance deployment keeps a synchronous standby in another Availability Zone. A Multi-AZ DB cluster (RDS for MySQL and RDS for PostgreSQL only) has a writer and two readable standbys in three zones.
- Read scaling: read replicas, created manually. RDS doesn’t add or remove replicas automatically as load changes.
- Backups: automated backups are on by default when you create a DB instance, kept for 1 to 35 days, with point-in-time restore.
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:
- The partition key decides which partition stores an item. All items that share a partition key value form an item collection, stored together and sorted by sort key.
- A
Querynames one partition key value and can narrow the sort key with conditions such asbegins_withorBETWEEN, ascending or descending. - Because there is no JOIN, related data is stored together or copied (denormalized) at write time. An order header can carry its total and item count so nobody has to add up lines at read time.
- Many applications keep several entity types in one table (single-table design), using key prefixes such as
CUSTOMER#andORDER#. AWS lists the trade-offs: fewer round trips and tables to manage, but a steeper learning curve and backups, encryption and capacity shared across all entities.
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:
- At most 100 unique items and 4 MB of data per transaction.
- No two actions in one transaction can target the same item.
- One AWS account and one Region only. ACID guarantees apply in the Region where the write is made; global tables replicate the changes afterward, and multi-Region strong consistency (MRSC) global tables don’t support the transaction APIs at all.
- Transactional reads and writes consume two units per item instead of one.
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:
- Compute: change the DB instance class. That needs downtime; on a Multi-AZ deployment, RDS modifies the standby first and then fails over, which shortens it. You can apply the change immediately or in the next maintenance window.
- Storage: increase it or turn on storage autoscaling, up to the engine’s limit.
- Reads: add read replicas, up to 15 for RDS for MySQL, MariaDB and PostgreSQL (fewer on some commercial engines, for example 5 for Oracle).
- Writes: all go to one primary. Past the largest instance class, you split data across databases yourself.
What are DynamoDB capacity modes?
DynamoDB has two throughput modes per table:
- On-demand is the default and the mode AWS recommends for most workloads. You pay per read and write request, there is no capacity to plan, and the table scales down to zero cost for throughput when idle.
- Provisioned means you set read capacity units (RCUs) and write capacity units (WCUs) per second, optionally with auto scaling, and pay for what you provision whether you use it or not. It suits steady, predictable traffic.
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:
- DynamoDB: reads and writes (per request on on-demand, or per provisioned capacity unit-hour), storage, and the optional features you turn on, such as backups, PITR, global table replication and streams. Strongly consistent reads cost twice eventually consistent ones, and transactions cost twice regular operations.
- RDS: DB instance hours (Multi-AZ deployments have their own rates on the pricing pages, and every read replica is billed as a standard DB instance), provisioned storage per GiB-month, provisioned IOPS for io1, io2 and gp3, backup storage, and data transfer. Reserved instances lower the hourly rate for long-running databases.
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:
- Show a customer’s orders, newest first, with totals.
- Show the lines of one order.
- List all shipped orders for the warehouse screen.
- 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
- 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
Queryon one partition, reading only the 2 items it returns, because the data was stored the way the screen reads it. - 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
Scanof every item, followed by aggregation in your own code. - Scale makes that difference concrete. With 160,000 items, the customer
Querystill reads 5 items for 0.5 read units. TheScanreads all 160,000 items, about 1,316 read units, across 11 pages of 1 MB.Scancost depends on the items evaluated, not on what a filter returns, so a filter expression doesn’t make it cheaper. - 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. - 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:
- Data lives in a shared cluster volume with copies across three Availability Zones. The volume grows automatically as data grows; the Aurora overview gives 256 TiB as the maximum, and the exact limit depends on the engine version.
- One primary and up to 15 Aurora Replicas share that volume, so adding a reader doesn’t copy the data.
- Two storage configurations: Aurora Standard, which charges per I/O request, and Aurora I/O-Optimized, with no separate I/O charges. AWS suggests I/O-Optimized when I/O is 25% or more of your Aurora spending.
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
- 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.
- 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.
- Relying on
Scanfor 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. - 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.
- 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.
- Storing unbounded lists in one item. The 400 KB limit includes attribute names. Split growing collections into separate items under one partition key.
- 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.
- Assuming RDS read replicas are current. Replication is asynchronous. Send read-your-own-write queries to the primary.
- 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.
- Amazon DynamoDB Developer Guide: What is Amazon DynamoDB? (serverless, no versions or maintenance windows, no JOIN, three-AZ replication, PITR 1-35 days, Streams, zero-ETL), Constraints (400 KB items, key lengths, capacity unit sizes, transactions, Query/Scan 1 MB, BatchGetItem), Quotas (20 GSIs, 5 LSIs, table throughput, table size), Read consistency, Throughput capacity modes, Provisioned capacity mode (auto scaling, switching limits), Read and write operations (unit consumption for Query, Scan, transactions), Item sizes and formats, Secondary indexes (GSI vs LSI), Local secondary indexes, Scanning tables, NoSQL design, Data modeling foundations (single-table design), Partition key design (3,000/1,000 units per partition), Designing partition keys to distribute workload, Modeling relational data, SQL vs DynamoDB: reading data, Storing large items and attributes, Switching capacity modes, Point-in-time recovery, Global tables design (MREC and MRSC), Time to live
- Amazon DynamoDB API Reference: TransactWriteItems, Query
- AWS Database Blog: Backup strategies for Amazon DynamoDB (PITR not retroactive, settings to reapply after restore)
- Amazon RDS User Guide: What is Amazon RDS? (DB engines), DB instance storage (storage types, 64 TiB and 256 TiB), Multi-AZ deployments, Multi-AZ DB instance deployments, Multi-AZ DB cluster deployments, Supported engines for Multi-AZ DB clusters, Failing over a Multi-AZ DB instance (60-120 seconds), Read replicas, Maintaining a DB instance, DB instance billing
- Amazon RDS: Getting started: backing up and restoring (1-35 day retention, point-in-time recovery), RDS features (restore up to the last five minutes), RDS FAQs (read replica counts), create-db-instance-read-replica CLI reference, AWS Database Blog: maintenance and scaling the instance class
- Amazon Aurora User Guide: What is Amazon Aurora? (256 TiB cluster volume), Aurora storage (three AZs, Standard and I/O-Optimized), Adding Aurora Replicas (up to 15)
- AWS Certification: SAA-C03 exam guide, Domain 3 (task 3.3, high-performing database solutions)
- Pricing: Amazon DynamoDB pricing, Amazon RDS pricing, AWS Pricing Calculator
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.