The Complete DynamoDB Guide — Concepts, Patterns & Best Practices

A deep-dive reference for designing, building, and operating production systems on Amazon DynamoDB.

🌱 Seedling·created: ·category:Databases

A deep-dive reference for engineers designing, building, and operating production systems on Amazon DynamoDB.


Table of Contents

  1. Introduction & Mental Model
  2. Core Concepts
  3. Primary Keys: Partition Key & Sort Key
  4. Data Types
  5. Capacity Modes: On-Demand vs Provisioned
  6. Secondary Indexes: GSI & LSI
  7. Data Modeling Philosophy
  8. Single-Table Design
  9. Core Design Patterns
  10. Consistency Models
  11. Transactions
  12. Batch Operations
  13. DynamoDB Streams
  14. Time To Live (TTL)
  15. DAX — DynamoDB Accelerator
  16. Global Tables (Multi-Region)
  17. Security Best Practices
  18. Monitoring & Observability
  19. Cost Optimization
  20. Error Handling & Retries
  21. SDK Examples (Node.js & Python)
  22. Common Anti-Patterns
  23. Backup, Restore & Migration
  24. Best Practices Checklist

1. Introduction & Mental Model

DynamoDB is a fully managed, serverless, key-value and document NoSQL database built by AWS for single-digit millisecond performance at any scale. Unlike relational databases, DynamoDB does not offer joins, foreign keys, or ad-hoc queries across arbitrary attributes at scale. Instead, it trades query flexibility for predictable, horizontally scalable performance.

The single most important mental shift when moving from SQL to DynamoDB:

In SQL, you model your data first and figure out queries later. In DynamoDB, you must know your access patterns FIRST, then model your data around them.

If you design a DynamoDB table the way you’d design a normalized relational schema, you will hit a wall — either you’ll need application-side joins (slow, expensive) or you’ll be forced to use expensive Scan operations.

When DynamoDB is a good fit

  • High-scale, predictable access patterns (get item by ID, get items by a known relationship)
  • Need for consistent low-latency reads/writes regardless of scale
  • Serverless architectures (Lambda + DynamoDB is a classic pairing)
  • Workloads with well-understood, finite query patterns
  • Event-driven systems that benefit from Streams

When DynamoDB is a poor fit

  • Heavy ad-hoc analytical queries, complex joins, aggregations across many entities → use Redshift, Athena, or a relational DB, or export to a data lake
  • Highly dynamic/unknown query patterns that evolve constantly
  • Small datasets with low traffic where operational simplicity of Postgres/MySQL wins

2. Core Concepts

ConceptRelational AnalogyDescription
TableTableA collection of items
ItemRowA single record, up to 400 KB
AttributeColumnA key-value field on an item (schemaless — items in the same table can have different attributes)
Primary KeyPrimary KeyUniquely identifies each item; either simple (PK only) or composite (PK + SK)

Key facts:

  • DynamoDB tables are schemaless except for the primary key attributes — every other attribute is optional and can vary per item.
  • Max item size is 400 KB, including attribute names and values.
  • Table names, once created, cannot be renamed.
  • DynamoDB is region-scoped by default (Global Tables extend this).

3. Primary Keys: Partition Key & Sort Key

3.1 Simple Primary Key (Partition Key only)

PK: UserId

Every item must have a unique UserId. Good for pure key-value lookups.

3.2 Composite Primary Key (Partition Key + Sort Key)

PK: UserId       SK: OrderId

This is the workhorse of DynamoDB modeling. All items sharing the same partition key are stored together, sorted by the sort key — this lets you Query a range of related items in one request (e.g., “all orders for user X, sorted by date”).

3.3 How Partitioning Actually Works

DynamoDB hashes the partition key value to determine which physical partition stores the item. This means:

  • Items with the same PK always land on the same partition.
  • A well-distributed PK spreads load across many partitions → higher throughput.
  • A “hot” PK (too many requests hitting one PK value) can throttle even if your table-level provisioned capacity looks fine, because each partition has its own throughput ceiling (3,000 RCU / 1,000 WCU per partition, subject to change/adaptive capacity).

3.4 Choosing a Good Partition Key

  • High cardinality: many distinct values (e.g., UserId, DeviceId, TenantId) — avoid low-cardinality keys like Status or Country as the sole PK.
  • Even access distribution: avoid a PK design where 1% of values receive 90% of the traffic (e.g., a single celebrity user, a single “global counter” row).
  • Matches your dominant access pattern: if 90% of queries are “get all X for a given Y”, Y should be your partition key.

3.5 Sort Key Design Techniques

The sort key isn’t just an ID — it’s a powerful modeling tool:

  • Composite/compound sort keys: SK = "ORDER#2024-06-01#o-1234" lets you query by date range and prefix simultaneously.
  • Hierarchical data: SK = "COUNTRY#US#STATE#CA#CITY#SF" enables begins_with() queries at any level of the hierarchy.
  • Type-prefixing for single-table design: SK = "PROFILE", SK = "ORDER#123", SK = "ADDRESS#456" lets multiple entity types live under one partition key.
  • Sortable timestamps: use ISO-8601 (2024-06-01T12:00:00Z) so lexicographic sort = chronological sort.
  • Zero-padded numbers: SK = "00042" instead of SK = "42" so that string-sort matches numeric-sort ("9" > "10" as strings, but "09" < "10").

4. Data Types

Scalar Types

  • String (S), Number (N), Binary (B), Boolean (BOOL), Null (NULL)

Document Types

  • Map (M) — like a JSON object, supports nesting
  • List (L) — ordered collection, supports mixed types

Set Types

  • String Set (SS), Number Set (NS), Binary Set (BS) — unordered collections of unique scalar values. Useful for tags, permission lists, etc. Cannot contain duplicates or empty values.

Practical notes

  • Numbers are stored as strings internally with up to 38 digits of precision — avoid floating point for money; store cents as integers, or use a Decimal-safe SDK type.
  • Empty strings and empty binary values ARE allowed since 2020, but empty Sets are not.
  • Prefer Map/List over deeply nested JSON blobs when you need to update sub-fields atomically — DynamoDB supports updating nested attributes directly (SET item.address.city = :c).

5. Capacity Modes: On-Demand vs Provisioned

On-Demand

  • Pay per request (per RCU/WCU consumed), no capacity planning.
  • Scales instantly to handle spiky, unpredictable traffic (up to double the previous peak within 30 minutes, per AWS docs — plan ahead for extreme spikes).
  • Best for: new applications, unpredictable/spiky workloads, low ops overhead priority.
  • Downsides: ~2-2.5x more expensive per request than well-utilized provisioned capacity.

Provisioned (with Auto Scaling)

  • You specify RCU/WCU; Application Auto Scaling adjusts within min/max bounds based on a target utilization (e.g., 70%).
  • Best for: predictable, steady-state traffic where cost optimization matters.
  • Supports Reserved Capacity purchases for further discounts on steady baseline load.

Capacity Unit Math

  • 1 RCU = one strongly consistent read of up to 4 KB per second, OR two eventually consistent reads of up to 4 KB per second.
  • 1 WCU = one write of up to 1 KB per second.
  • Transactional reads/writes consume 2x the normal RCU/WCU.
  • Items larger than the unit size round up (e.g., a 4.5 KB item consumes 2 RCU for a strongly consistent read).

Rule of Thumb

Start with On-Demand for new/unknown workloads. Switch to Provisioned + Auto Scaling once traffic patterns stabilize and you can forecast baseline load, to cut costs.


6. Secondary Indexes: GSI & LSI

6.1 Global Secondary Index (GSI)

  • A GSI has its own partition key and optional sort key, different from the base table.
  • Has its own provisioned/on-demand capacity, billed separately.
  • Eventually consistent only (strongly consistent reads are not supported on GSIs).
  • Can be added or removed after table creation.
  • Up to 20 GSIs per table (soft limit, can be raised).
  • GSI writes are asynchronous — a write to the base table doesn’t fail even if the GSI update fails or lags, but this means GSIs can briefly be out of sync during heavy write bursts (GSI throttling can also throttle the base table write if the GSI can’t keep up).

6.2 Local Secondary Index (LSI)

  • Shares the base table’s partition key, but has a different sort key.
  • Must be created at table creation time — cannot be added later, and cannot be removed without recreating the table.
  • Supports strongly consistent reads.
  • Shares the base table’s throughput capacity (no separate billing).
  • Limited to 10 GB per partition key value combined across base table + all LSIs — this is a hard constraint that trips up many teams.
  • Max 5 LSIs per table.

6.3 GSI vs LSI — When to Use Which

FactorGSILSI
Different partition key needed?YesNo — same PK as base table
Add after table creation?YesNo
Strong consistency needed?No (eventually consistent only)Yes
Separate capacity/costYesNo (shares base table capacity)
10GB per-partition limitNoYes

Practical guidance: Default to GSIs. Use LSIs only when you need strong consistency on an alternate sort order for the same entity group, and you’re confident the per-partition item collection will stay well under 10 GB.

6.4 Sparse Indexes

An index only contains items that have the indexed attribute present. This is a deliberate and powerful pattern: if you only add a GSI1PK attribute to items that need to appear in a particular query (e.g., only “active” orders), the index stays small and cheap, and you get an implicit filter for free.

Example: add StatusGSI_PK = "PENDING" only to pending orders. Completed orders never appear in this GSI at all — no filtering needed, no wasted RCUs scanning irrelevant items.

6.5 Index Overloading (Generic Indexes)

In single-table design, you often name GSIs generically — GSI1PK, GSI1SK, GSI2PK, GSI2SK — and reuse the same physical index to represent different relationships for different entity types. This lets a handful of indexes support dozens of access patterns.

6.6 Projections

Each index projects a subset of attributes from the base table:

  • KEYS_ONLY — smallest, cheapest, fastest to write
  • INCLUDE — base keys + a specified attribute list
  • ALL — every attribute (largest storage cost, but avoids a second round-trip to the base table)

Best practice: project only what the query needs. Over-projecting wastes storage and write capacity; under-projecting forces expensive extra GetItem calls back to the base table.


7. Data Modeling Philosophy

The 5-Step Modeling Process

  1. List every entity in your domain (Users, Orders, Products, Reviews…).
  2. List every access pattern your application needs — be exhaustive, including admin/reporting queries. Write them as sentences: “Get user by ID”, “Get all orders for a user in the last 30 days”, “Get top 10 products by sales this month”.
  3. Identify relationships between entities (1:1, 1:N, N:M).
  4. Design your primary key and index structure so that every access pattern maps to a single Query (or occasionally GetItem) — never a Scan for a hot-path production query.
  5. Validate by walking through each access pattern against your design (an ER-diagram-like exercise called “Entity Relationship Diagram → Access Pattern Table”).

Access-Pattern-Driven Table Example

Access PatternIndexKey Condition
Get user profile by IDBase tablePK = USER#123, SK = PROFILE
Get all orders for a userBase tablePK = USER#123, SK begins_with ORDER#
Get order by order IDGSI1GSI1PK = ORDER#456
Get all orders with status PENDINGGSI2 (sparse)GSI2PK = STATUS#PENDING
Get all reviews for a productBase table (if product-centric) or GSI3PK = PRODUCT#789, SK begins_with REVIEW#

NoSQL Modeling Principles

  • Denormalize aggressively. Duplicate data across items to avoid joins. Storage is cheap; compute/latency from joins is not.
  • Pre-compute what you can. Maintain running aggregates (counters, totals) via atomic UpdateItem operations rather than computing them at read time.
  • Design for your most frequent and most latency-sensitive access patterns first, then work backward to less frequent ones (which can sometimes tolerate a Scan + Filter, or be offloaded to a secondary analytics store).

8. Single-Table Design

What It Is

Instead of one DynamoDB table per entity type (like relational tables), you store multiple entity types in one physical table, using generic key names (PK, SK) and type-prefixed values to distinguish entities.

PK              SK                  Attributes
USER#123        PROFILE             name, email, createdAt
USER#123        ORDER#2024-01-01    total, status
USER#123        ORDER#2024-02-15    total, status
ORDER#456       METADATA            userId, total, items[]
PRODUCT#789     PROFILE             name, price, stock
PRODUCT#789     REVIEW#001          rating, text, userId

Why Single-Table Design?

  • Enables fetching multiple related entity types in a single Query by partition key (e.g., a user’s profile AND their last 10 orders in one call) — this is the core performance win.
  • Reduces the number of round trips the application makes, which matters enormously at scale (fewer network hops = lower latency, lower cost).
  • Fits DynamoDB’s pricing/throughput model (pay per table’s indexes, not per number of tables).

Why It’s Controversial

  • Steep learning curve; hard to read/reason about compared to normalized relational schemas.
  • Harder to evolve if access patterns weren’t fully known upfront.
  • Tooling (admin consoles, ORMs) is less mature for generic key/value tables.
  • Rick Houlihan (AWS, originator of the pattern) recommends it primarily for high-scale, well-understood OLTP systems. For smaller apps or apps with evolving/unclear requirements, multi-table design is often the pragmatic choice — don’t cargo-cult single-table design.

Practical Guidance

Use single-table design when you have a finite, well-understood set of access patterns and scale/cost genuinely matter. Use multi-table (one table per entity, or a few tables per bounded context) when requirements are still evolving, the team is new to DynamoDB, or query patterns are genuinely heterogeneous (e.g., a platform serving many different downstream consumers with different needs).


9. Core Design Patterns

9.1 Adjacency List Pattern (Modeling Many-to-Many)

Model relationships as items themselves. E.g., Users belong to many Groups, Groups have many Users:

PK          SK              Type
USER#1      GROUP#A         Membership
USER#1      GROUP#B         Membership
GROUP#A     USER#1          Membership   (mirrored via GSI, PK/SK swapped)
GROUP#A     USER#2          Membership

A GSI with GSI1PK = SK, GSI1SK = PK lets you query both directions (“groups for a user” and “users in a group”) from the same item set.

9.2 Composite/Hierarchical Sort Keys

SK = "ORG#acme#DEPT#eng#TEAM#platform#USER#42"

begins_with(SK, "ORG#acme#DEPT#eng") retrieves everyone in that department, at any team, in one query.

9.3 Write Sharding (for Hot Partitions)

When a partition key would naturally receive disproportionate traffic (e.g., a global leaderboard, a popular “trending” counter, a single tenant with huge volume), append a random or calculated shard suffix:

PK = "COUNTER#pageviews#shard-7"   (shard = hash(something) % N)

Reads must then fan out across all N shards and aggregate — a worthwhile tradeoff to avoid partition throttling on writes.

9.4 Time-Series Data Pattern

Use a rolling partition key by time bucket to avoid unbounded partition growth and hot “today” partitions:

PK = "METRIC#cpu#2024-06"     SK = "2024-06-15T10:00:00Z"

Query a specific month’s data in one Query; older buckets can be moved to cheaper storage (S3 via Streams + Firehose) or expired via TTL.

9.5 Sparse Index for Filtering

Covered in §6.4 — only items matching a condition get the GSI attribute, turning an expensive Scan+Filter into a cheap Query.

9.6 Materialized Aggregations / Counters

Maintain a running total via atomic counters instead of computing aggregates at read time:

UpdateItem({
  Key: { PK: "PRODUCT#789", SK: "METADATA" },
  UpdateExpression: "ADD reviewCount :inc, ratingSum :rating",
  ExpressionAttributeValues: { ":inc": 1, ":rating": 5 }
})

9.7 Versioning / Optimistic Locking

Use a version attribute with a conditional UpdateExpression to prevent lost updates:

UpdateItem({
  ConditionExpression: "version = :expectedVersion",
  UpdateExpression: "SET #data = :newData, version = version + :one",
})

9.8 Soft Deletes & Status Flags via Sparse GSIs

Rather than physically deleting or scanning for status != 'deleted', only present (non-deleted) items carry a GSI_ActivePK attribute — deleted items simply drop out of that index.

9.9 Connection/Cursor Pagination

DynamoDB Query/Scan return a LastEvaluatedKey for pagination — pass it back as ExclusiveStartKey on the next call. Encode it (e.g., base64 JSON) if exposing to a client as an opaque cursor; never expose raw key structure to untrusted clients.

9.10 Filtering vs Key Conditions

  • KeyConditionExpression narrows the partition/range scanned — this is where efficiency comes from.
  • FilterExpression is applied after items are read (and RCUs consumed) — it reduces what’s returned, not what’s read/billed. Never rely on FilterExpression for performance-critical filtering; redesign the key schema instead.

10. Consistency Models

  • Eventually Consistent Reads (default): may not reflect the most recent write; typically consistent within ~1 second. Cheaper (half the RCU of strong reads).
  • Strongly Consistent Reads: always reflect the latest successful write. Not available on GSIs. Slightly higher latency and cost (full RCU).
  • Transactional Reads/Writes: ACID guarantees across up to 100 items/4MB, at 2x the RCU/WCU cost.

Guidance: Default to eventually consistent reads unless you have a specific correctness requirement (e.g., “read your own write” right after a critical update, financial balances, inventory counts at checkout time).


11. Transactions

TransactWriteItems and TransactGetItems provide ACID transactions across multiple items, potentially spanning multiple tables (same account/region).

Capabilities

  • Up to 100 unique items, 4 MB total, per transaction.
  • Supports conditional checks (ConditionCheck) as a transaction participant without modifying data — useful for enforcing invariants (e.g., “only proceed if user’s account is ACTIVE”).
  • All-or-nothing: if any condition fails, the entire transaction is rolled back.

Example: Transfer funds between two accounts

TransactWriteItems({
  TransactItems: [
    {
      Update: {
        Key: { PK: "ACCT#1" },
        ConditionExpression: "balance >= :amount",
        UpdateExpression: "SET balance = balance - :amount",
        ExpressionAttributeValues: { ":amount": 100 }
      }
    },
    {
      Update: {
        Key: { PK: "ACCT#2" },
        UpdateExpression: "SET balance = balance + :amount",
        ExpressionAttributeValues: { ":amount": 100 }
      }
    }
  ]
})

When to Use / Avoid

  • Use for genuine multi-item invariants (payments, inventory reservation, unique-constraint enforcement).
  • Avoid overusing — transactions cost 2x capacity and add latency. Most DynamoDB workloads should be designed to need transactions rarely, if ever, by leaning on single-item atomic updates and careful key design.

12. Batch Operations

BatchGetItem

  • Retrieve up to 100 items (or 16 MB) across one or more tables in a single call.
  • Not transactional — partial failures return UnprocessedKeys, which you must retry.
  • Runs reads in parallel internally — more efficient than N sequential GetItem calls, but still consumes the same total RCUs.

BatchWriteItem

  • Up to 25 put/delete requests, 16 MB total, per call.
  • No update or conditional support in batch writes (only PutItem/DeleteItem, no UpdateItem, no ConditionExpression).
  • Not transactional — check UnprocessedItems and retry with exponential backoff.
  • Duplicate item keys within one batch request are rejected outright by the API.

Best practice: always implement retry logic for UnprocessedKeys/UnprocessedItems — DynamoDB does not automatically retry partial batch failures for you.


13. DynamoDB Streams

Streams capture a time-ordered sequence of item-level changes (insert, update, delete) and retain them for 24 hours, available via:

  • Lambda triggers (most common) — near real-time event-driven processing.
  • Kinesis Data Streams for DynamoDB — longer retention (up to 1 year), supports multiple concurrent consumers, ideal for fan-out to multiple downstream systems.

View Types

  • KEYS_ONLY — just the key attributes
  • NEW_IMAGE — the entire item after the change
  • OLD_IMAGE — the entire item before the change
  • NEW_AND_OLD_IMAGES — both (most flexible, most storage)

Common Use Cases

  • Real-time replication to search indexes (OpenSearch/Elasticsearch)
  • Materialized views / cross-table denormalization
  • Event-driven microservices (publish domain events on write)
  • Change Data Capture (CDC) into a data lake (Streams → Firehose → S3)
  • Audit logging
  • Cross-region/cross-account replication for custom Global Table-like setups

Gotchas

  • Each shard delivers records in order, but ordering across shards is not guaranteed — if strict global ordering matters, you need a partition-key strategy where related items always land in the same shard, or a sequencing attribute.
  • Lambda triggers process shards in parallel by default; a slow/failing record can block a shard until it’s resolved or the retry policy skips it (configure bisectBatchOnFunctionError, maximumRetryAttempts, and a DLQ).

14. Time To Live (TTL)

  • Mark items for automatic deletion by setting a Unix epoch (seconds, not milliseconds) timestamp on a designated TTL attribute.
  • Deletion is not instantaneous — items are typically removed within 48 hours of expiry (background process), though they’re excluded from Query/Scan/GetItem results as soon as they expire, even before physical deletion.
  • Free — no additional WCU cost for TTL deletions.
  • TTL deletions do appear in Streams (marked with a special userIdentity field indicating a system delete) — useful for archiving expired data before it disappears (e.g., TTL → Streams → Lambda → S3 cold storage).

Common Uses

  • Session data expiry
  • Temporary locks/leases
  • Cache-like tables
  • Log/event retention windows
  • Automatic cleanup of soft-deleted records after a grace period

15. DAX — DynamoDB Accelerator

DAX is a fully managed, in-memory cache specifically for DynamoDB, providing microsecond read latency.

  • Write-through cache: writes go to DAX, which writes to DynamoDB, keeping cache and table consistent for writes made through DAX.
  • Caches both item cache (individual GetItem results) and query cache (Query/Scan result sets).
  • Default TTL for cached data is configurable (e.g., 5 minutes).
  • Requires deployment inside a VPC; the DAX client SDK replaces the standard DynamoDB client with minimal code changes.
  • Best for read-heavy, read-intensive workloads with repeated reads of the same items (e.g., product catalogs, leaderboard reads).
  • DAX only supports eventually consistent reads through its cache — strongly consistent reads bypass the cache and go straight to DynamoDB.
  • Not a fit for write-heavy workloads or workloads needing strong consistency on every read.

16. Global Tables (Multi-Region)

Global Tables provide multi-region, multi-active replication with:

  • Sub-second typical replication latency between regions.
  • Last-writer-wins conflict resolution based on internal timestamps (application-level conflict resolution is your responsibility if this isn’t sufficient).
  • Each region has its own full replica with its own local reads/writes — great for global applications needing low-latency local reads worldwide, and for disaster recovery/business continuity.
  • Streams, TTL, and most other features work per-replica, with some considerations (e.g., TTL deletes replicate like any other delete).

When to Use

  • Globally distributed user bases needing local read/write latency.
  • Regional failover / high availability requirements.
  • Not a substitute for backups — it replicates data (and mistakes/deletes) instantly across regions; always maintain Point-in-Time Recovery (PITR) or backups separately.

17. Security Best Practices

  • IAM Fine-Grained Access Control: Use dynamodb:LeadingKeys condition keys to restrict a caller (e.g., a Cognito-authenticated user) to only access items where the partition key matches their own identity — critical for multi-tenant apps.
  • Encryption at rest: enabled by default (AWS owned key, or choose AWS managed/customer managed KMS key for additional control and audit trail).
  • Encryption in transit: all API calls use TLS.
  • VPC Endpoints: use a Gateway VPC Endpoint for DynamoDB so traffic from your VPC never traverses the public internet.
  • Least privilege: scope IAM policies to specific actions (GetItem, Query) and specific tables/indexes — avoid dynamodb:* on Resource: *.
  • Avoid storing highly sensitive data in keys — partition/sort key values can appear in logs, CloudTrail events, and error messages.
  • Consider field-level encryption (e.g., via AWS Encryption SDK) for particularly sensitive attributes (PII, secrets) before writing them to DynamoDB.

18. Monitoring & Observability

Key CloudWatch Metrics

  • ConsumedReadCapacityUnits / ConsumedWriteCapacityUnits
  • ThrottledRequests / ReadThrottleEvents / WriteThrottleEvents
  • SystemErrors / UserErrors
  • SuccessfulRequestLatency
  • ConditionalCheckFailedRequests (spikes may indicate optimistic-locking contention)
  • ReplicationLatency (Global Tables)

Contributor Insights

Identifies the most frequently accessed and most throttled keys — invaluable for diagnosing hot-partition issues without manual log digging.

CloudTrail

Logs all control-plane API calls (table creation, index changes, IAM-related activity) for audit purposes.

Practical Alerting

  • Alert on sustained throttling (ThrottledRequests > 0 for several consecutive minutes).
  • Alert on SuccessfulRequestLatency p99 exceeding SLA thresholds.
  • Alert on Auto Scaling reaching max capacity repeatedly (signals under-provisioned max bounds).

19. Cost Optimization

  • Right-size projections on GSIs — don’t use ALL when KEYS_ONLY or INCLUDE suffices.
  • Use sparse indexes to avoid indexing items that will never be queried through that index.
  • Prefer Provisioned + Auto Scaling with Reserved Capacity for steady, predictable workloads — can be significantly cheaper than On-Demand at scale.
  • Use TTL aggressively to expire data you no longer need instead of manually deleting it (also saves on storage costs, and it’s free).
  • Standard-IA table class: DynamoDB offers a Standard-Infrequent Access table class with lower storage cost but higher throughput cost — good fit for tables with large storage footprint but relatively low request rates (e.g., audit logs, historical data).
  • Batch operations reduce request overhead versus many single-item calls (though total RCU/WCU consumed is the same, there are fewer round trips and often lower Lambda/compute costs).
  • Compress large attributes (e.g., gzip a large JSON blob before storing) to reduce item size and therefore capacity unit consumption.
  • Avoid unnecessary strong consistency — eventually consistent reads cost half as much.
  • Monitor and eliminate Scan operations in hot paths — they’re often the single biggest hidden cost.

20. Error Handling & Retries

Common Errors

  • ProvisionedThroughputExceededException — you’re being throttled; the SDK retries with exponential backoff automatically by default, but sustained throttling means you need to redesign your key schema or increase capacity.
  • ConditionalCheckFailedException — a conditional write failed (expected in optimistic locking flows — treat as a normal control-flow signal, not necessarily a bug).
  • TransactionCanceledException — a transaction failed; check the CancellationReasons array to see which specific item/condition caused it.
  • ItemCollectionSizeLimitExceededException — an LSI-backed item collection exceeded the 10 GB limit.
  • ValidationException — malformed request (bad expression syntax, wrong data type, etc.).

Retry Strategy

  • All modern AWS SDKs implement exponential backoff with jitter by default for retryable errors — don’t build your own naive retry loop without jitter, as synchronized retries across many clients can cause “retry storms.”
  • For BatchWriteItem/BatchGetItem, you must explicitly re-submit UnprocessedItems/UnprocessedKeys — the SDK does not do this automatically for you at the batch level (only for the whole-request-level throttling retry).

21. SDK Examples (Node.js & Python)

Node.js (AWS SDK v3, DocumentClient)

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  PutCommand,
  GetCommand,
  QueryCommand,
  UpdateCommand,
} from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

// Put an item
await docClient.send(new PutCommand({
  TableName: "AppTable",
  Item: { PK: "USER#123", SK: "PROFILE", name: "Ada", email: "ada@example.com" },
  ConditionExpression: "attribute_not_exists(PK)",
}));

// Get an item
const { Item } = await docClient.send(new GetCommand({
  TableName: "AppTable",
  Key: { PK: "USER#123", SK: "PROFILE" },
}));

// Query a range of related items
const { Items } = await docClient.send(new QueryCommand({
  TableName: "AppTable",
  KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
  ExpressionAttributeValues: { ":pk": "USER#123", ":prefix": "ORDER#" },
}));

// Atomic counter update
await docClient.send(new UpdateCommand({
  TableName: "AppTable",
  Key: { PK: "PRODUCT#789", SK: "METADATA" },
  UpdateExpression: "ADD viewCount :inc",
  ExpressionAttributeValues: { ":inc": 1 },
}));

Python (boto3)

import boto3
from boto3.dynamodb.conditions import Key, Attr

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("AppTable")

# Put an item
table.put_item(
    Item={"PK": "USER#123", "SK": "PROFILE", "name": "Ada", "email": "ada@example.com"},
    ConditionExpression=Attr("PK").not_exists(),
)

# Get an item
response = table.get_item(Key={"PK": "USER#123", "SK": "PROFILE"})
item = response.get("Item")

# Query a range of related items
response = table.query(
    KeyConditionExpression=Key("PK").eq("USER#123") & Key("SK").begins_with("ORDER#")
)
items = response["Items"]

# Atomic counter update
table.update_item(
    Key={"PK": "PRODUCT#789", "SK": "METADATA"},
    UpdateExpression="ADD viewCount :inc",
    ExpressionAttributeValues={":inc": 1},
)

22. Common Anti-Patterns

Anti-PatternWhy It’s a ProblemFix
Using Scan in a hot request pathReads the entire table, consumes huge RCUs, slow at scaleRedesign keys/indexes so a Query covers the pattern
Modeling like a relational DB (many small normalized tables + app-side joins)Requires many round trips, high latency, high costDenormalize; use single-table or purpose-built access-pattern tables
Low-cardinality partition keys (e.g., Status, Country) as sole PKCreates hot partitions, throttlingAdd high-cardinality component, or shard the key
Storing large blobs (>400 KB) directlyExceeds item size limitStore in S3, keep a pointer/reference in DynamoDB
Relying on FilterExpression for performanceFiltering happens after read — you still pay full RCU for scanned itemsRedesign key schema so filtering happens via KeyConditionExpression
Overusing Transactions2x capacity cost, added latency, added complexityUse only for genuine multi-item invariants
Ignoring UnprocessedItems/UnprocessedKeys in batch callsSilent data loss on partial batch failuresAlways check and retry
Not planning access patterns before modelingForces expensive redesigns or Scan-heavy workarounds laterDo the 5-step modeling process (§7) upfront
Unbounded item collections on an LSIHits the 10 GB per-partition-key hard limitUse a GSI instead, or shard the partition key

23. Backup, Restore & Migration

  • Point-in-Time Recovery (PITR): continuous backups enabling restore to any second in the last up to 35 days. Restoring creates a new table — it does not overwrite the existing one.
  • On-Demand Backups: manual, full backups retained until explicitly deleted; useful before risky schema/data migrations.
  • Export to S3: export table data (full or from a PITR point) to S3 in DynamoDB JSON or Amazon Ion format without consuming table read capacity — ideal for analytics pipelines (Athena, EMR) or archival.
  • Import from S3: bulk-load data into a new table from S3 (CSV, DynamoDB JSON, Amazon Ion).
  • Migration strategy for schema changes: because DynamoDB is schemaless beyond the primary key, most “schema migrations” are really data migrations — write new-shape items alongside old ones, backfill via a Scan+transform script or Streams-driven backfill Lambda, then cut over reads once backfilled.

24. Best Practices Checklist

  • Access patterns identified and documented before table design
  • Partition key has high cardinality and even access distribution
  • Sort key designed to support range queries / hierarchy where needed
  • GSIs used for alternate access patterns; sparse indexes used where filtering is needed
  • LSIs used only when strong consistency + alternate sort order is genuinely required, with 10 GB/partition limit accounted for
  • No Scan operations in hot/production request paths
  • Appropriate capacity mode chosen (On-Demand for new/unpredictable, Provisioned+AutoScaling for steady-state)
  • TTL configured for expirable data
  • Streams wired up if event-driven downstream processing is needed
  • DAX evaluated for read-heavy, latency-critical workloads
  • IAM policies scoped with least privilege, LeadingKeys condition used for multi-tenant isolation
  • PITR enabled on production tables
  • CloudWatch alarms configured for throttling and latency
  • Retry logic in place for batch operation partial failures
  • Item sizes monitored; large blobs offloaded to S3
  • Cost reviewed periodically (capacity mode, projections, table class)

Further Reading

  • AWS Documentation: Amazon DynamoDB Developer Guide
  • The DynamoDB Book by Alex DeBrie
  • AWS re:Invent talks by Rick Houlihan on Advanced NoSQL Design Patterns

This note is part of the Digital Garden — a collection of connected, evolving thoughts.