DynamoDB: Stop Treating It Like a Relational Database πΈποΈ
DynamoDB: Stop Treating It Like a Relational Database πΈποΈ
Real talk: The first time I used DynamoDB, I thought "It's just another database!" I created a table, added 15 global secondary indexes (GSIs), did a bunch of scans, and deployed to production feeling like a genius. π
Three weeks later, my AWS bill was $847/month, queries were taking 4+ seconds, and I was frantically Googling "how to use DynamoDB correctly" at 2 AM. π
Welcome to DynamoDB - where thinking like a relational database developer will absolutely DESTROY your performance and your budget!
What Even Is DynamoDB? (Beyond "NoSQL Database") π€
DynamoDB = AWS's fully managed NoSQL database - Key-value and document store that scales to any size.
Think of it like: A giant hash table in the sky... with very specific rules about how you can access data!
Real features:
- Fully managed (no servers!)
- Millisecond latency at ANY scale
- Auto-scaling (handles traffic spikes)
- Built-in backups and point-in-time recovery
- Global tables (multi-region replication)
Why it's everywhere: When configured right, it's STUPID fast and incredibly cheap. When configured wrong? It's a money pit that makes MySQL look good! πΈ
Why it's confusing: You have to completely change how you think about data modeling. SQL habits will HURT you here!
The $847 DynamoDB Bill: My NoSQL Horror Story π
When architecting our serverless e-commerce backend, I needed to store user profiles, orders, and product data. "DynamoDB is perfect for this!" I thought.
What I naively did (treating it like SQL):
// Created a "Users" table
const usersTable = {
TableName: 'Users',
KeySchema: [
{ AttributeName: 'userId', KeyType: 'HASH' }
],
AttributeDefinitions: [
{ AttributeName: 'userId', AttributeType: 'S' },
{ AttributeName: 'email', AttributeType: 'S' },
{ AttributeName: 'username', AttributeType: 'S' },
{ AttributeName: 'createdAt', AttributeType: 'N' },
{ AttributeName: 'country', AttributeType: 'S' }
],
// Added GSIs for EVERY field I wanted to query! π±
GlobalSecondaryIndexes: [
{
IndexName: 'EmailIndex',
KeySchema: [{ AttributeName: 'email', KeyType: 'HASH' }],
Projection: { ProjectionType: 'ALL' }
},
{
IndexName: 'UsernameIndex',
KeySchema: [{ AttributeName: 'username', KeyType: 'HASH' }],
Projection: { ProjectionType: 'ALL' }
},
{
IndexName: 'CreatedAtIndex',
KeySchema: [{ AttributeName: 'createdAt', KeyType: 'HASH' }],
Projection: { ProjectionType: 'ALL' }
},
{
IndexName: 'CountryIndex',
KeySchema: [{ AttributeName: 'country', KeyType: 'HASH' }],
Projection: { ProjectionType: 'ALL' }
}
// ... and 11 more GSIs! π€¦ββοΈ
]
}
What happened next:
- Storage costs EXPLODED (each GSI stores a full copy of data!)
- Write costs were INSANE (each write = 1 write + 15 GSI writes!)
- Query performance was TERRIBLE (wrong access patterns!)
- Bill for 100K users: $847/month when it should've been $30! π±
The breakdown:
With 15 GSIs (my terrible design):
- Storage: 100GB Γ 16 copies (main table + 15 GSIs) = 1.6TB
- Storage cost: 1600GB Γ $0.25/GB = $400/month
- Write capacity: 1000 WCU Γ 16 (main + GSIs) = 16,000 WCU
- Write cost: 16,000 Γ $0.00065/hour Γ 730 hours = $7,600/month
- Read capacity: 5000 RCU Γ $0.00013/hour Γ 730 hours = $474/month
- Total: $8,474/month (before on-demand pricing saved me!)
With proper design (1 table, 2 GSIs):
- Storage: 100GB Γ 3 copies = 300GB
- Storage cost: 300GB Γ $0.25/GB = $75/month
- Optimized access patterns = 90% fewer reads
- Batch writes = 80% fewer WCUs
- Total: $28/month! π°
The lesson: DynamoDB pricing punishes bad data modeling. EVERY index costs money. Design for your access patterns, not for flexibility!
In production, I've deployed DynamoDB tables serving 10M requests/day for $50/month. The secret? Proper data modeling! π―
DynamoDB Mistake #1: Too Many Global Secondary Indexes π¨
The SQL habit:
-- In PostgreSQL, indexes are cheap:
CREATE INDEX ON users(email);
CREATE INDEX ON users(username);
CREATE INDEX ON users(country);
CREATE INDEX ON users(age);
CREATE INDEX ON users(created_at);
-- 5 indexes = 5Γ faster queries, minimal cost!
In DynamoDB - WRONG!
// Each GSI = FULL COPY of your data!
// 5 GSIs = 6Γ storage cost (original + 5 copies)
// 5 GSIs = 6Γ write cost (write to table + 5 indexes)
// BAD: 15 GSIs
GlobalSecondaryIndexes: [
{ IndexName: 'EmailIndex', ... },
{ IndexName: 'UsernameIndex', ... },
{ IndexName: 'CountryIndex', ... },
{ IndexName: 'AgeIndex', ... },
{ IndexName: 'CreatedAtIndex', ... },
// ... 10 more! πΈ
]
The proper approach - Composite keys:
// GOOD: 1-2 GSIs with smart composite keys
const usersTable = {
TableName: 'Users',
// Partition key for direct access
KeySchema: [
{ AttributeName: 'PK', KeyType: 'HASH' }, // USER#userId
{ AttributeName: 'SK', KeyType: 'RANGE' } // METADATA
],
// GSI for email lookups
GlobalSecondaryIndexes: [{
IndexName: 'GSI1',
KeySchema: [
{ AttributeName: 'GSI1PK', KeyType: 'HASH' }, // EMAIL#email
{ AttributeName: 'GSI1SK', KeyType: 'RANGE' } // USER#userId
],
Projection: { ProjectionType: 'ALL' }
}]
}
// Store data like this:
{
"PK": "USER#12345",
"SK": "METADATA",
"userId": "12345",
"email": "[email protected]",
"username": "john",
"GSI1PK": "EMAIL#[email protected]",
"GSI1SK": "USER#12345"
}
// Query by user ID (partition key)
const user = await dynamodb.get({
TableName: 'Users',
Key: { PK: 'USER#12345', SK: 'METADATA' }
}).promise()
// Query by email (GSI)
const userByEmail = await dynamodb.query({
TableName: 'Users',
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :email',
ExpressionAttributeValues: { ':email': 'EMAIL#[email protected]' }
}).promise()
Why this is better:
- β 1 GSI instead of 15 (15Γ cheaper!)
- β Composite keys enable flexible queries
- β Storage cost: 2Γ instead of 16Γ
- β Write cost: 2Γ instead of 16Γ
A serverless pattern that saved us: Design your table around access patterns, NOT around entity types! One table to rule them all! π―
DynamoDB Mistake #2: Using Scan Instead of Query π‘
The problem:
// EXPENSIVE: Scan entire table to find user by email
const result = await dynamodb.scan({
TableName: 'Users',
FilterExpression: 'email = :email',
ExpressionAttributeValues: { ':email': '[email protected]' }
}).promise()
// What actually happens:
// 1. DynamoDB reads EVERY SINGLE ITEM in table
// 2. Filters results after reading (you pay for ALL reads!)
// 3. With 1M users: Scans 1M items to find 1 user! π±
Cost comparison (1M items, 1KB each):
Scan (to find 1 item):
- Reads all 1M items = 1,000,000 KB = 1GB
- Read capacity: 1GB / 4KB per RCU = 250,000 RCUs
- Cost: 250,000 RCUs Γ $0.00013/hour = $32.50 PER SCAN!
- Query time: 3-5 seconds
Query (with proper key):
- Reads 1 item = 1KB
- Read capacity: 1 KB / 4KB per RCU = 0.25 RCU
- Cost: 0.25 RCU Γ $0.00013/hour = $0.0000325 PER QUERY
- Query time: 10-20ms β¨
The fix - Use Query with keys:
// GOOD: Query with partition key
const result = await dynamodb.query({
TableName: 'Users',
IndexName: 'GSI1',
KeyConditionExpression: 'GSI1PK = :email',
ExpressionAttributeValues: {
':email': 'EMAIL#[email protected]'
}
}).promise()
// Reads ONLY items matching the key
// Fast, cheap, scalable! π
When Scan is acceptable:
// Only use Scan for:
// 1. Small tables (<100 items)
// 2. Admin tools (not production!)
// 3. Background jobs with no time constraints
// 4. Export/backup operations
// NEVER use Scan for:
// β Production API queries
// β Real-time user requests
// β High-frequency operations
When architecting on AWS, I learned: If you're using Scan in production, you're doing it wrong! Redesign your table with proper keys! π―
DynamoDB Mistake #3: Not Using Single-Table Design π
The SQL habit (multiple tables):
// Creating separate tables for each entity (EXPENSIVE!)
- UsersTable
- OrdersTable
- ProductsTable
- ReviewsTable
- AddressesTable
// 5 tables = 5Γ provisioned capacity costs
// Relationships = multiple queries = slow + expensive!
The DynamoDB way (single table):
// ONE table for everything!
const mainTable = {
TableName: 'ApplicationData',
KeySchema: [
{ AttributeName: 'PK', KeyType: 'HASH' },
{ AttributeName: 'SK', KeyType: 'RANGE' }
]
}
// Store different entities with prefixes:
// Users:
{ "PK": "USER#12345", "SK": "METADATA", "name": "John", ... }
// Orders:
{ "PK": "USER#12345", "SK": "ORDER#67890", "total": 99.99, ... }
// Products:
{ "PK": "PRODUCT#abc", "SK": "METADATA", "name": "Widget", ... }
// Reviews:
{ "PK": "PRODUCT#abc", "SK": "REVIEW#12345", "rating": 5, ... }
// User's addresses:
{ "PK": "USER#12345", "SK": "ADDRESS#home", "street": "123 Main", ... }
Benefits:
// Query user + their orders in ONE request!
const result = await dynamodb.query({
TableName: 'ApplicationData',
KeyConditionExpression: 'PK = :userId',
ExpressionAttributeValues: { ':userId': 'USER#12345' }
}).promise()
// Returns:
// - User metadata (SK = "METADATA")
// - All user's orders (SK = "ORDER#...")
// - All user's addresses (SK = "ADDRESS#...")
// ONE QUERY! Blazingly fast! π
Why single-table is better:
- β 1 table = 1Γ provisioned capacity cost
- β Fetch related data in 1 query (not 5!)
- β Atomic transactions across entity types
- β Easier capacity planning
- β Lower latency (1 request instead of many)
Cost comparison (10 RCU, 5 WCU per table):
5 separate tables:
- Provisioned capacity: 5 tables Γ (10 RCU + 5 WCU)
- Cost: 5 Γ (10 Γ $0.00013/hour + 5 Γ $0.00065/hour) Γ 730 hours
- Cost: 5 Γ $3.32 = $16.60/month (just for capacity!)
1 single table:
- Provisioned capacity: 1 table Γ (10 RCU + 5 WCU)
- Cost: 1 Γ $3.32 = $3.32/month
- **Savings: $13.28/month (80% cheaper!)** π°
The mental shift:
SQL thinking: "One table per entity type"
DynamoDB thinking: "One table per application/service"
SQL: Normalize everything
DynamoDB: Denormalize strategically
SQL: JOIN tables at query time
DynamoDB: Pre-join data at write time
DynamoDB Mistake #4: Not Using BatchWrite/BatchGet π¦
The slow way (one at a time):
// SLOW: 100 separate write requests
for (const item of items) {
await dynamodb.put({
TableName: 'Users',
Item: item
}).promise()
}
// Time: 100 requests Γ 50ms = 5 seconds! π
// Cost: 100 WCUs
The fast way (batches):
// FAST: Batch writes (25 items per batch)
const batches = chunk(items, 25) // Split into batches of 25
for (const batch of batches) {
await dynamodb.batchWrite({
RequestItems: {
'Users': batch.map(item => ({
PutRequest: { Item: item }
}))
}
}).promise()
}
// Time: 4 batches Γ 50ms = 200ms! β‘
// Cost: 100 WCUs (same cost, 25Γ faster!)
BatchGet example:
// Get multiple items at once
const userIds = ['USER#1', 'USER#2', 'USER#3', 'USER#4', 'USER#5']
const result = await dynamodb.batchGet({
RequestItems: {
'ApplicationData': {
Keys: userIds.map(id => ({
PK: id,
SK: 'METADATA'
}))
}
}
}).promise()
// Returns all 5 users in ONE request!
// Instead of 5 separate GetItem calls
Handle unprocessed items:
// BatchWrite can return unprocessed items
async function batchWriteWithRetry(items) {
let unprocessed = items
while (unprocessed.length > 0) {
const batches = chunk(unprocessed, 25)
for (const batch of batches) {
const result = await dynamodb.batchWrite({
RequestItems: {
'Users': batch.map(item => ({
PutRequest: { Item: item }
}))
}
}).promise()
// Retry unprocessed items
if (result.UnprocessedItems?.Users) {
unprocessed = result.UnprocessedItems.Users.map(r => r.PutRequest.Item)
await sleep(100) // Exponential backoff in production!
} else {
unprocessed = []
}
}
}
}
Performance comparison:
Writing 1000 items:
Individual PutItem:
- 1000 requests
- Time: ~50 seconds
- Network roundtrips: 1000
BatchWrite (25 per batch):
- 40 batches
- Time: ~2 seconds (25Γ faster!)
- Network roundtrips: 40
A production pattern that saved us: ALWAYS batch reads/writes when possible. Cut our API latency from 3s to 200ms! π―
DynamoDB Mistake #5: Provisioned Capacity Without Auto-Scaling β‘
The disaster scenario:
// Set fixed capacity
await dynamodb.updateTable({
TableName: 'Users',
ProvisionedThroughput: {
ReadCapacityUnits: 10,
WriteCapacityUnits: 5
}
}).promise()
// Traffic spike happens (Black Friday, Reddit hug, etc.)
// Requests: 1000 RCU needed
// Provisioned: 10 RCU
// Result: 990 requests THROTTLED! π±
// Users see: "Service Unavailable" errors
Fix #1: Enable Auto-Scaling
// Enable auto-scaling (automatically adjusts capacity)
await applicationAutoScaling.registerScalableTarget({
ServiceNamespace: 'dynamodb',
ResourceId: 'table/Users',
ScalableDimension: 'dynamodb:table:ReadCapacityUnits',
MinCapacity: 5,
MaxCapacity: 1000
}).promise()
await applicationAutoScaling.putScalingPolicy({
PolicyName: 'UsersReadScaling',
ServiceNamespace: 'dynamodb',
ResourceId: 'table/Users',
ScalableDimension: 'dynamodb:table:ReadCapacityUnits',
PolicyType: 'TargetTrackingScaling',
TargetTrackingScalingPolicyConfiguration: {
TargetValue: 70.0, // Scale when utilization hits 70%
PredefinedMetricSpecification: {
PredefinedMetricType: 'DynamoDBReadCapacityUtilization'
}
}
}).promise()
// Now scales automatically from 5 to 1000 RCU as needed! π
Fix #2: Use On-Demand Pricing
// Even better for unpredictable traffic
await dynamodb.updateTable({
TableName: 'Users',
BillingMode: 'PAY_PER_REQUEST' // Pay only for what you use!
}).promise()
// Benefits:
// - No capacity planning needed
// - Auto-scales to ANY load
// - No throttling (up to 40K RCU/WCU per second!)
// - Pay per request ($1.25 per million reads)
Cost comparison (variable traffic):
Scenario: 100K reads/day normally, 1M reads on spike days
Provisioned (no auto-scaling):
- Daily capacity: 100K / 86400 = 1.2 RCU average
- Provisioned: 5 RCU (minimum)
- Cost: 5 RCU Γ $0.00013/hour Γ 730 hours = $0.47/month
- But... spike days = THROTTLING! Users angry! π‘
Provisioned (with auto-scaling):
- Min capacity: 5 RCU
- Max capacity: 50 RCU (for spikes)
- Average cost: $0.47-$4.70/month
- Handles spikes gracefully! β
On-Demand:
- 100K reads Γ 30 days = 3M reads/month
- Cost: 3M / 1M Γ $0.25 = $0.75/month
- Spike days: Automatically handles 1M reads
- No throttling, no planning! π
When to use what:
Provisioned + Auto-Scaling:
β
Predictable traffic
β
High sustained load
β
Want to optimize costs
On-Demand:
β
Unpredictable traffic
β
New application (unknown usage)
β
Spiky workloads
β
Development/test environments
DynamoDB Mistake #6: Ignoring Item Size Limits π
The hard limits:
// DynamoDB item limits:
// - Max item size: 400 KB
// - Max partition key size: 2048 bytes
// - Max sort key size: 1024 bytes
// BAD: Storing large objects directly
const user = {
PK: 'USER#12345',
SK: 'METADATA',
profileImage: '<base64 encoded 2MB image>', // ERROR! Too big!
orderHistory: [ /* 10,000 orders */ ] // ERROR! Too big!
}
await dynamodb.put({
TableName: 'Users',
Item: user
}).promise()
// Throws: ValidationException: Item size has exceeded the maximum allowed size
The fix - Use S3 for large data:
// GOOD: Store large files in S3, reference in DynamoDB
const s3 = new AWS.S3()
const dynamodb = new AWS.DynamoDB.DocumentClient()
// Upload image to S3
const imageKey = `users/${userId}/profile.jpg`
await s3.putObject({
Bucket: 'user-uploads',
Key: imageKey,
Body: imageBuffer
}).promise()
// Store S3 reference in DynamoDB
await dynamodb.put({
TableName: 'Users',
Item: {
PK: `USER#${userId}`,
SK: 'METADATA',
name: 'John Doe',
profileImageUrl: `s3://user-uploads/${imageKey}`, // Just a reference!
profileImageSize: imageBuffer.length
}
}).promise()
// Later, retrieve image from S3
const imageUrl = await s3.getSignedUrl('getObject', {
Bucket: 'user-uploads',
Key: user.profileImageUrl.replace('s3://user-uploads/', ''),
Expires: 3600 // 1 hour
})
For arrays that grow - Paginate:
// BAD: Storing all orders in one item
{
"PK": "USER#12345",
"SK": "ORDERS",
"orders": [ /* thousands of orders, hits 400KB limit! */ ]
}
// GOOD: Store orders as separate items
// Order 1:
{ "PK": "USER#12345", "SK": "ORDER#2024-01-01#001", "total": 99.99, ... }
// Order 2:
{ "PK": "USER#12345", "SK": "ORDER#2024-01-02#002", "total": 49.99, ... }
// ...
// Query orders with pagination
const result = await dynamodb.query({
TableName: 'ApplicationData',
KeyConditionExpression: 'PK = :userId AND begins_with(SK, :prefix)',
ExpressionAttributeValues: {
':userId': 'USER#12345',
':prefix': 'ORDER#'
},
Limit: 20, // Page size
ExclusiveStartKey: lastEvaluatedKey // For pagination
}).promise()
Item size monitoring:
// Check item size before writing
function getItemSize(item) {
const json = JSON.stringify(item)
return new TextEncoder().encode(json).length
}
const itemSize = getItemSize(user)
if (itemSize > 400000) {
console.error(`Item too large: ${itemSize} bytes`)
// Move large attributes to S3
}
DynamoDB Mistake #7: Not Using Time-To-Live (TTL) β°
The problem:
// Storing session data, temporary tokens, cache entries...
// They stay FOREVER, costing storage money!
// 1 million sessions Γ 1KB each = 1GB storage
// Cost: 1GB Γ $0.25/GB = $0.25/month (forever!)
// After 1 year: 12GB Γ $0.25 = $3/month (for deleted sessions!)
The fix - Enable TTL:
// Enable TTL on a table
await dynamodb.updateTimeToLive({
TableName: 'Sessions',
TimeToLiveSpecification: {
Enabled: true,
AttributeName: 'expiresAt' // Unix timestamp
}
}).promise()
// Store items with expiration
const expiresAt = Math.floor(Date.now() / 1000) + (24 * 60 * 60) // 24 hours
await dynamodb.put({
TableName: 'Sessions',
Item: {
PK: `SESSION#${sessionId}`,
SK: 'METADATA',
userId: '12345',
token: 'abc123',
expiresAt: expiresAt // DynamoDB auto-deletes after this timestamp!
}
}).promise()
// DynamoDB automatically deletes expired items (within 48 hours)
// No cost for deletes! Free cleanup! π
Use cases for TTL:
// Session tokens (expire after 24 hours)
{ expiresAt: now + 86400 }
// Temporary files (expire after 7 days)
{ expiresAt: now + 604800 }
// Cache entries (expire after 1 hour)
{ expiresAt: now + 3600 }
// Password reset tokens (expire after 15 minutes)
{ expiresAt: now + 900 }
// Verification codes (expire after 10 minutes)
{ expiresAt: now + 600 }
Savings from TTL:
Without TTL (manual cleanup):
- 1M sessions/month created
- Average session lifetime: 1 day
- Storage accumulates: 1GB/month
- After 1 year: 12GB Γ $0.25 = $3/month
- Lambda cleanup function: $5/month
- Total: $8/month
With TTL (automatic cleanup):
- 1M sessions/month created
- Auto-deleted after expiration
- Steady-state storage: ~1GB (rolling 24 hours)
- Cost: 1GB Γ $0.25 = $0.25/month
- No Lambda needed!
- **Savings: $7.75/month** π°
The DynamoDB Cost Optimization Playbook π°
Here's how I reduced our DynamoDB bill from $847/month to $47/month:
1. Switch to On-Demand (If Traffic is Variable)
// Stop paying for unused capacity
await dynamodb.updateTable({
TableName: 'Users',
BillingMode: 'PAY_PER_REQUEST'
}).promise()
// Savings: $400/month β $50/month for our workload! π
2. Remove Unused GSIs
// Audit GSI usage with CloudWatch
// Delete unused indexes
await dynamodb.updateTable({
TableName: 'Users',
GlobalSecondaryIndexUpdates: [{
Delete: { IndexName: 'UnusedIndex' }
}]
}).promise()
// Savings: Each GSI = 2Γ storage + write costs eliminated! π°
3. Use Projection Type Wisely
// BAD: Projecting ALL attributes (full copy!)
GlobalSecondaryIndexes: [{
IndexName: 'EmailIndex',
Projection: { ProjectionType: 'ALL' } // Copies EVERYTHING!
}]
// GOOD: Project only keys (minimal copy)
GlobalSecondaryIndexes: [{
IndexName: 'EmailIndex',
Projection: { ProjectionType: 'KEYS_ONLY' } // Just keys!
}]
// BETTER: Project specific attributes needed
GlobalSecondaryIndexes: [{
IndexName: 'EmailIndex',
Projection: {
ProjectionType: 'INCLUDE',
NonKeyAttributes: ['name', 'email'] // Only these attributes
}
}]
// Storage savings: ALL (100%) vs KEYS_ONLY (~10%) vs INCLUDE (~30%)
4. Compress Large Attributes
const zlib = require('zlib')
// Store compressed data
const largeData = JSON.stringify(/* big object */)
const compressed = zlib.gzipSync(largeData).toString('base64')
await dynamodb.put({
TableName: 'Data',
Item: {
PK: 'DATA#123',
SK: 'METADATA',
data: compressed, // Compressed! 70% smaller!
isCompressed: true
}
}).promise()
// Read and decompress
const item = await dynamodb.get({ /* ... */ }).promise()
if (item.Item.isCompressed) {
const decompressed = zlib.gunzipSync(
Buffer.from(item.Item.data, 'base64')
).toString()
const data = JSON.parse(decompressed)
}
// Savings: 70% smaller items = 70% lower storage + read costs! π―
5. Use PartiQL for Complex Queries
// Instead of complex FilterExpressions
const result = await dynamodb.executeStatement({
Statement: `
SELECT * FROM Users
WHERE PK = 'USER#12345'
AND begins_with(SK, 'ORDER#')
AND total > 100
`
}).promise()
// Cleaner syntax, same performance!
6. Monitor with CloudWatch Alarms
// Set up billing alerts
await cloudwatch.putMetricAlarm({
AlarmName: 'DynamoDB-High-Cost',
MetricName: 'ConsumedReadCapacityUnits',
Namespace: 'AWS/DynamoDB',
Statistic: 'Sum',
Period: 300,
EvaluationPeriods: 1,
Threshold: 100000, // Alert if >100K RCUs in 5 min
ComparisonOperator: 'GreaterThanThreshold',
Dimensions: [{
Name: 'TableName',
Value: 'Users'
}]
}).promise()
// Get notified BEFORE your bill explodes! π¨
The Bottom Line π‘
DynamoDB is incredible - when you use it correctly!
The essentials:
- Design for access patterns (not flexibility!)
- Use single-table design (one table per service)
- Query, don't Scan (design proper keys)
- Batch operations (25Γ faster!)
- Use On-Demand (for variable traffic)
- Minimize GSIs (each one costs $$)
- Enable TTL (automatic cleanup)
- Store large data in S3 (not DynamoDB)
The truth about DynamoDB:
It's not "just another database" - it's a completely different way of thinking! SQL habits will HURT you. Design for your queries upfront. Denormalize strategically. One table to rule them all!
When architecting our serverless backend, I learned: DynamoDB is dirt cheap when designed right, prohibitively expensive when designed wrong. Stop treating it like Postgres. Embrace single-table design. Use composite keys. Batch everything. And for the love of all that is holy, NEVER use Scan in production! π
You don't need perfect DynamoDB design from day one - you need EFFICIENT access patterns that scale! π
Your Action Plan π―
This week:
- Audit existing DynamoDB tables (check for unused GSIs!)
- Switch to On-Demand if traffic is variable
- Enable TTL on appropriate tables
- Review access patterns (are you Scanning?)
This month:
- Migrate to single-table design
- Remove ALL unnecessary GSIs
- Implement batch operations
- Use S3 for large data
This quarter:
- Optimize item sizes (compress large attributes)
- Set up cost monitoring alarms
- Test with realistic load
- Become the DynamoDB guru on your team! π
Resources Worth Your Time π
Tools I use daily:
- NoSQL Workbench - Visual data modeling
- DynamoDB Toolbox - Better DynamoDB SDK
- AWS Cost Explorer - Track DynamoDB costs
Reading list:
- The DynamoDB Book - Alex DeBrie (best resource!)
- Single-Table Design - Game changer!
- AWS DynamoDB Best Practices
Real talk: The best DynamoDB strategy is thinking VERY differently from SQL. Read Alex DeBrie's book - it'll save you thousands in AWS bills!
Still treating DynamoDB like MySQL? Connect with me on LinkedIn and share your NoSQL war stories!
Want to see my DynamoDB schemas? Check out my GitHub - production single-table designs!
Now go forth and model your data properly! ποΈβ¨
P.S. If you have more than 5 GSIs, you're probably doing it wrong. Review your access patterns and redesign for single-table! Your wallet will thank you! πΈ
P.P.S. I once had a table with 18 GSIs. The monthly bill was $1,200. After redesigning to single-table with 2 GSIs? $65/month. Learn from my expensive mistakes - design for access patterns FIRST! π―