Understanding Amazon DynamoDB
Amazon DynamoDB is a fully managed NoSQL key-value and document database service provided by AWS. It delivers consistent single-digit millisecond performance at any scale, making it one of the most powerful database solutions for modern applications. Unlike traditional relational databases, DynamoDB is schema-less (or more accurately, schema-flexible), horizontally scalable, and requires zero server provisioning or maintenance overhead.
At its core, DynamoDB stores data in tables composed of items (rows) and attributes (columns). Each table requires a primary key that uniquely identifies every item. The service automatically replicates data across multiple Availability Zones, encrypts data at rest by default, and provides built-in backup and restore capabilities. DynamoDB supports both eventually consistent and strongly consistent reads, giving developers fine-grained control over data freshness versus performance.
Why DynamoDB Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →For developers and architects building cloud-native applications, DynamoDB solves several critical challenges simultaneously. First, it eliminates the operational burden of managing database infrastructure — no patching, no disk management, no cluster scaling headaches. Second, its pay-per-request pricing model (on-demand capacity) means you never pay for idle capacity, which is ideal for variable workloads and startups. Third, DynamoDB's predictable latency profile makes it indispensable for latency-sensitive use cases like session stores, shopping carts, gaming leaderboards, and real-time analytics.
Beyond operational simplicity, DynamoDB integrates natively with other AWS services such as Lambda for event-driven processing, AppSync for GraphQL backends, and Kinesis Data Streams for change data capture. This ecosystem integration accelerates development velocity and enables serverless architectures that scale automatically with demand.
Core Concepts and Terminology
Tables, Items, and Attributes
A table is a collection of items, analogous to a table in relational databases but without a fixed schema. An item is a single data record within a table, identified by its primary key. Items can contain up to 400KB of data. Attributes are key-value pairs that make up an item. Each item in the same table can have different attributes — only the primary key attributes must be present in every item.
Primary Keys
DynamoDB supports two types of primary keys:
- Simple Primary Key (Partition Key only): A single attribute that uniquely identifies an item. The partition key is hashed to determine the physical partition where the item is stored.
- Composite Primary Key (Partition Key + Sort Key): Two attributes where the combination must be unique. The partition key groups items together, and the sort key determines the order of items within that group. This enables rich query patterns like retrieving all items with the same partition key, sorted by the sort key value.
Secondary Indexes
Secondary indexes allow querying on non-key attributes. DynamoDB offers two types:
- Local Secondary Index (LSI): Shares the same partition key as the base table but uses a different sort key. LSIs are created at table creation time and cannot be added later. They consume the table's read capacity units.
- Global Secondary Index (GSI): Uses a completely different primary key (both partition and sort keys can differ). GSIs can be created or deleted at any time and have their own provisioned throughput settings, decoupled from the base table.
Setting Up DynamoDB: AWS Console Walkthrough
The AWS Management Console provides the quickest path to creating your first DynamoDB table. Navigate to the DynamoDB service and click "Create table." You'll need to provide a table name, a partition key name and type, and optionally a sort key. Below is a step-by-step walkthrough for creating a table that stores user profiles.
In the console, configure the following settings:
- Table name: Users
- Partition key: userId (String)
- Sort key: createdAt (Number) — optional but recommended for ordering
- Capacity mode: Choose "On-demand" for unpredictable workloads or "Provisioned" if you have steady-state traffic patterns and want to use reserved capacity pricing
- Encryption: Default KMS-managed key or choose a customer-managed key
Click "Create table" and within minutes your table becomes active across multiple Availability Zones. The console also provides a visual interface for exploring items, running queries, and monitoring metrics through CloudWatch dashboards embedded directly in the DynamoDB page.
Local Development Setup: DynamoDB Local
For local development and testing without incurring AWS charges, DynamoDB Local is an invaluable tool. It's a downloadable version of DynamoDB that runs on your machine, exposing the same API endpoints. You can run it as a Docker container, a JAR file, or integrated into tools like AWS SAM CLI.
Running DynamoDB Local with Docker
# Pull the official DynamoDB Local Docker image
docker pull amazon/dynamodb-local
# Run DynamoDB Local on port 8000
docker run -d -p 8000:8000 --name dynamodb-local amazon/dynamodb-local
# Verify it's running
curl http://localhost:8000/shell/
Configuring AWS CLI for Local Endpoint
# Create a table locally using the AWS CLI with the local endpoint
aws dynamodb create-table \
--table-name Users \
--attribute-definitions \
AttributeName=userId,AttributeType=S \
AttributeName=createdAt,AttributeType=N \
--key-schema \
AttributeName=userId,KeyType=HASH \
AttributeName=createdAt,KeyType=RANGE \
--provisioned-throughput \
ReadCapacityUnits=5,WriteCapacityUnits=5 \
--endpoint-url http://localhost:8000
# List tables to confirm
aws dynamodb list-tables --endpoint-url http://localhost:8000
The local instance uses the same API as the production service but stores data in memory by default (or in a local file if configured). This means data is ephemeral between restarts unless you specify the -dbPath flag to persist to disk.
Working with DynamoDB Using the AWS SDK for Node.js
The AWS SDK for JavaScript (v3) provides a modern, modular interface for DynamoDB operations. Install the necessary packages and configure your client with region and credentials. The following examples use the document client, which automatically marshals and unmarshals JavaScript objects to DynamoDB's native attribute format.
Installing and Configuring the SDK
# Install the DynamoDB client and utilities
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
// Import and configure the DynamoDB Document Client
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, GetCommand, QueryCommand, ScanCommand, UpdateCommand, DeleteCommand } from "@aws-sdk/lib-dynamodb";
// Create the base client
const client = new DynamoDBClient({
region: "us-east-1",
// For local development, uncomment:
// endpoint: "http://localhost:8000",
// credentials: { accessKeyId: "local", secretAccessKey: "local" }
});
// Create the document client wrapper
const docClient = DynamoDBDocumentClient.from(client);
Creating a Table Programmatically
import { DynamoDBClient, CreateTableCommand } from "@aws-sdk/client-dynamodb";
const client = new DynamoDBClient({ region: "us-east-1" });
async function createUsersTable() {
const command = new CreateTableCommand({
TableName: "Users",
AttributeDefinitions: [
{ AttributeName: "userId", AttributeType: "S" },
{ AttributeName: "createdAt", AttributeType: "N" }
],
KeySchema: [
{ AttributeName: "userId", KeyType: "HASH" },
{ AttributeName: "createdAt", KeyType: "RANGE" }
],
BillingMode: "PAY_PER_REQUEST",
GlobalSecondaryIndexes: [
{
IndexName: "EmailIndex",
KeySchema: [
{ AttributeName: "email", KeyType: "HASH" }
],
Projection: {
ProjectionType: "ALL"
}
}
],
AttributeDefinitions: [
{ AttributeName: "userId", AttributeType: "S" },
{ AttributeName: "createdAt", AttributeType: "N" },
{ AttributeName: "email", AttributeType: "S" }
]
});
try {
const response = await client.send(command);
console.log("Table creation initiated:", response.TableDescription.TableStatus);
} catch (error) {
console.error("Error creating table:", error);
}
}
createUsersTable();
CRUD Operations in Detail
// --- CREATE: Insert a new item ---
async function createUser(userId, email, name, age) {
const command = new PutCommand({
TableName: "Users",
Item: {
userId: userId,
createdAt: Date.now(),
email: email,
name: name,
age: age,
isActive: true,
preferences: {
theme: "dark",
notifications: true
}
},
// Optional: Condition expression to prevent overwrites
ConditionExpression: "attribute_not_exists(userId)"
});
try {
await docClient.send(command);
console.log("User created successfully");
} catch (error) {
if (error.name === "ConditionalCheckFailedException") {
console.log("User already exists — item not overwritten");
} else {
console.error("Error creating user:", error);
}
}
}
// --- READ: Get a single item by its full primary key ---
async function getUser(userId, createdAt) {
const command = new GetCommand({
TableName: "Users",
Key: {
userId: userId,
createdAt: createdAt
},
// Retrieve only specific attributes to save RCUs
ProjectionExpression: "userId, email, #nm, preferences.theme",
ExpressionAttributeNames: {
"#nm": "name" // 'name' is a reserved word, must use placeholder
},
ConsistentRead: false // Use eventually consistent reads for better performance
});
try {
const response = await docClient.send(command);
if (response.Item) {
console.log("User found:", response.Item);
return response.Item;
} else {
console.log("User not found");
return null;
}
} catch (error) {
console.error("Error fetching user:", error);
}
}
// --- READ: Query items by partition key ---
async function getUserActivities(userId) {
const command = new QueryCommand({
TableName: "Users",
KeyConditionExpression: "userId = :uid",
ExpressionAttributeValues: {
":uid": userId
},
// Optional: filter sort key range
// KeyConditionExpression: "userId = :uid AND createdAt BETWEEN :start AND :end",
Limit: 10,
ScanIndexForward: false // descending order (newest first)
});
try {
const response = await docClient.send(command);
console.log(`Found ${response.Items.length} items`);
return response.Items;
} catch (error) {
console.error("Error querying:", error);
}
}
// --- READ: Scan (full table scan — use sparingly) ---
async function getAllActiveUsers() {
const command = new ScanCommand({
TableName: "Users",
FilterExpression: "isActive = :active",
ExpressionAttributeValues: {
":active": true
},
Limit: 50
});
try {
const response = await docClient.send(command);
console.log(`Scanned ${response.ScannedCount} items, found ${response.Count} matches`);
return response.Items;
} catch (error) {
console.error("Error scanning:", error);
}
}
// --- UPDATE: Modify specific attributes ---
async function updateUserAge(userId, createdAt, newAge) {
const command = new UpdateCommand({
TableName: "Users",
Key: {
userId: userId,
createdAt: createdAt
},
UpdateExpression: "SET age = :newAge, #mod = :now REMOVE temporaryData",
ExpressionAttributeValues: {
":newAge": newAge,
":now": Date.now()
},
ExpressionAttributeNames: {
"#mod": "lastModified"
},
ReturnValues: "ALL_NEW" // Returns the updated item
});
try {
const response = await docClient.send(command);
console.log("Updated user:", response.Attributes);
} catch (error) {
console.error("Error updating:", error);
}
}
// --- DELETE: Remove an item ---
async function deleteUser(userId, createdAt) {
const command = new DeleteCommand({
TableName: "Users",
Key: {
userId: userId,
createdAt: createdAt
},
ConditionExpression: "attribute_exists(userId)",
ReturnValues: "ALL_OLD" // Returns the item as it was before deletion
});
try {
const response = await docClient.send(command);
console.log("Deleted user:", response.Attributes);
} catch (error) {
console.error("Error deleting:", error);
}
}
Batch Operations for Efficiency
import { BatchGetCommand, BatchWriteCommand } from "@aws-sdk/lib-dynamodb";
// Batch read up to 100 items in a single request
async function batchGetUsers(userKeys) {
const command = new BatchGetCommand({
RequestItems: {
"Users": {
Keys: userKeys, // Array of { userId: "...", createdAt: number }
ConsistentRead: false,
ProjectionExpression: "userId, email, name"
}
}
});
try {
const response = await docClient.send(command);
console.log(`Retrieved ${response.Responses["Users"].length} items`);
// Check for unprocessed keys and implement retry logic if needed
if (response.UnprocessedKeys && Object.keys(response.UnprocessedKeys).length > 0) {
console.log("Some keys were unprocessed — implement exponential backoff retry");
}
return response.Responses["Users"];
} catch (error) {
console.error("Batch get error:", error);
}
}
// Batch write: put or delete up to 25 items per request
async function batchCreateUsers(usersArray) {
const putRequests = usersArray.map(user => ({
PutRequest: {
Item: {
userId: user.userId,
createdAt: Date.now(),
email: user.email,
name: user.name
}
}
}));
// Split into chunks of 25 (DynamoDB limit per batch)
const chunks = [];
for (let i = 0; i < putRequests.length; i += 25) {
chunks.push(putRequests.slice(i, i + 25));
}
for (const chunk of chunks) {
const command = new BatchWriteCommand({
RequestItems: {
"Users": chunk
}
});
try {
const response = await docClient.send(command);
if (response.UnprocessedItems && Object.keys(response.UnprocessedItems).length > 0) {
console.log("Retrying unprocessed items with backoff...");
// Implement exponential backoff retry here
}
} catch (error) {
console.error("Batch write error:", error);
}
}
}
Data Modeling Strategies for DynamoDB
Effective DynamoDB usage requires a fundamentally different approach to data modeling compared to relational databases. Instead of normalization and joins, you design your tables based on access patterns. The single most important principle is: identify how your application queries data, then model your tables and indexes to serve those queries efficiently without scans.
The Single Table Design Pattern
A powerful pattern for DynamoDB is storing multiple entity types in a single table using composite keys and generic attribute names. This leverages the fact that DynamoDB tables are schema-flexible and items can have different attribute sets. For example, in an e-commerce application, you might store users, orders, and products in one table, differentiated by a type attribute and using partition key overloading.
// Example single-table design for a social app
// Partition Key: PK (entity identifier)
// Sort Key: SK (hierarchical relationship)
//
// User profile: PK="USER#abc123", SK="PROFILE"
// User's posts: PK="USER#abc123", SK="POST#"
// Post comments: PK="POST#xyz789", SK="COMMENT#"
// Friendship: PK="USER#abc123", SK="FRIEND#def456"
async function createUserProfileAndPosts(userId, profileData, posts) {
const items = [];
// User profile item
items.push({
PutRequest: {
Item: {
PK: `USER#${userId}`,
SK: "PROFILE",
entityType: "User",
email: profileData.email,
name: profileData.name,
joinedAt: Date.now()
}
}
});
// User's posts
for (const post of posts) {
items.push({
PutRequest: {
Item: {
PK: `USER#${userId}`,
SK: `POST#${post.timestamp}`,
entityType: "Post",
content: post.content,
likes: 0,
createdAt: post.timestamp
}
}
});
}
// Batch write all items atomically
const command = new BatchWriteCommand({
RequestItems: { "AppTable": items }
});
await docClient.send(command);
}
async function getUserWithPosts(userId) {
const command = new QueryCommand({
TableName: "AppTable",
KeyConditionExpression: "PK = :pk AND SK <= :sk",
ExpressionAttributeValues: {
":pk": `USER#${userId}`,
":sk": "POST#"
},
// This fetches the PROFILE item and all POST items for this user
Limit: 11 // profile + 10 most recent posts
});
const response = await docClient.send(command);
return response.Items;
}
Capacity Management: On-Demand vs. Provisioned
DynamoDB offers two billing modes. On-Demand Capacity charges per request (reads and writes) and automatically scales to handle traffic spikes without throttling — ideal for variable workloads, prototyping, and when capacity planning is difficult. Provisioned Capacity lets you specify read and write capacity units (RCUs and WCUs) upfront, with lower per-request costs and the ability to use reserved capacity for further discounts. Provisioned mode works well for predictable, steady-state traffic patterns.
You can switch between modes once every 24 hours per table. Use CloudWatch metrics like ConsumedReadCapacityUnits and ThrottledRequests to monitor usage and adjust provisioned capacity or identify if on-demand is more cost-effective for your workload.
Best Practices for Production DynamoDB
1. Design for Access Patterns First
Before creating any table, enumerate every query your application needs to perform. Model your partition keys, sort keys, and secondary indexes to satisfy those queries with minimal latency. Avoid generic designs that require scans or filtering — every query should ideally hit an index or use the primary key directly.
2. Use Composite Keys Effectively
Leverage sort keys to enable range queries, sorting, and hierarchical relationships. A well-chosen sort key can eliminate the need for additional secondary indexes. Common sort key patterns include timestamps for chronological ordering, hierarchical prefixes (e.g., ORDER#2024-01-15#item123), and version numbers for optimistic locking.
3. Keep Item Sizes Small
DynamoDB charges based on item size (measured in 1KB increments for reads). Keep frequently accessed items compact by storing large binary data (images, files) in S3 and referencing them via URLs. Consider splitting large items across multiple table entries if they contain rarely accessed attributes.
4. Implement Efficient Pagination
DynamoDB query and scan results are limited to 1MB per page. Always check for LastEvaluatedKey in responses and implement pagination loops. This is critical for scan operations that may touch large portions of a table.
async function paginatedQuery(tableName, partitionKeyValue, limit = 100) {
let lastEvaluatedKey = undefined;
let allItems = [];
do {
const command = new QueryCommand({
TableName: tableName,
KeyConditionExpression: "PK = :pk",
ExpressionAttributeValues: { ":pk": partitionKeyValue },
Limit: limit,
ExclusiveStartKey: lastEvaluatedKey
});
const response = await docClient.send(command);
allItems = allItems.concat(response.Items);
lastEvaluatedKey = response.LastEvaluatedKey;
} while (lastEvaluatedKey);
console.log(`Total items retrieved: ${allItems.length}`);
return allItems;
}
5. Use Conditional Expressions for Consistency
Prevent race conditions and unintended overwrites with conditional expressions. Common patterns include attribute_not_exists() to prevent duplicate inserts, attribute_exists() to ensure an item exists before updating, and version-based optimistic locking using numeric counters.
6. Monitor and Handle Throttling
Implement exponential backoff retry logic for throttled requests (ProvisionedThroughputExceededException or RequestLimitExceeded). The AWS SDKs include automatic retry mechanisms, but understanding the underlying behavior helps in debugging performance issues. Use CloudWatch alarms on ThrottledRequests to detect capacity saturation.
7. Leverage DynamoDB Streams for Event-Driven Architectures
Enable DynamoDB Streams on your tables to capture a time-ordered sequence of item-level changes. This integrates seamlessly with AWS Lambda to trigger functions on create, update, or delete events, enabling real-time data synchronization, audit logging, or cross-service communication without polling.
// Example: Processing DynamoDB Stream events in a Lambda function
export const handler = async (event) => {
for (const record of event.Records) {
// record.eventName is "INSERT", "MODIFY", or "REMOVE"
const eventName = record.eventName;
// New image (current state of the item)
const newImage = record.dynamodb.NewImage;
// Old image (previous state, only for MODIFY and REMOVE)
const oldImage = record.dynamodb.OldImage;
console.log(`Processing ${eventName} event for item`, newImage);
// Implement your logic: sync to Elasticsearch, send notifications, etc.
}
};
8. Secure Access with IAM Policies and Fine-Grained Access Control
Never use root or broad-scoped credentials for DynamoDB access. Define IAM policies that restrict access to specific tables, indexes, and even individual attributes or partition key values. DynamoDB supports fine-grained access control where you can scope IAM policies to specific partition key prefixes, enabling multi-tenant isolation within a single table.
// Example IAM policy for fine-grained access control
// This policy limits a user to only their own data based on partition key prefix
/*
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/AppTable",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["USER#${aws:username}"]
}
}
}
]
}
*/
Infrastructure as Code: DynamoDB with CloudFormation and CDK
CloudFormation Example
# CloudFormation YAML snippet for a DynamoDB table
Resources:
OrdersTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: Orders
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: customerId
AttributeType: S
- AttributeName: orderDate
AttributeType: S
- AttributeName: orderStatus
AttributeType: S
KeySchema:
- AttributeName: customerId
KeyType: HASH
- AttributeName: orderDate
KeyType: RANGE
GlobalSecondaryIndexes:
- IndexName: StatusIndex
KeySchema:
- AttributeName: orderStatus
KeyType: HASH
- AttributeName: orderDate
KeyType: RANGE
Projection:
ProjectionType: ALL
StreamSpecification:
StreamViewType: NEW_AND_OLD_IMAGES
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
Tags:
- Key: Environment
Value: Production
AWS CDK Example (TypeScript)
import * as cdk from 'aws-cdk-lib';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
export class MyAppStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const ordersTable = new dynamodb.Table(this, 'OrdersTable', {
tableName: 'Orders',
partitionKey: {
name: 'customerId',
type: dynamodb.AttributeType.STRING
},
sortKey: {
name: 'orderDate',
type: dynamodb.AttributeType.STRING
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecovery: true,
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
// Add a Global Secondary Index
ordersTable.addGlobalSecondaryIndex({
indexName: 'StatusIndex',
partitionKey: {
name: 'orderStatus',
type: dynamodb.AttributeType.STRING
},
sortKey: {
name: 'orderDate',
type: dynamodb.AttributeType.STRING
},
projectionType: dynamodb.ProjectionType.ALL
});
// Enable DynamoDB Streams
ordersTable.dynamoStreamSpecification = {
streamViewType: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES
};
// Output table ARN for reference in other stacks
new cdk.CfnOutput(this, 'OrdersTableArn', {
value: ordersTable.tableArn
});
}
}
Monitoring and Troubleshooting
DynamoDB integrates deeply with CloudWatch, providing metrics for read/write capacity consumption, throttled requests, system errors, and latency at the table and index level. Set up dashboards and alarms for the following key metrics:
- ConsumedReadCapacityUnits / ConsumedWriteCapacityUnits: Track actual usage versus provisioned limits
- ThrottledRequests: Any non-zero value indicates capacity issues that need attention
- SuccessfulRequestLatency: Monitor P99 latency to detect performance degradation
- SystemErrors: AWS-internal errors that may require support escalation
Use CloudWatch Contributor Insights to identify frequently accessed partition keys and detect hot partitions. If a single partition key receives disproportionate traffic, consider redistributing your key space or implementing write sharding with derived partition keys (e.g., appending a suffix to distribute writes across multiple logical partitions).
Conclusion
Amazon DynamoDB represents a paradigm shift in database management, trading the rigid schemas and complex joins of relational databases for unparalleled scalability, predictable performance, and operational simplicity. Success with DynamoDB requires embracing access-pattern-driven data modeling, understanding the nuances of key design and capacity management, and leveraging the rich ecosystem of AWS integrations for event-driven architectures. By following the setup patterns, code examples, and best practices outlined in this guide, you can build applications that scale seamlessly from prototype to global production workloads while maintaining single-digit millisecond latency. The investment in learning DynamoDB's unique strengths pays dividends in reduced operational overhead, simplified scaling, and faster development cycles for modern cloud-native applications.