DynamoDB Schema Pattern: SaaS Multi-Tenant
The multi-tenant SaaS pattern is the most common — and most debated — DynamoDB schema design. Every B2B SaaS app needs tenant isolation, user management, and resource scoping. Get the key design right and everything downstream is clean. Get it wrong and you’ll be patching access patterns for months.
This pattern handles a typical SaaS app where tenants (organizations) have users, and users create resources scoped to their tenant. Think project management tools, CRM systems, analytics dashboards — anything where Org → Users → Resources is the core hierarchy.
Access Patterns
Before touching any schema design, list the access patterns. This is the step most people skip, and it’s the step that matters most.
| # | Access Pattern | Operation |
|---|---|---|
| 1 | Get tenant by ID | GetItem |
| 2 | Get user by ID | GetItem |
| 3 | List all users in a tenant | Query |
| 4 | Get user by email (login) | Query (GSI) |
| 5 | Get project by ID | GetItem |
| 6 | List all projects in a tenant | Query |
| 7 | List projects by user (creator) | Query (GSI) |
| 8 | List recent projects in a tenant | Query (sorted by date) |
| 9 | Get tenant subscription/billing info | GetItem (same partition as tenant) |
| 10 | List all tenants (admin) | Query (GSI) |
Ten access patterns. One table. Three GSIs. Let’s design it.
These three GSIs use non-colliding key prefixes, which means they can be collapsed into a single overloaded GSI. I’ll show the dedicated version first for clarity, then the optimized version below.
Entities
This schema has four entities sharing a single table:
- Tenant — the organization/account
- User — belongs to a tenant
- Project — a resource created by a user, scoped to a tenant
- Subscription — billing info, stored alongside the tenant
Stay in the loop
DynamoDB patterns, single-table tips, and product updates. No spam.
Table Design
Primary Key Structure
| Entity | PK | SK | Purpose |
|---|---|---|---|
| Tenant | TENANT#<tenantId> | #METADATA | Tenant base record |
| Subscription | TENANT#<tenantId> | #SUBSCRIPTION | Billing info (same partition as tenant) |
| User | TENANT#<tenantId> | USER#<userId> | User within tenant |
| Project | TENANT#<tenantId> | PROJECT#<createdAt>#<projectId> | Project within tenant, sorted by creation date |
Why this works:
- Tenant + Subscription share a partition. Fetching tenant info and billing is a single
QueryonTENANT#<tenantId>— no GSI needed. Access patterns #1 and #9 are covered by the same partition key. - Users are in the tenant partition. “List all users in tenant” (pattern #3) is a
Querywith SKbegins_with("USER#"). No GSI. - Projects are in the tenant partition, sorted by date. “List all projects in tenant” and “list recent projects” (patterns #6 and #8) are both served by querying the tenant partition with SK
begins_with("PROJECT#"). ThecreatedAtprefix in the sort key gives chronological ordering for free. - Getting any entity by ID (patterns #1, #2, #5) uses
GetItemwith the full composite key.
GSI Design
| GSI | PK | SK | Purpose |
|---|---|---|---|
| GSI1 | USER_EMAIL#<email> | USER#<userId> | Look up user by email (login flow) |
| GSI2 | USER#<userId> | PROJECT#<createdAt>#<projectId> | List projects by creator |
| GSI3 | TENANT_LIST | <tenantName>#<tenantId> | List all tenants (admin view) |
GSI1 covers pattern #4 — the login flow where you have an email and need the user record. This is a common pattern: use the primary table for tenant-scoped access and a GSI for cross-tenant lookups like authentication.
GSI2 covers pattern #7 — “show me my projects.” The PK is the user’s ID, and the SK sorts projects by creation date.
GSI3 covers pattern #10 — admin listing of all tenants. The static PK TENANT_LIST collects all tenants into one partition, sorted by name. This is the “list” GSI pattern — useful when you need to enumerate all items of a given entity type.
Sample Data
Here’s what the table looks like with real data:
| pk | sk | gsi1pk | gsi1sk | gsi2pk | gsi2sk | gsi3pk | gsi3sk | Entity Data |
|---|---|---|---|---|---|---|---|---|
TENANT#t_01 | #METADATA | — | — | — | — | TENANT_LIST | Acme Corp#t_01 | {name: "Acme Corp", plan: "pro", ...} |
TENANT#t_01 | #SUBSCRIPTION | — | — | — | — | — | — | {plan: "pro", seats: 10, stripeId: "cus_...", ...} |
TENANT#t_01 | USER#u_01 | USER_EMAIL#alice@acme.com | USER#u_01 | — | — | — | — | {name: "Alice", role: "admin", ...} |
TENANT#t_01 | USER#u_02 | USER_EMAIL#bob@acme.com | USER#u_02 | — | — | — | — | {name: "Bob", role: "member", ...} |
TENANT#t_01 | PROJECT#2026-02-01#p_01 | — | — | USER#u_01 | PROJECT#2026-02-01#p_01 | — | — | {title: "Q1 Roadmap", status: "active", ...} |
TENANT#t_01 | PROJECT#2026-02-10#p_02 | — | — | USER#u_02 | PROJECT#2026-02-10#p_02 | — | — | {title: "API Redesign", status: "draft", ...} |
TENANT#t_02 | #METADATA | — | — | — | — | TENANT_LIST | Globex Inc#t_02 | {name: "Globex Inc", plan: "free", ...} |
Notice how all of Acme Corp’s data — tenant metadata, subscription, users, and projects — lives in a single partition. A query on pk = TENANT#t_01 with no SK filter retrieves the entire tenant in one call. In practice you’d scope it with SK conditions, but the option to fetch everything is powerful for exports and migrations.
ElectroDB Entity Definitions
Here’s how this schema looks in ElectroDB, the library I use in production:
Tenant Entity
import { Entity } from "electrodb";
export const TenantEntity = new Entity({
model: {
entity: "tenant",
version: "1",
service: "saas",
},
attributes: {
tenantId: { type: "string", required: true },
name: { type: "string", required: true },
plan: {
type: "string",
required: true,
default: "free",
},
createdAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
readOnly: true,
},
updatedAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
set: () => new Date().toISOString(),
watch: "*",
},
},
indexes: {
primary: {
pk: {
field: "pk",
composite: ["tenantId"],
template: "TENANT#${tenantId}",
},
sk: {
field: "sk",
composite: [],
template: "#METADATA",
},
},
list: {
index: "gsi3",
pk: {
field: "gsi3pk",
composite: [],
template: "TENANT_LIST",
},
sk: {
field: "gsi3sk",
composite: ["name", "tenantId"],
template: "${name}#${tenantId}",
},
},
},
});
User Entity
export const UserEntity = new Entity({
model: {
entity: "user",
version: "1",
service: "saas",
},
attributes: {
tenantId: { type: "string", required: true },
userId: { type: "string", required: true },
email: { type: "string", required: true },
name: { type: "string", required: true },
role: {
type: "string",
required: true,
default: "member",
},
createdAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
readOnly: true,
},
updatedAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
set: () => new Date().toISOString(),
watch: "*",
},
},
indexes: {
primary: {
pk: {
field: "pk",
composite: ["tenantId"],
template: "TENANT#${tenantId}",
},
sk: {
field: "sk",
composite: ["userId"],
template: "USER#${userId}",
},
},
byEmail: {
index: "gsi1",
pk: {
field: "gsi1pk",
composite: ["email"],
template: "USER_EMAIL#${email}",
},
sk: {
field: "gsi1sk",
composite: ["userId"],
template: "USER#${userId}",
},
},
},
});
Project Entity
export const ProjectEntity = new Entity({
model: {
entity: "project",
version: "1",
service: "saas",
},
attributes: {
tenantId: { type: "string", required: true },
projectId: { type: "string", required: true },
title: { type: "string", required: true },
description: { type: "string", required: false },
status: {
type: "string",
required: true,
default: "active",
},
createdBy: { type: "string", required: true },
createdAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
readOnly: true,
},
updatedAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
set: () => new Date().toISOString(),
watch: "*",
},
},
indexes: {
primary: {
pk: {
field: "pk",
composite: ["tenantId"],
template: "TENANT#${tenantId}",
},
sk: {
field: "sk",
composite: ["createdAt", "projectId"],
template: "PROJECT#${createdAt}#${projectId}",
},
},
byCreator: {
index: "gsi2",
pk: {
field: "gsi2pk",
composite: ["createdBy"],
template: "USER#${createdBy}",
},
sk: {
field: "gsi2sk",
composite: ["createdAt", "projectId"],
template: "PROJECT#${createdAt}#${projectId}",
},
},
},
});
Subscription Entity
export const SubscriptionEntity = new Entity({
model: {
entity: "subscription",
version: "1",
service: "saas",
},
attributes: {
tenantId: { type: "string", required: true },
plan: {
type: "string",
required: true,
default: "free",
},
seats: { type: "number", required: true, default: 1 },
stripeId: { type: "string" },
billingCycle: {
type: "string",
required: true,
default: "monthly",
},
expiresAt: { type: "string" },
createdAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
readOnly: true,
},
updatedAt: {
type: "string",
required: true,
default: () => new Date().toISOString(),
set: () => new Date().toISOString(),
watch: "*",
},
},
indexes: {
primary: {
pk: {
field: "pk",
composite: ["tenantId"],
template: "TENANT#${tenantId}",
},
sk: {
field: "sk",
composite: [],
template: "#SUBSCRIPTION",
},
},
},
});
Why This Design
Trade-offs I’m making:
Tenant-scoped partitions over entity-scoped partitions. Putting all tenant data under TENANT#<id> means a single query can fetch users, projects, and metadata together. The downside: if a tenant has 100,000 projects, that partition gets hot. For most SaaS apps this isn’t an issue — DynamoDB partitions can handle 3,000 RCUs and 1,000 WCUs per second. If you’re building the next Notion, you’d want to split projects into their own partition.
Date-prefixed sort keys for projects. The SK PROJECT#<createdAt>#<projectId> gives chronological ordering naturally. If you need to sort by title or status instead, you’d need another GSI. I’m optimizing for the most common access pattern: “show me recent projects.”
Static PK for tenant listing. The TENANT_LIST GSI partition is a known “hot key” pattern — all tenants write to the same partition key. This is fine for admin views with low read volume. If you need high-throughput tenant enumeration (unlikely), use a Scan instead.
Email GSI for authentication. Cross-tenant email lookup is essential for login flows but infrequent compared to tenant-scoped queries. A dedicated GSI is the right call — it’s clean and doesn’t pollute other indexes.
What I’d add for production:
- Audit trail entity — SK pattern
AUDIT#<timestamp>#<eventId>in the tenant partition for compliance - Invitation entity — for pending team invites, with a GSI on the invited email
- API key entity — stored in the tenant partition with a cross-tenant GSI for key lookup during request authentication
Production Optimization: Overloaded GSIs
The design above uses three dedicated GSIs — one per cross-partition query. That’s deliberate for clarity. But in production, you’re paying for each GSI: every write to the base table is replicated to every GSI. Three GSIs means 3x write amplification.
Look at the GSI partition keys:
| GSI | Entity | PK Pattern |
|---|---|---|
| GSI1 | User | USER_EMAIL#<email> |
| GSI2 | Project | USER#<createdBy> |
| GSI3 | Tenant | TENANT_LIST |
These prefixes never collide. That means all three can share a single overloaded GSI:
| Entity | gsi1pk | gsi1sk | Purpose |
|---|---|---|---|
| User | USER_EMAIL#<email> | USER#<userId> | Login by email |
| Project | USER#<createdBy> | PROJECT#<createdAt>#<projectId> | Projects by creator |
| Tenant | TENANT_LIST | <name>#<tenantId> | Admin tenant listing |
One GSI. Same ten access patterns. One-third the write cost.
Here’s what changes in ElectroDB — every entity’s secondary index points to gsi1 instead of its own dedicated GSI:
// Tenant — list index moves from gsi3 to gsi1
list: {
index: "gsi1",
pk: {
field: "gsi1pk",
composite: [],
template: "TENANT_LIST",
},
sk: {
field: "gsi1sk",
composite: ["name", "tenantId"],
template: "${name}#${tenantId}",
},
},
// User — byEmail already on gsi1, no change
byEmail: {
index: "gsi1",
pk: {
field: "gsi1pk",
composite: ["email"],
template: "USER_EMAIL#${email}",
},
sk: {
field: "gsi1sk",
composite: ["userId"],
template: "USER#${userId}",
},
},
// Project — byCreator moves from gsi2 to gsi1
byCreator: {
index: "gsi1",
pk: {
field: "gsi1pk",
composite: ["createdBy"],
template: "USER#${createdBy}",
},
sk: {
field: "gsi1sk",
composite: ["createdAt", "projectId"],
template: "PROJECT#${createdAt}#${projectId}",
},
},
ElectroDB handles the overloading transparently — each entity writes its own key pattern to the shared gsi1pk/gsi1sk fields, and queries scope to the right items automatically because the PK prefixes are unique.
When to overload vs. keep separate:
- Overload when you’re optimizing for cost and your team understands the pattern. This is the standard approach for production single-table designs.
- Keep separate when you’re prototyping, when different GSIs need different projection types (
ALLvsKEYS_ONLY), or when you want to independently manage throughput per access pattern.
For this schema, I’d overload from day one. The prefixes are clean, there’s no overlap risk, and cutting from 3 GSIs to 1 saves real money at scale.
Design This Visually → Coming Soon
Imagine dragging these four entities onto a canvas, drawing the GSI connections, and seeing the sample data table update in real time. That’s what I’m building at singletable.dev.
This is pattern #1 of 10 in the SingleTable pattern library. Next up: E-Commerce Orders.