DynamoDB Single-Table Design Patterns for SaaS Applications
Quick summary: A practical guide to DynamoDB single-table design for SaaS — covering access patterns, tenant isolation, GSI strategies, and the patterns that make DynamoDB the ideal serverless database.
Key Takeaways
- A practical guide to DynamoDB single-table design for SaaS — covering access patterns, tenant isolation, GSI strategies, and the patterns that make DynamoDB the ideal serverless database
- A practical guide to DynamoDB single-table design for SaaS — covering access patterns, tenant isolation, GSI strategies, and the patterns that make DynamoDB the ideal serverless database

Table of Contents
DynamoDB is the default database for serverless applications on AWS — and for good reason. Single-digit millisecond latency at any scale, zero infrastructure management, on-demand capacity with automatic scaling, and a pricing model that starts at pennies and scales to enterprise workloads.
But DynamoDB’s design philosophy is fundamentally different from relational databases. SQL databases optimize for flexible querying over normalized data. DynamoDB optimizes for known access patterns over denormalized data. Getting this right — especially for multi-tenant SaaS applications — requires understanding single-table design.
Why Single-Table Design?
In a relational database, you create a table for each entity: users, orders, products, invoices. In DynamoDB, you can store all of these entity types in a single table using composite keys.
Why?
- DynamoDB pricing — You pay for read/write capacity per table. Multiple tables multiply your baseline costs and make capacity planning harder.
- Transaction scope — DynamoDB transactions work within a single table. Cross-table transactions require additional coordination.
- Access pattern efficiency — A single table with well-designed keys allows you to retrieve related entities in a single query instead of multiple queries across tables.
- Operational simplicity — One table to monitor, backup, and manage instead of dozens.
Key Design Concepts
Partition Key (PK) and Sort Key (SK)
Every DynamoDB item requires a partition key. Optionally, you can add a sort key. Together, they form the primary key:
- Partition key (PK) — Determines which physical partition stores the item. All items with the same PK are stored together and can be queried together.
- Sort key (SK) — Enables range queries within a partition. Items with the same PK are sorted by SK.
Example for a SaaS application:
| PK | SK | Entity | Data |
|---|---|---|---|
| TENANT#acme | PROFILE | Tenant | name, plan, created_at |
| TENANT#acme | USER#user-001 | User | email, role, last_login |
| TENANT#acme | USER#user-002 | User | email, role, last_login |
| TENANT#acme | ORDER#ord-001 | Order | total, status, created_at |
| TENANT#acme | ORDER#ord-002 | Order | total, status, created_at |
Access patterns this supports:
- Get tenant profile:
PK = TENANT#acme, SK = PROFILE - List all users for a tenant:
PK = TENANT#acme, SK begins_with USER# - List all orders for a tenant:
PK = TENANT#acme, SK begins_with ORDER# - Get all data for a tenant:
PK = TENANT#acme(returns everything)
Notice that the tenant ID is the partition key. This provides natural tenant isolation — a query for one tenant physically cannot return data from another tenant.
Global Secondary Indexes (GSIs)
GSIs provide alternative access patterns by projecting a different key structure over the same data. You can have up to 20 GSIs per table.
GSI overloading — Use generic attribute names like GSI1PK and GSI1SK so different entity types can reuse the same GSI for different access patterns:
| PK | SK | GSI1PK | GSI1SK | Entity |
|---|---|---|---|---|
| TENANT#acme | USER#user-001 | USER#user-001@acme.com | TENANT#acme | User |
| TENANT#acme | ORDER#ord-001 | STATUS#pending | ORDER#2026-04-10 | Order |
| TENANT#acme | ORDER#ord-002 | STATUS#shipped | ORDER#2026-04-08 | Order |
GSI1 access patterns:
- Look up user by email:
GSI1PK = USER#user-001@acme.com - List pending orders across a tenant:
GSI1PK = STATUS#pending, GSI1SK begins_with ORDER#
SaaS Multi-Tenant Patterns
Pattern 1: Tenant-Prefixed Partition Key
The simplest and most common pattern. Every partition key starts with the tenant ID:
PK: TENANT#{tenant_id}#ORDER
SK: {order_id}Advantages:
- Strongest isolation — queries are physically scoped to a tenant’s data
- No risk of cross-tenant data leakage
- Simple to understand and implement
Disadvantage:
- Cannot query across tenants without scanning (which is fine — cross-tenant queries should happen in an analytics pipeline, not the application)
Pattern 2: Tenant Isolation with IAM
Combine DynamoDB’s key design with IAM policies for defense-in-depth:
{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:PutItem"],
"Resource": "arn:aws:dynamodb:*:*:table/SaaSTable",
"Condition": {
"ForAllValues:StringLike": {
"dynamodb:LeadingKeys": ["TENANT#${aws:PrincipalTag/tenant_id}*"]
}
}
}This IAM policy restricts the Lambda function to only access items where the partition key starts with the tenant’s ID. Even if application code has a bug, IAM prevents cross-tenant access.
Pattern 3: Per-Tenant Tables
For enterprise SaaS with strict compliance requirements, you can create a separate DynamoDB table per tenant:
Table: saas-tenant-acme
Table: saas-tenant-globex
Table: saas-tenant-initechWhen to use: Tenants requiring dedicated encryption keys (per-tenant KMS keys), separate backup policies, or regulatory data isolation. See our SaaS multi-tenancy architecture guide for the full silo vs pool analysis.
When to avoid: More than 50 tenants. Managing hundreds of tables becomes an operational burden.
Common Access Patterns
Pattern: Hierarchical Data
SaaS applications often have hierarchical relationships: Tenant → Project → Task → Comment.
| PK | SK |
|---|---|
| TENANT#acme | PROJECT#proj-001 |
| TENANT#acme | PROJECT#proj-001#TASK#task-001 |
| TENANT#acme | PROJECT#proj-001#TASK#task-001#COMMENT#cmt-001 |
| TENANT#acme | PROJECT#proj-001#TASK#task-002 |
Queries:
- All projects for a tenant:
PK = TENANT#acme, SK begins_with PROJECT# - All tasks in a project:
PK = TENANT#acme, SK begins_with PROJECT#proj-001#TASK# - All comments on a task:
PK = TENANT#acme, SK begins_with PROJECT#proj-001#TASK#task-001#COMMENT#
Pattern: Time-Series Data
For activity logs, audit trails, or event streams:
| PK | SK |
|---|---|
| TENANT#acme#ACTIVITY#2026-04 | 2026-04-10T14:30:00Z#evt-001 |
| TENANT#acme#ACTIVITY#2026-04 | 2026-04-10T15:45:00Z#evt-002 |
| TENANT#acme#ACTIVITY#2026-03 | 2026-03-28T09:15:00Z#evt-003 |
The month is included in the partition key to prevent hot partitions. Querying a specific month is efficient; querying across months requires multiple queries (which is acceptable for time-series data).
Pattern: Inverted Index for Search
If you need to find items by attribute value (e.g., “find all users with role=admin”):
GSI with role as the key:
| GSI2PK | GSI2SK | Data |
|---|---|---|
| TENANT#acme#ROLE#admin | USER#user-001 | … |
| TENANT#acme#ROLE#member | USER#user-002 | … |
Query: GSI2PK = TENANT#acme#ROLE#admin returns all admin users for that tenant.
Cost Optimization
On-Demand vs Provisioned
| Mode | Best For | Pricing |
|---|---|---|
| On-demand | Variable traffic, new applications, development | Pay per read/write ($1.25 per million writes, $0.25 per million reads) |
| Provisioned | Steady-state traffic, predictable workloads | Per RCU/WCU per hour + auto-scaling |
| Provisioned + Reserved | Consistent high-volume production | Up to 77% discount with 1-year commitment |
For most SaaS startups: Start with on-demand mode. Switch to provisioned with auto-scaling when your traffic patterns become predictable (typically after 3-6 months of production data).
Reducing Read Costs
- Eventually consistent reads — Half the cost of strongly consistent reads. Use for data that can tolerate slight staleness (dashboards, reports, activity feeds).
- DAX (DynamoDB Accelerator) — In-memory cache for microsecond read latency. Cost-effective when your read-to-write ratio exceeds 10:1.
- Projection expressions — Only retrieve the attributes you need instead of the full item. Reduces consumed RCUs for items with many attributes.
Reducing Storage Costs
- TTL (Time to Live) — Automatically delete expired items at no cost. Essential for session data, temporary tokens, and audit logs with retention periods.
- Standard-IA table class — 60% lower storage cost for infrequently accessed data. Useful for archive or compliance tables.
For comprehensive AWS cost optimization, including DynamoDB right-sizing and capacity planning, talk to our team.
DynamoDB Streams for Event-Driven SaaS
DynamoDB Streams captures a time-ordered sequence of item-level changes:
DynamoDB Table → DynamoDB Streams → Lambda → (downstream actions)SaaS use cases:
- Audit trail — Stream all changes to an audit log table or S3 for compliance
- Real-time notifications — Trigger notifications when order status changes
- Cross-service synchronization — Replicate data to OpenSearch for full-text search or to a data lake for analytics
- Billing event capture — Track usage events for metered billing
Common Mistakes
Mistake 1: Designing for Flexibility Instead of Access Patterns
If you design your DynamoDB table like a relational database (one entity per table, normalized data, ad-hoc queries), you will fight DynamoDB’s strengths. Start with your access patterns, then design your keys to support them. If you cannot define your access patterns, DynamoDB may not be the right database for that workload.
Mistake 2: Hot Partitions
If all your queries hit the same partition key (e.g., a single tenant that represents 80% of your traffic), that partition becomes a bottleneck. Design your keys to distribute traffic. For hot tenants, consider adding a suffix to the partition key (e.g., TENANT#acme#SHARD#1, TENANT#acme#SHARD#2) and scatter-gather across shards.
Mistake 3: Scanning Instead of Querying
A Scan operation reads the entire table — expensive and slow. If you find yourself scanning, you are missing an index. Every access pattern should be a Query (O(1) partition lookup + sort key range) or GetItem (exact primary key lookup).
Mistake 4: Not Using Transactions
DynamoDB supports ACID transactions across up to 100 items in a single table. If you need to update an order and its inventory atomically, use TransactWriteItems instead of separate writes that can partially fail.
Getting Started
DynamoDB single-table design requires a mindset shift from relational databases, but the operational benefits — zero infrastructure management, predictable performance, and seamless scaling — make it the natural choice for serverless SaaS applications on AWS.
For serverless application design with DynamoDB, Lambda, and API Gateway, see our AWS Serverless Architecture Services.


