DynamoDB Single-Table Pattern: Inventory Management
Inventory management has one non-negotiable constraint: stock updates must be atomic. If 10 customers try to buy the last item simultaneously, exactly one should succeed. The other 9 should see “out of stock.”
In a relational database, you’d use SELECT FOR UPDATE or optimistic locking. In DynamoDB, you use atomic updates with condition expressions - the same technique the Booking/Scheduling pattern uses to prevent double-bookings - and they’re actually simpler and more reliable for this use case.
This pattern handles products, warehouse stock levels, and a movement audit trail with atomic stock operations and oversell prevention.
Access patterns
| # | Access Pattern | Operation | Notes |
|---|---|---|---|
| AP1 | Get product details | GetItem | Product info page |
| AP2 | List products by category | Query (GSI1) | Category browsing |
| AP3 | Get stock level for a product at a warehouse | GetItem | Stock check |
| AP4 | List all stock at a warehouse | Query | Warehouse dashboard |
| AP5 | List all warehouses stocking a product | Query (GSI1) | Fulfillment routing |
| AP6 | Receive stock (increment) | UpdateItem | Inbound shipment |
| AP7 | Reserve stock for an order (atomic, prevent oversell) | UpdateItem with condition | Order placement |
| AP8 | Get movement history for a warehouse | Query | Audit trail |
Eight access patterns. Three entity types. One table, one GSI.
Entities
Three entity types:
- Product: the catalog entry — name, SKU, category
- Stock: the quantity of a product at a specific warehouse, with reserved and available levels tracked separately
- Movement: an append-only audit trail of every stock change — inbound, outbound, reservation, release

Table design
Primary key structure
| Entity | PK | SK | Purpose |
|---|---|---|---|
| Product | PRODUCT#<productId> | #METADATA | Product catalog info |
| Stock | WAREHOUSE#<warehouseId> | PRODUCT#<productId> | Stock level per warehouse-product pair |
| Movement | WAREHOUSE#<warehouseId> | MOVEMENT#<movementId> | Audit trail of stock changes |
Stock is keyed by warehouse × product. Each Stock item represents one product at one warehouse. The warehouse partition groups all stock at a location together (AP4), and the GSI inverts it to find all warehouses for a product (AP5).
Movements use ULIDs for chronological ordering, so all movements at a warehouse sort by time and the audit trail is a single Query.

Atomic stock operations
Receiving stock (AP6):
await StockEntity.update({ warehouseId, productId })
.add({ quantity: inboundQty })
.go();
DynamoDB’s ADD operation is atomic - no read-modify-write cycle.
Reserving stock with oversell prevention (AP7):
async function reserveStock(warehouseId: string, productId: string, qty: number) {
await client.update({
TableName: TABLE_NAME,
Key: { pk: `WAREHOUSE#${warehouseId}`, sk: `PRODUCT#${productId}` },
UpdateExpression: "ADD reservedQty :qty",
// Only reserve if enough unreserved stock exists
ConditionExpression: "(quantity - reservedQty) >= :qty",
ExpressionAttributeValues: { ":qty": qty },
});
}
The condition expression (quantity - reservedQty) >= :qty is evaluated atomically. If two orders try to reserve the last 5 units simultaneously, only one succeeds. The other gets a ConditionalCheckFailedException.
Fulfilling a reservation (shipping):
await client.update({
TableName: TABLE_NAME,
Key: { pk: `WAREHOUSE#${warehouseId}`, sk: `PRODUCT#${productId}` },
UpdateExpression: "ADD quantity :negQty, reservedQty :negQty",
ExpressionAttributeValues: { ":negQty": -qty },
});
Both quantity and reservedQty decrease - the item has left the warehouse.
Recording movements
Every stock change should also write a Movement record. Use TransactWriteItems to atomically update stock and create the movement:
await client.transactWrite({
TransactItems: [
{ Update: { /* stock update with condition */ } },
{ Put: { /* new Movement record */ } },
],
});
This gives you a complete audit trail - every stock change is traceable to a movement with a type, reason, and reference ID.
Sample data
| pk | sk | gsi1pk | gsi1sk | Entity Data |
|---|---|---|---|---|
PRODUCT#prod_01 | #METADATA | CATEGORY#electronics | PRODUCT#prod_01 | { name: "Wireless Mouse", sku: "WM-100" } |
WAREHOUSE#wh_nyc | PRODUCT#prod_01 | PRODUCT#prod_01 | WAREHOUSE#wh_nyc | { quantity: 150, reservedQty: 12, reorderPoint: 20 } |
WAREHOUSE#wh_nyc | PRODUCT#prod_02 | PRODUCT#prod_02 | WAREHOUSE#wh_nyc | { quantity: 45, reservedQty: 0, reorderPoint: 10 } |
WAREHOUSE#wh_nyc | MOVEMENT#01HW... | - | - | { productId: "prod_01", type: "reserve", quantity: 3, referenceId: "order_01" } |
WAREHOUSE#wh_la | PRODUCT#prod_01 | PRODUCT#prod_01 | WAREHOUSE#wh_la | { quantity: 80, reservedQty: 5, reorderPoint: 15 } |
ElectroDB entity definitions
export const ProductEntity = new Entity({
model: { entity: "product", version: "1", service: "inventory" },
attributes: {
productId: { type: "string", required: true },
name: { type: "string", required: true },
sku: { type: "string", required: true },
category: { type: "string" },
active: { type: "boolean", required: true, default: 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: ["productId"], template: "PRODUCT#${productId}" },
sk: { field: "sk", composite: [], template: "#METADATA" },
},
byCategory: {
index: "GSI1",
pk: { field: "gsi1pk", composite: ["category"], template: "CATEGORY#${category}" },
sk: { field: "gsi1sk", composite: ["productId"], template: "PRODUCT#${productId}" },
},
},
}, { client, table }); Why this design
Available quantity is computed at read time (availableQty = quantity - reservedQty) rather than stored. This avoids another field to keep in sync, and the two source fields are always updated atomically.
The reservation pattern separates intent from fulfillment. Reserving stock (order placed) and decrementing stock (order shipped) are separate operations. This handles the gap between order creation and shipment: reserved stock is still in the warehouse but not available for other orders.
Movements live in the warehouse partition, not their own partition. “Show me all activity at warehouse NYC” is a single Query. If you also need “all movements for product X across all warehouses,” add a GSI on the Movement entity with PK = PRODUCT#<productId>.
Low-stock alerts can be triggered via DynamoDB Streams. When a stock update causes quantity - reservedQty to drop below reorderPoint, a stream-triggered Lambda fires a notification.
Design this visually → coming soon
The Stock entity sitting at the intersection of Warehouse and Product partitions - with atomic operations flowing in from orders and movements flowing out - is exactly the kind of thing that’s easier to understand on a canvas than in prose. That’s what I’m building at singletable.dev.
Pattern #8 of 10 in the SingleTable pattern library. The atomic update with condition expression technique is the same mechanism used in the Booking/Scheduling pattern for preventing double-booking. I’m building singletable.dev to make these patterns visual.