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 PatternOperation
1Get tenant by IDGetItem
2Get user by IDGetItem
3List all users in a tenantQuery
4Get user by email (login)Query (GSI)
5Get project by IDGetItem
6List all projects in a tenantQuery
7List projects by user (creator)Query (GSI)
8List recent projects in a tenantQuery (sorted by date)
9Get tenant subscription/billing infoGetItem (same partition as tenant)
10List 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
Single-Table Pattern
SaaS Multi-Tenant
Tenant isolation via composite keys · GSI-based cross-tenant queries
singletable.dev
1 : N
1 : N
1 : 1
Tenant
GSI3
PK TENANT#<tenantId>
SK #METADATA
· name, plan, createdAt
User
GSI1
PK TENANT#<tenantId>
SK USER#<userId>
· email, name, role
Project
GSI2
PK TENANT#<tenantId>
SK PROJECT#<createdAt>#<projectId>
· title, status, createdBy
Subscription
PK TENANT#<tenantId>
SK #SUBSCRIPTION
· plan, seats, stripeId
Access Patterns
Get tenant by ID
Get user by ID
List users in tenant
Get user by email (GSI1)
Get project by ID
List projects in tenant
Projects by creator (GSI2)
Recent projects (sorted)
Get subscription info
List all tenants (GSI3)

Stay in the loop

DynamoDB patterns, single-table tips, and product updates. No spam.

Table Design

Primary Key Structure

EntityPKSKPurpose
TenantTENANT#<tenantId>#METADATATenant base record
SubscriptionTENANT#<tenantId>#SUBSCRIPTIONBilling info (same partition as tenant)
UserTENANT#<tenantId>USER#<userId>User within tenant
ProjectTENANT#<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 Query on TENANT#<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 Query with SK begins_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#"). The createdAt prefix in the sort key gives chronological ordering for free.
  • Getting any entity by ID (patterns #1, #2, #5) uses GetItem with the full composite key.

GSI Design

GSIPKSKPurpose
GSI1USER_EMAIL#<email>USER#<userId>Look up user by email (login flow)
GSI2USER#<userId>PROJECT#<createdAt>#<projectId>List projects by creator
GSI3TENANT_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.

DynamoDB

SaaS Multi-Tenant

Single-Table DynamoDB Pattern

Entity PK SK GSIs
Tenant TENANT#<tenantId> #METADATA
GSI3
User TENANT#<tenantId> USER#<userId>
GSI1
Project TENANT#<tenantId> PROJECT#<createdAt>#<projectId>
GSI2
Subscription TENANT#<tenantId> #SUBSCRIPTION

Sample Data

Here’s what the table looks like with real data:

pkskgsi1pkgsi1skgsi2pkgsi2skgsi3pkgsi3skEntity Data
TENANT#t_01#METADATATENANT_LISTAcme Corp#t_01{name: "Acme Corp", plan: "pro", ...}
TENANT#t_01#SUBSCRIPTION{plan: "pro", seats: 10, stripeId: "cus_...", ...}
TENANT#t_01USER#u_01USER_EMAIL#alice@acme.comUSER#u_01{name: "Alice", role: "admin", ...}
TENANT#t_01USER#u_02USER_EMAIL#bob@acme.comUSER#u_02{name: "Bob", role: "member", ...}
TENANT#t_01PROJECT#2026-02-01#p_01USER#u_01PROJECT#2026-02-01#p_01{title: "Q1 Roadmap", status: "active", ...}
TENANT#t_01PROJECT#2026-02-10#p_02USER#u_02PROJECT#2026-02-10#p_02{title: "API Redesign", status: "draft", ...}
TENANT#t_02#METADATATENANT_LISTGlobex 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:

GSIEntityPK Pattern
GSI1UserUSER_EMAIL#<email>
GSI2ProjectUSER#<createdBy>
GSI3TenantTENANT_LIST

These prefixes never collide. That means all three can share a single overloaded GSI:

Entitygsi1pkgsi1skPurpose
UserUSER_EMAIL#<email>USER#<userId>Login by email
ProjectUSER#<createdBy>PROJECT#<createdAt>#<projectId>Projects by creator
TenantTENANT_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 (ALL vs KEYS_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.

Join the waitlist →


This is pattern #1 of 10 in the SingleTable pattern library. Next up: E-Commerce Orders.

Stay in the loop

DynamoDB patterns, single-table tips, and product updates. No spam.