Skip to content

DynamoDB Single-Table Pattern: Booking/Scheduling

Booking systems have a constraint that most CRUD apps don’t: two people can’t book the same slot. In a relational database, you’d use a unique constraint or a SELECT FOR UPDATE. In DynamoDB, you use condition expressions - the same atomic-update technique that prevents overselling in the Inventory Management pattern - and they’re actually more reliable than SQL locking for this use case.

This pattern handles provider availability, time slot management, and atomic booking with double-booking prevention. It works for appointment scheduling, resource reservation, classroom booking, or any system where a time slot can be claimed by exactly one customer.

Access patterns

#Access PatternOperationNotes
AP1Get provider metadataGetItemProvider info, timezone
AP2List providers by specialtyQuery (GSI1)“Show me all dentists”
AP3Get available slots for a provider on a dateQueryCalendar view
AP4Book a slot (atomic, prevent double-booking)TransactWriteItemsThe critical operation
AP5Get booking detailsGetItemConfirmation page
AP6List bookings for a customerQuery (GSI1)“My appointments”
AP7Cancel a booking (release the slot)TransactWriteItemsReverse of AP4

Seven access patterns. Three entity types. One table, one GSI.

Entities

Three entity types:

  • Provider: a service provider with a schedule — dentist, therapist, room, anything bookable
  • Slot: a specific time window in a provider’s calendar, with an availability status
  • Booking: a claimed slot, with customer details and status
Booking/Scheduling entity diagram showing Provider, Slot, Booking relationships
3 entities · 1 GSI · slots pre-created, bookings atomic

Table design

Primary key structure

EntityPKSKPurpose
ProviderPROVIDER#<providerId>#METADATAProvider details
SlotPROVIDER#<providerId>SLOT#<dateTime>Time slot with availability status
BookingBOOKING#<bookingId>#METADATABooking record

Slots are pre-created. When a provider sets their availability for a week, you batch-write Slot items with status: "available". Each slot has a specific date-time and duration. This front-loads the work of determining availability; querying open slots is just a filter on status.

Slots live in the provider partition, so “Show me available slots for Dr. Smith on April 27” is a single Query with a date-range filter on the SK.

Bookings have their own partition, which enables direct lookup by booking ID (confirmation pages, customer service) and a customer GSI for “my appointments.”

Booking/Scheduling DynamoDB schema: Entity, PK, SK, GSI columns for all three entities
PK/SK structure for Provider, Slot, and Booking — slots in the provider partition, bookings in their own

The booking transaction: no double-booking

This is the core of the pattern. Booking a slot is a TransactWriteItems with two operations:

import { ulid } from 'ulid';

async function bookSlot(providerId: string, dateTime: string, customerId: string) {
  const bookingId = ulid();
  
  await client.transactWrite({
    TransactItems: [
      {
        // Update the slot: available → booked
        Update: {
          TableName: TABLE_NAME,
          Key: { pk: `PROVIDER#${providerId}`, sk: `SLOT#${dateTime}` },
          UpdateExpression: "SET #status = :booked, bookingId = :bid",
          // THIS IS THE GUARD: only works if slot is currently available
          ConditionExpression: "#status = :available",
          ExpressionAttributeNames: { "#status": "status" },
          ExpressionAttributeValues: {
            ":booked": "booked",
            ":available": "available",
            ":bid": bookingId,
          },
        },
      },
      {
        // Create the booking record
        Put: {
          TableName: TABLE_NAME,
          Item: {
            pk: `BOOKING#${bookingId}`,
            sk: "#METADATA",
            gsi1pk: `CUSTOMER#${customerId}`,
            gsi1sk: `BOOKING#${bookingId}`,
            customerId, providerId, dateTime,
            status: "confirmed",
            duration: 30,
            createdAt: new Date().toISOString(),
          },
          // Guard: ensure booking doesn't already exist
          ConditionExpression: "attribute_not_exists(pk)",
        },
      },
    ],
  });
  
  return bookingId;
}

If two customers try to book the same slot simultaneously, exactly one transaction succeeds. The other fails with a TransactionCanceledException because the condition expression #status = :available is no longer true. No race condition. No distributed locks.

Cancellation

Cancellation is the reverse transaction:

async function cancelBooking(bookingId: string, providerId: string, dateTime: string) {
  await client.transactWrite({
    TransactItems: [
      {
        // Release the slot: booked → available
        Update: {
          TableName: TABLE_NAME,
          Key: { pk: `PROVIDER#${providerId}`, sk: `SLOT#${dateTime}` },
          UpdateExpression: "SET #status = :available REMOVE bookingId",
          ConditionExpression: "#status = :booked AND bookingId = :bid",
          ExpressionAttributeNames: { "#status": "status" },
          ExpressionAttributeValues: { ":booked": "booked", ":available": "available", ":bid": bookingId },
        },
      },
      {
        // Update booking status
        Update: {
          TableName: TABLE_NAME,
          Key: { pk: `BOOKING#${bookingId}`, sk: "#METADATA" },
          UpdateExpression: "SET #status = :cancelled",
          ExpressionAttributeNames: { "#status": "status" },
          ExpressionAttributeValues: { ":cancelled": "cancelled" },
        },
      },
    ],
  });
}

Sample data

pkskgsi1pkgsi1skEntity Data
PROVIDER#dr_smith#METADATASPECIALTY#dentistPROVIDER#dr_smith{ name: "Dr. Smith", timezone: "America/New_York" }
PROVIDER#dr_smithSLOT#2026-04-27T09:00:00Z--{ status: "booked", duration: 30, bookingId: "01HW..." }
PROVIDER#dr_smithSLOT#2026-04-27T09:30:00Z--{ status: "available", duration: 30 }
PROVIDER#dr_smithSLOT#2026-04-27T10:00:00Z--{ status: "blocked", duration: 30 }
BOOKING#01HW...#METADATACUSTOMER#cust_01BOOKING#01HW...{ customerId: "cust_01", providerId: "dr_smith", dateTime: "2026-04-27T09:00:00Z", status: "confirmed" }

ElectroDB entity definitions

export const ProviderEntity = new Entity({
  model: { entity: "provider", version: "1", service: "booking" },
  attributes: {
    providerId: { type: "string", required: true },
    name:       { type: "string", required: true },
    specialty:  { type: "string" },
    active:     { type: "boolean", required: true, default: true },
    timezone:   { type: "string", required: true, default: "UTC" },
    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: ["providerId"], template: "PROVIDER#${providerId}" },
      sk: { field: "sk", composite: [],              template: "#METADATA" },
    },
    bySpecialty: {
      index: "GSI1",
      pk: { field: "gsi1pk", composite: ["specialty"], template: "SPECIALTY#${specialty}" },
      sk: { field: "gsi1sk", composite: ["providerId"], template: "PROVIDER#${providerId}" },
    },
  },
}, { client, table });

Why this design

Pre-creating slots adds a batch write step when providers set their availability. The payoff: checking availability is a simple Query + filter, and the booking transaction is clean. Creating slots on demand instead requires checking for conflicts during the booking write, which is more complex and error-prone.

Condition expressions are the double-booking guard. This is more reliable than a read-then-write pattern. DynamoDB evaluates the condition atomically at write time, with no window for a race condition between the read and the write. The same mechanism drives atomic state transitions in the Workflow/State Machine pattern.

Bookings are separate from slots. The slot tracks availability state in the provider’s partition. The booking tracks the customer’s appointment details in its own partition. They reference each other by ID. This avoids forcing every booking query through the provider partition. Booking IDs are ULIDs for the same reasons as elsewhere in this library: time ordering within the customer’s GSI partition plus direct lookup by ID.

The specialty GSI will be fine for most apps. If you have 50 dentists, SPECIALTY#dentist is a reasonable partition size. At 10,000+ providers per specialty, consider adding a location component to the GSI PK: SPECIALTY#dentist#NYC.

Design this visually → coming soon

The booking transaction - with its condition on the slot status - is the heart of this pattern. On a canvas where you can see the slot flip from available to booked and the booking record appear simultaneously, the atomic guarantee becomes concrete. That’s what I’m building at singletable.dev.

Join the waitlist →


Pattern #6 of 10 in the SingleTable pattern library. The condition expression technique for preventing double-booking is one of DynamoDB’s most powerful features for transactional use cases. I’m building singletable.dev to make these patterns visual.

Tejovanth N

These patterns come from real apps - rasika.life, rekha.app, rrmstays - all running single-table DynamoDB with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.