← DynamoDB patterns

08 - Operations

2026-10-20

So far we've been designing tables. This page is about running them in production: choosing a capacity mode, protecting data, going multi-region, and getting data out for analytics without hurting the live table.

Four ideas run through it. Capacity modes: on-demand needs no planning and scales instantly, while provisioned with auto scaling is cheaper for steady, predictable traffic, and we can switch between them. Backups: point-in-time recovery restores to any second in the last 35 days, on-demand backups are kept until we delete them, and a restore always creates a new table. Global tables replicate a table across regions, active-active; concurrent writes to the same item resolve as last-writer-wins, so we route each item's writes to one home region when that matters. And analytics belongs elsewhere: export to S3 (which uses no table capacity) and query with Athena, or use zero-ETL to Redshift or OpenSearch, rather than running reporting Scans against the live table.

DynamoDB Local doesn't support most of these APIs, so the demos make the real calls, show that they fail locally, and then simulate the behaviour.

# Pattern You'll learn
38 Capacity modes Sizing read/write units; switching modes; auto scaling
39 PITR & backups Enabling PITR, on-demand backups, restore-to-new-table
40 Global tables Adding replicas; last-writer-wins and how to avoid losing writes
41 Analytics outside DynamoDB Export to S3 format; querying the export instead of the table

38 · Capacity modes: on-demand vs provisioned

Code: 38-capacity-modes.py · run with just run 38

Problem

DynamoDB bills for throughput in one of two ways. Pick the wrong one and we either overpay a lot, or get throttled when traffic spikes.

Pattern

The rule of thumb is: start on on-demand, and move to provisioned + auto scaling once traffic is steady and predictable.

Mode You pay for Best for
On-demand (PAY_PER_REQUEST) each request (RRU / WRU) new apps, spiky or unknown traffic, dev/test, low volume
Provisioned + auto scaling capacity per hour, used or not steady, predictable traffic at scale
Provisioned + reserved capacity 1- or 3-year commitment large, stable baseline

The capacity-unit maths is the same in both modes:

For example, with 3 KB items, 500 reads/s and 100 writes/s, we need 500 RCU for strong reads (250 for eventual) and 300 WCU.

Auto scaling uses a separate service, Application Auto Scaling, with a target-tracking policy (e.g. keep utilisation at 70%).

In practice

The script sizes the table, switches it to provisioned, prints the auto scaling setup (DynamoDB Local doesn't have Application Auto Scaling, so it's printed rather than executed), and switches back:

Python
def rcu(item_kb, reads_per_sec, consistent=True):
    units = -(-item_kb // 4)  # ceil
    return units * reads_per_sec * (1 if consistent else 0.5)


def wcu(item_kb, writes_per_sec):
    return -(-item_kb // 1) * writes_per_sec


client.update_table(
    TableName=TABLE,
    BillingMode="PROVISIONED",
    ProvisionedThroughput={"ReadCapacityUnits": 250, "WriteCapacityUnits": 300},
)
...
client.update_table(TableName=TABLE, BillingMode="PAY_PER_REQUEST")
=== Start: on-demand ===
{
  "BillingMode": "PAY_PER_REQUEST",
  "ProvisionedThroughput": {
    "ReadCapacityUnits": 0,
    "WriteCapacityUnits": 0
  }
}

=== Sizing for 3 KB items, 500 reads/s, 100 writes/s ===
{
  "RCU (strong)": 500,
  "RCU (eventual)": 250.0,
  "WCU": 300
}

>>> UpdateTable p38-capacity-modes

=== After switching to provisioned ===
{
  "BillingMode": "PROVISIONED",
  "ProvisionedThroughput": {
    "ReadCapacityUnits": 250,
    "WriteCapacityUnits": 300
  }
}

=== Auto scaling setup (AWS only, not executed) ===
{
  "register_scalable_target": {
    "ServiceNamespace": "dynamodb",
    "ResourceId": "table/p38-capacity-modes",
    "ScalableDimension": "dynamodb:table:WriteCapacityUnits",
    "MinCapacity": 50,
    "MaxCapacity": 1000
  },
  "put_scaling_policy": {
    "PolicyType": "TargetTrackingScaling",
    "TargetTrackingScalingPolicyConfiguration": {
      "TargetValue": 70.0,
      "PredefinedMetricSpecification": {
        "PredefinedMetricType": "DynamoDBWriteCapacityUtilization"
      }
    }
  }
}

>>> UpdateTable p38-capacity-modes

=== Back to on-demand ===
{
  "BillingMode": "PAY_PER_REQUEST",
  ...
}

A 3 KB item rounds up to one 4 KB read unit, so 500 strong reads/s is 500 RCU, and half that for eventual reads. On the write side it rounds up to three 1 KB units, so 100 writes/s is 300 WCU. The switch between modes is a single UpdateTable call in each direction.

Why

Trade-offs

Gotchas

39 · Point-in-time recovery & backups

Code: 39-pitr-backups.py · run with just run 39

Problem

A bad deploy, a buggy migration or a fat-fingered script overwrites or deletes data. DynamoDB replicates for durability, but it faithfully replicates our mistakes too. We need a way back.

Pattern

We use both kinds of backup, and possibly AWS Backup on top:

Feature What it is Use for
Point-in-time recovery (PITR) continuous backup; restore to any second in the last 1–35 days accidental writes/deletes, bad deploys
On-demand backup named full snapshot, kept until deleted before migrations, compliance, long retention
AWS Backup schedules, retention rules, cross-account/region copies org-wide backup policy

In AWS, the calls look like this:

Python
client.update_continuous_backups(
    TableName="orders",
    PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": True, "RecoveryPeriodInDays": 35},
)
client.create_backup(TableName="orders", BackupName="before-migration")
client.restore_table_to_point_in_time(
    SourceTableName="orders", TargetTableName="orders-restored",
    RestoreDateTime=datetime(2026, 10, 20, 9, 55),
)

In practice

DynamoDB Local doesn't implement any of the backup APIs, so this one is simulated. The script makes the real calls anyway, so we can see them fail, and then uses a scan-and-write-back snapshot as a stand-in:

Python
attempt("Enable PITR", client.update_continuous_backups, TableName=TABLE,
        PointInTimeRecoverySpecification={"PointInTimeRecoveryEnabled": True, "RecoveryPeriodInDays": 35})
backup = attempt("Create on-demand backup", client.create_backup, TableName=TABLE, BackupName="before-migration")

local_snapshot = table.scan()["Items"]  # local stand-in for a backup

# Oops - a bad deploy zeroes the balance.
table.update_item(Key={"PK": "ACCOUNT#1", "SK": "META"}, UpdateExpression="SET balance = :z", ExpressionAttributeValues={":z": 0})

# Restores ALWAYS create a NEW table; you then swap traffic or copy items back.
attempt("Restore to latest restorable time", client.restore_table_to_point_in_time,
        SourceTableName=TABLE, TargetTableName=f"{TABLE}-restored", UseLatestRestorableTime=True)
>>> PutItem p39-pitr-backups
    item:       {PK: 'ACCOUNT#1', SK: 'META', balance: 100}

>>> UpdateContinuousBackups p39-pitr-backups

=== Enable PITR: not supported by DynamoDB Local ===
"An unknown operation was requested."

>>> CreateBackup p39-pitr-backups

=== Create on-demand backup: not supported by DynamoDB Local ===
"An unknown operation was requested."

>>> Scan p39-pitr-backups

>>> UpdateItem p39-pitr-backups
    key:        {PK: 'ACCOUNT#1', SK: 'META'}
    update:     SET balance = 0

=== After bad deploy ===
{
  "balance": 0,
  "SK": "META",
  "PK": "ACCOUNT#1"
}

>>> RestoreTableToPointInTime

=== Restore to latest restorable time: not supported by DynamoDB Local ===
"An unknown operation was requested."

>>> BatchWriteItem
    p39-pitr-backups: 1 puts, 0 deletes

=== Local stand-in: restored from snapshot ===
{
  "SK": "META",
  "balance": 100,
  "PK": "ACCOUNT#1"
}

The story is the same one we'd live through in AWS: an account with a balance of 100, a bad deploy that zeroes it, and a restore that brings the 100 back. Locally the restore is a BatchWriteItem of the snapshot. In AWS it would be a new orders-restored table that we copy from or swap to.

Why

Trade-offs

Gotchas

40 · Global tables (multi-region)

Code: 40-global-tables.py · run with just run 40

Problem

Our users are spread across continents, and cross-region latency adds 100+ ms per request. Or we need to survive a whole AWS region going down. A single-region table can't do either.

Pattern

We make the table a global table: a fully managed replica in every chosen region. Each replica accepts reads and writes (active-active). DynamoDB replicates changes between regions asynchronously, usually in about a second.

Python
client.update_table(
    TableName="orders",
    ReplicaUpdates=[{"Create": {"RegionName": "eu-west-1"}}],
)

Conflicts, where the same item is written in two regions before replication catches up, are resolved by last writer wins, using an internal timestamp. The losing write is discarded with no error.

sequenceDiagram
    participant US as us-east-1
    participant EU as eu-west-1
    US->>US: write ORDER#o1 = CANCELLED
    EU->>EU: write ORDER#o1 = SHIPPED (later timestamp)
    US-->>EU: replicate CANCELLED
    Note over EU: older timestamp, dropped
    EU-->>US: replicate SHIPPED
    Note over US,EU: both regions converge on SHIPPED

In practice

DynamoDB Local doesn't support replicas, so the script simulates two regions with dicts. Each write is stamped with a timestamp and queued for asynchronous replication, and the replicator only applies a write if it's newer than what the target already has:

Python
def replicate():
    while replication_log:
        target, key, item = replication_log.pop(0)
        current = regions[target].get(key)
        if current is None or item["_ts"] > current["_ts"]:
            regions[target][key] = item  # last writer wins
        else:
            print(f"  {target}: dropped older write from {item['_region']} ({item['status']})")

It then shows three things: a write in one region becoming visible in the other after replication, a conflict, and the fix.

>>> UpdateTable p40-global-tables

=== Add replica: not supported by DynamoDB Local ===
"Nothing to update"

=== Normal case: write in one region, read anywhere (after replication) ===
us-east-1  wrote ORDER#o1: PLACED

=== Before replication (eu-west-1 is stale) ===
{
  "us-east-1": {
    "ORDER#o1": "PLACED"
  },
  "eu-west-1": {}
}

=== After replication ===
{
  "us-east-1": {
    "ORDER#o1": "PLACED"
  },
  "eu-west-1": {
    "ORDER#o1": "PLACED"
  }
}

=== Conflict: both regions update the same item before replicating ===
us-east-1  wrote ORDER#o1: CANCELLED
eu-west-1  wrote ORDER#o1: SHIPPED
  eu-west-1: dropped older write from us-east-1 (CANCELLED)

=== Converged (CANCELLED was silently lost!) ===
{
  "us-east-1": {
    "ORDER#o1": "SHIPPED"
  },
  "eu-west-1": {
    "ORDER#o1": "SHIPPED"
  }
}

=== Mitigation: give each item a home region and only write there ===
eu-west-1  wrote ORDER#o2: PLACED
eu-west-1  wrote ORDER#o2: PAID

=== No conflicts possible ===
{
  "us-east-1": {
    "ORDER#o1": "SHIPPED",
    "ORDER#o2": "PAID"
  },
  "eu-west-1": {
    "ORDER#o1": "SHIPPED",
    "ORDER#o2": "PAID"
  }
}

The conflict is the scary part. A customer cancelled the order in us-east-1, a warehouse shipped it in eu-west-1 a moment later, and both regions converge on SHIPPED. The cancellation is gone, and nobody got an error. Once ORDER#o2 has a home region and is only ever written there, its writes are applied in order and conflicts can't happen.

Why

Trade-offs

Gotchas

41 · Analytics outside DynamoDB

Code: 41-analytics-export.py · run with just run 41

Problem

"Revenue by country last quarter." "Top 100 customers." Questions like these need to read the whole table and group or sort by attributes that aren't keys. In DynamoDB that means a full Scan, which is slow, expensive, and eats the capacity our live traffic needs (12).

Pattern

DynamoDB serves the application. Analytics runs on a copy. There are three common ways to get that copy:

Option How Freshness
Export to S3 + Athena / Glue / Spark ExportTableToPointInTime, full or incremental, in DynamoDB JSON or Ion snapshot (minutes–hours)
Zero-ETL to Redshift / OpenSearch managed integration, no pipeline code near real-time
Streams → Firehose → S3 change events into a data lake (31) near real-time

The export is a single call:

Python
client.export_table_to_point_in_time(
    TableArn=table_arn,
    S3Bucket="my-analytics-bucket",
    S3Prefix="exports/orders",
    ExportFormat="DYNAMODB_JSON",        # or "ION"
    ExportType="INCREMENTAL_EXPORT",     # or "FULL_EXPORT" (default)
)

Then we query it in Athena:

SQL
SELECT item.country.S AS country, COUNT(*) AS orders, SUM(CAST(item.total.N AS int)) AS revenue
FROM orders_export GROUP BY 1;

In practice

DynamoDB Local has no export API, so this one is simulated too. The script seeds 500 orders, tries the real export, and then does a 4-segment parallel scan into gzipped files in the same {"Item": {...}} line format a real export writes. Finally it runs the GROUP BY in Python, reading only the files:

Python
def export_segment(segment):
    path = export_dir / f"segment-{segment}.json.gz"
    kwargs = dict(TableName=TABLE, Segment=segment, TotalSegments=SEGMENTS)
    with gzip.open(path, "wt") as f:
        while True:
            resp = client.scan(**kwargs)  # clients are thread-safe; resource objects are not
            for item in resp["Items"]:
                # same line format as a real DYNAMODB_JSON export
                f.write(json.dumps({"Item": {k: ser.serialize(v) for k, v in item.items()}}, default=str) + "\n")
            if "LastEvaluatedKey" not in resp:
                break
            kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"]


with ThreadPoolExecutor(SEGMENTS) as pool:
    counts = list(pool.map(export_segment, range(SEGMENTS)))
>>> BatchWriteItem
    p41-analytics-export: 25 puts, 0 deletes
    ... (+17 more BatchWriteItem calls not shown)

>>> ExportTableToPointInTime

=== ExportTableToPointInTime: not supported by DynamoDB Local ===
"UnknownOperationException"

>>> Scan p41-analytics-export
    Segment:    0
    TotalSegments: 4

>>> Scan p41-analytics-export
    Segment:    1
    TotalSegments: 4

>>> Scan p41-analytics-export
    Segment:    2
    TotalSegments: 4
    ... (+1 more Scan calls not shown)

=== Exported ===
{
  "dir": ".../p41-export-gbtkfbg8/data",
  "items per segment": [
    142,
    122,
    114,
    122
  ]
}

=== Revenue by country (computed off the table) ===
{
  "DE": {
    "orders": 127,
    "revenue": 13240
  },
  "FR": {
    "orders": 125,
    "revenue": 11535
  },
  "UK": {
    "orders": 120,
    "revenue": 11979
  },
  "US": {
    "orders": 128,
    "revenue": 12867
  }
}

The four segments split the 500 items roughly evenly, and each one is scanned in parallel. Once the export exists, the revenue-by-country report never touches the table again. We could re-run it, change it, or add a JOIN, and DynamoDB would never notice.

Why

Trade-offs

Gotchas


← 07 - Performance & scale | 09 - What not to do →