+1 (726) 207-9872

DynamoDB Single-Table Design for Serverless Framework V4: Access Patterns, GSIs, and Streams

Most serverless teams get Lambda right and DynamoDB wrong. The functions are small, the IAM is tight, the deploys are clean — and then the API has eleven tables, three of them scanned on every request, and a "quick report" endpoint that times out at 29 seconds. None of our other tutorials cover the data layer directly, so this one does: how to model a DynamoDB table for a serverless API, how to express it in Serverless Framework V4, and how to keep it fast as access patterns pile up.

This is deliberately practical. It is the modelling exercise we run in the first week of most serverless architecture engagements.

Start from access patterns, not entities

Relational modelling starts with entities and normalizes; DynamoDB modelling starts with the queries your application makes and works backwards. Write them down before you touch YAML. For a small B2B SaaS, the list usually looks like this:

#Access patternFrequency
1Get one organisation by idhigh
2Get one user by idhigh
3List all users in an organisationhigh
4Look up a user by email (login)medium
5List an organisation's projects, newest firsthigh
6List a project's events in a time rangehigh
7List all projects across orgs by status (admin)low

Seven patterns. Each one must be satisfiable by a GetItem or a Query on a key — never a Scan. If a pattern can only be served by a scan, that is a modelling bug, not a capacity problem.

One table, generic keys

Single-table design puts several entity types in one table with deliberately meaningless key names — PK and SK — so different items can use them differently. The partition key groups items that are read together; the sort key orders and filters within the group.

EntityPKSKOther attributes
OrganisationORG#<orgId>ORG#<orgId>name, plan, createdAt
UserORG#<orgId>USER#<userId>email, role, name
ProjectORG#<orgId>PROJECT#<createdAt>#<projectId>name, status
EventPROJECT#<projectId>EVENT#<timestamp>#<ulid>type, payload

With that layout:

  • Pattern 1 is GetItem on PK = ORG#123, SK = ORG#123.
  • Pattern 3 is Query PK = ORG#123 AND begins_with(SK, "USER#").
  • Pattern 5 is Query PK = ORG#123 AND begins_with(SK, "PROJECT#") with ScanIndexForward: false — the createdAt prefix in the sort key means "newest first" is free.
  • Pattern 6 is Query PK = PROJECT#abc AND SK BETWEEN "EVENT#2026-01-01" AND "EVENT#2026-02-01" — a timestamp prefix gives you range queries with no index.

Two patterns are left over (4 and 7). Those are what secondary indexes are for.

Note the sort keys are sortable strings. Use ISO-8601 timestamps (2026-03-14T09:00:00Z), zero-padded numbers, and ULIDs or KSUIDs rather than UUID v4 — a UUID v4 suffix adds uniqueness but destroys ordering if you put it first.

Secondary indexes, sparsely

A Global Secondary Index (GSI) is a second view of the table with its own keys. Two rules keep them cheap:

  1. Project only what you need. KEYS_ONLY or INCLUDE cost far less in storage and write throughput than ALL.
  2. Make them sparse. An item only appears in a GSI if it has both index key attributes. Write the GSI key attributes on only the entities the index serves, and the index stays small.

For pattern 4, write GSI1PK = EMAIL#<lowercased email> on user items only. For pattern 7, write GSI2PK = STATUS#<status> and GSI2SK = <createdAt> on project items only. Organisations and events carry neither attribute, so they never enter either index.

The Serverless Framework V4 resource

One table, two sparse GSIs, on-demand billing, streams on, point-in-time recovery on:

service: saas-api

provider:
  name: aws
  runtime: nodejs22.x
  stage: ${opt:stage, 'dev'}
  environment:
    TABLE_NAME: !Ref AppTable
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:GetItem
            - dynamodb:PutItem
            - dynamodb:UpdateItem
            - dynamodb:DeleteItem
            - dynamodb:Query
            - dynamodb:BatchGetItem
            - dynamodb:TransactWriteItems
          Resource:
            - !GetAtt AppTable.Arn
            - !Sub '${AppTable.Arn}/index/*'

resources:
  Resources:
    AppTable:
      Type: AWS::DynamoDB::Table
      DeletionPolicy: Retain
      UpdateReplacePolicy: Retain
      Properties:
        TableName: ${self:service}-${sls:stage}
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - { AttributeName: PK,      AttributeType: S }
          - { AttributeName: SK,      AttributeType: S }
          - { AttributeName: GSI1PK,  AttributeType: S }
          - { AttributeName: GSI2PK,  AttributeType: S }
          - { AttributeName: GSI2SK,  AttributeType: S }
        KeySchema:
          - { AttributeName: PK, KeyType: HASH }
          - { AttributeName: SK, KeyType: RANGE }
        GlobalSecondaryIndexes:
          - IndexName: GSI1
            KeySchema:
              - { AttributeName: GSI1PK, KeyType: HASH }
            Projection: { ProjectionType: KEYS_ONLY }
          - IndexName: GSI2
            KeySchema:
              - { AttributeName: GSI2PK, KeyType: HASH }
              - { AttributeName: GSI2SK, KeyType: RANGE }
            Projection:
              ProjectionType: INCLUDE
              NonKeyAttributes: [name, status]
        StreamSpecification:
          StreamViewType: NEW_AND_OLD_IMAGES
        PointInTimeRecoverySpecification:
          PointInTimeRecoveryEnabled: true
        SSESpecification:
          SSEEnabled: true
        TimeToLiveSpecification:
          AttributeName: expiresAt
          Enabled: true

Three details that matter more than they look:

  • DeletionPolicy: Retain. Without it, serverless remove — or a failed stack update that rolls back a replacement — deletes your production data. Note that this means the table survives stack deletion and you must clean it up by hand.
  • Never let the table name be auto-generated. A logical-ID rename or a property that forces replacement will silently create a new empty table. Naming it ${self:service}-${sls:stage} ties it to the stage and makes accidental replacement fail loudly instead.
  • PAY_PER_REQUEST first. On-demand costs more per request than well-utilised provisioned capacity but nothing when idle, and it absorbs spikes without throttling. Move to provisioned with auto-scaling only once you have a month of steady traffic to size against.

Access code: one module, typed keys

Keep every key string in one place. Handlers should never concatenate ORG# themselves.

// src/lib/keys.js
export const keys = {
  org:     (orgId) => ({ PK: `ORG#${orgId}`, SK: `ORG#${orgId}` }),
  user:    (orgId, userId) => ({ PK: `ORG#${orgId}`, SK: `USER#${userId}` }),
  project: (orgId, createdAt, projectId) => ({
    PK: `ORG#${orgId}`,
    SK: `PROJECT#${createdAt}#${projectId}`,
  }),
  event:   (projectId, ts, id) => ({
    PK: `PROJECT#${projectId}`,
    SK: `EVENT#${ts}#${id}`,
  }),
};
// src/lib/repo.js
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
import { keys } from './keys.js';

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}), {
  marshallOptions: { removeUndefinedValues: true },
});
const TableName = process.env.TABLE_NAME;

export async function listProjects(orgId, { limit = 25, cursor } = {}) {
  const res = await ddb.send(new QueryCommand({
    TableName,
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
    ExpressionAttributeValues: { ':pk': `ORG#${orgId}`, ':sk': 'PROJECT#' },
    ScanIndexForward: false,
    Limit: limit,
    ExclusiveStartKey: cursor ? JSON.parse(Buffer.from(cursor, 'base64')) : undefined,
  }));
  return {
    items: res.Items ?? [],
    cursor: res.LastEvaluatedKey
      ? Buffer.from(JSON.stringify(res.LastEvaluatedKey)).toString('base64')
      : null,
  };
}

Two things to copy from that snippet: the client is created at module scope so it is reused across warm invocations, and pagination is an opaque base64 cursor rather than an offset. DynamoDB has no offsets, and exposing LastEvaluatedKey raw leaks your key design to clients.

Uniqueness and multi-item writes

"Email must be unique" has no equivalent of a unique constraint. Model it as an extra item and write both atomically:

import { TransactWriteCommand } from '@aws-sdk/lib-dynamodb';

await ddb.send(new TransactWriteCommand({
  TransactItems: [
    { Put: {
        TableName,
        Item: { ...keys.user(orgId, userId), email, role, GSI1PK: `EMAIL#${email}` },
        ConditionExpression: 'attribute_not_exists(PK)',
    }},
    { Put: {
        TableName,
        Item: { PK: `EMAIL#${email}`, SK: `EMAIL#${email}`, userId, orgId },
        ConditionExpression: 'attribute_not_exists(PK)',
    }},
  ],
}));

If either condition fails the whole transaction fails with TransactionCanceledException; inspect CancellationReasons to tell the caller which one. Transactions cost twice the write units of the same writes done separately, so use them for correctness, not convenience. For everything else, ConditionExpression on a single UpdateItem is the cheap optimistic-locking tool: ConditionExpression: 'version = :expected'.

Streams for derived data

The stream you enabled above is how you keep aggregates and search indexes correct without dual writes in the handler. A single stream consumer, filtered so it is only invoked for the records it cares about:

functions:
  onProjectChange:
    handler: src/streams/projects.handler
    events:
      - stream:
          type: dynamodb
          arn: !GetAtt AppTable.StreamArn
          startingPosition: LATEST
          batchSize: 100
          maximumRetryAttempts: 5
          functionResponseType: ReportBatchItemFailures
          destinations:
            onFailure:
              arn: !GetAtt StreamDlq.Arn
              type: sqs
          filterPatterns:
            - eventName: [INSERT, MODIFY]
              dynamodb:
                Keys:
                  SK:
                    S: [{ prefix: 'PROJECT#' }]

filterPatterns is the important line: without it, every event item write invokes this function too, and you pay for and debug invocations that do nothing. ReportBatchItemFailures plus a per-record try/catch means one poison record does not retry the whole batch — pair it with the SQS dead-letter destination and an alarm on queue depth. Handlers must also be idempotent, because streams give at-least-once delivery.

What we check in review

  • No Scan in application code. Ever. (aws dynamodb describe-table plus a grep for ScanCommand is a five-minute audit.)
  • No FilterExpression doing work a sort key should do — filters are applied after the read, so you pay for the rows you throw away.
  • Every list endpoint paginates and caps Limit.
  • Hot partitions: no PK that all writes share, such as TENANT#global or a date bucket that every item of a busy day lands in.
  • Item size: under 4 KB where possible; a 1 KB read consumes a quarter of the units a 4 KB read does. Large blobs go to S3 with the key in the item.
  • DeletionPolicy: Retain, PITR on, SSE on, TTL set for anything expiring.
  • IAM scoped to the one table ARN plus /index/*, not dynamodb:* on *.

When single-table is the wrong answer

Single-table design trades flexibility for latency and cost. It is the right default for known, high-volume access patterns. It is the wrong answer when analysts need ad-hoc queries (stream to S3 and query with Athena, or use a small Aurora Serverless v2 cluster), when your access patterns genuinely change every sprint, or when the team has no one who will maintain the key discipline. A second table for a genuinely unrelated bounded context is fine too — "single table" is about not having one table per entity, not about a religious commitment to the number one.

Getting the key design right early is the difference between a serverless API that stays cheap at scale and one that gets rewritten. If you would like a second pair of eyes on your access patterns and table design, get in touch — data modelling review is part of every architecture design engagement we run.