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 Pattern | Operation | Notes |
|---|---|---|---|
| AP1 | Get provider metadata | GetItem | Provider info, timezone |
| AP2 | List providers by specialty | Query (GSI1) | “Show me all dentists” |
| AP3 | Get available slots for a provider on a date | Query | Calendar view |
| AP4 | Book a slot (atomic, prevent double-booking) | TransactWriteItems | The critical operation |
| AP5 | Get booking details | GetItem | Confirmation page |
| AP6 | List bookings for a customer | Query (GSI1) | “My appointments” |
| AP7 | Cancel a booking (release the slot) | TransactWriteItems | Reverse 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

Table design
Primary key structure
| Entity | PK | SK | Purpose |
|---|---|---|---|
| Provider | PROVIDER#<providerId> | #METADATA | Provider details |
| Slot | PROVIDER#<providerId> | SLOT#<dateTime> | Time slot with availability status |
| Booking | BOOKING#<bookingId> | #METADATA | Booking 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.”

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
| pk | sk | gsi1pk | gsi1sk | Entity Data |
|---|---|---|---|---|
PROVIDER#dr_smith | #METADATA | SPECIALTY#dentist | PROVIDER#dr_smith | { name: "Dr. Smith", timezone: "America/New_York" } |
PROVIDER#dr_smith | SLOT#2026-04-27T09:00:00Z | - | - | { status: "booked", duration: 30, bookingId: "01HW..." } |
PROVIDER#dr_smith | SLOT#2026-04-27T09:30:00Z | - | - | { status: "available", duration: 30 } |
PROVIDER#dr_smith | SLOT#2026-04-27T10:00:00Z | - | - | { status: "blocked", duration: 30 } |
BOOKING#01HW... | #METADATA | CUSTOMER#cust_01 | BOOKING#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.
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.