The Complete DynamoDB Guide — Concepts, Patterns & Best Practices
A deep-dive reference for designing, building, and operating production systems on Amazon DynamoDB.
A deep-dive reference for engineers designing, building, and operating production systems on Amazon DynamoDB.
Table of Contents
- Introduction & Mental Model
- Core Concepts
- Primary Keys: Partition Key & Sort Key
- Data Types
- Capacity Modes: On-Demand vs Provisioned
- Secondary Indexes: GSI & LSI
- Data Modeling Philosophy
- Single-Table Design
- Core Design Patterns
- Consistency Models
- Transactions
- Batch Operations
- DynamoDB Streams
- Time To Live (TTL)
- DAX — DynamoDB Accelerator
- Global Tables (Multi-Region)
- Security Best Practices
- Monitoring & Observability
- Cost Optimization
- Error Handling & Retries
- SDK Examples (Node.js & Python)
- Common Anti-Patterns
- Backup, Restore & Migration
- 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
| Concept | Relational Analogy | Description |
|---|---|---|
| Table | Table | A collection of items |
| Item | Row | A single record, up to 400 KB |
| Attribute | Column | A key-value field on an item (schemaless — items in the same table can have different attributes) |
| Primary Key | Primary Key | Uniquely 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 likeStatusorCountryas 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”,
Yshould 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"enablesbegins_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 ofSK = "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 nestingList (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/Listover 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
| Factor | GSI | LSI |
|---|---|---|
| Different partition key needed? | Yes | No — same PK as base table |
| Add after table creation? | Yes | No |
| Strong consistency needed? | No (eventually consistent only) | Yes |
| Separate capacity/cost | Yes | No (shares base table capacity) |
| 10GB per-partition limit | No | Yes |
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 writeINCLUDE— base keys + a specified attribute listALL— 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
- List every entity in your domain (Users, Orders, Products, Reviews…).
- 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”.
- Identify relationships between entities (1:1, 1:N, N:M).
- Design your primary key and index structure so that every access pattern maps to a single
Query(or occasionallyGetItem) — never aScanfor a hot-path production query. - 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 Pattern | Index | Key Condition |
|---|---|---|
| Get user profile by ID | Base table | PK = USER#123, SK = PROFILE |
| Get all orders for a user | Base table | PK = USER#123, SK begins_with ORDER# |
| Get order by order ID | GSI1 | GSI1PK = ORDER#456 |
| Get all orders with status PENDING | GSI2 (sparse) | GSI2PK = STATUS#PENDING |
| Get all reviews for a product | Base table (if product-centric) or GSI3 | PK = 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
UpdateItemoperations 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
Queryby 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
KeyConditionExpressionnarrows the partition/range scanned — this is where efficiency comes from.FilterExpressionis applied after items are read (and RCUs consumed) — it reduces what’s returned, not what’s read/billed. Never rely onFilterExpressionfor 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
GetItemcalls, 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, noUpdateItem, noConditionExpression). - Not transactional — check
UnprocessedItemsand 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 attributesNEW_IMAGE— the entire item after the changeOLD_IMAGE— the entire item before the changeNEW_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/GetItemresults 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
userIdentityfield 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
GetItemresults) and query cache (Query/Scanresult 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:LeadingKeyscondition 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 — avoiddynamodb:*onResource: *. - 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/ConsumedWriteCapacityUnitsThrottledRequests/ReadThrottleEvents/WriteThrottleEventsSystemErrors/UserErrorsSuccessfulRequestLatencyConditionalCheckFailedRequests(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 > 0for several consecutive minutes). - Alert on
SuccessfulRequestLatencyp99 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
ALLwhenKEYS_ONLYorINCLUDEsuffices. - 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
Scanoperations 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 theCancellationReasonsarray 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-submitUnprocessedItems/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-Pattern | Why It’s a Problem | Fix |
|---|---|---|
Using Scan in a hot request path | Reads the entire table, consumes huge RCUs, slow at scale | Redesign 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 cost | Denormalize; use single-table or purpose-built access-pattern tables |
Low-cardinality partition keys (e.g., Status, Country) as sole PK | Creates hot partitions, throttling | Add high-cardinality component, or shard the key |
| Storing large blobs (>400 KB) directly | Exceeds item size limit | Store in S3, keep a pointer/reference in DynamoDB |
Relying on FilterExpression for performance | Filtering happens after read — you still pay full RCU for scanned items | Redesign key schema so filtering happens via KeyConditionExpression |
| Overusing Transactions | 2x capacity cost, added latency, added complexity | Use only for genuine multi-item invariants |
Ignoring UnprocessedItems/UnprocessedKeys in batch calls | Silent data loss on partial batch failures | Always check and retry |
| Not planning access patterns before modeling | Forces expensive redesigns or Scan-heavy workarounds later | Do the 5-step modeling process (§7) upfront |
| Unbounded item collections on an LSI | Hits the 10 GB per-partition-key hard limit | Use 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
Scanoperations 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,
LeadingKeyscondition 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