DynamoDB Single-Table Pattern: Workflow/State Machine
Approval chains, onboarding flows, CI/CD pipelines, order fulfillment - any process where “what happens next” depends on “what just happened.”
The DynamoDB challenge: modeling ordered steps and tracking execution state across those steps, with a full audit trail of every transition.
This pattern separates the workflow definition (what steps exist) from workflow execution (what actually happened when someone ran it). The same workflow definition can have thousands of executions, each at a different step with different data.
Access patterns
| # | Access Pattern | Operation | Notes |
|---|---|---|---|
| AP1 | Get workflow definition with all steps | Query | Load the full workflow |
| AP2 | Get execution status and current step | GetItem | Dashboard / status check |
| AP3 | List executions for a workflow (newest first) | Query (GSI1) | Execution history |
| AP4 | Get full transition history for an execution | Query | Audit trail / debugging |
| AP5 | Advance execution to next step | TransactWriteItems | State transition |
| AP6 | Get a specific step definition | GetItem | Step detail view |
Six access patterns. Four entity types. One table, one GSI.
Entities
Four entity types:
- Workflow: the definition — name, version, and list of steps
- Step: a single step in a workflow, ordered by an integer sort key
- Execution: a single run of a workflow, with current step and status
- Transition: an audit record of each step completion, with result and data

Table design
Primary key structure
| Entity | PK | SK | Purpose |
|---|---|---|---|
| Workflow | WORKFLOW#<workflowId> | #METADATA | Workflow name, version, status |
| Step | WORKFLOW#<workflowId> | STEP#<stepOrder> | Step definitions, ordered |
| Execution | EXECUTION#<executionId> | #METADATA | Current execution state |
| Transition | EXECUTION#<executionId> | TRANSITION#<transitionId> | State change audit trail |
Steps use sequential integer sort keys: STEP#1, STEP#2, STEP#3. Query the workflow partition to get all steps in order. The step order IS the sort key.
Executions are separate from workflow definitions. The workflow partition holds the blueprint. Each execution gets its own partition with its state and transition history, cleanly separating “what should happen” from “what did happen.”
Transitions use ULIDs. Each state transition is a chronologically-ordered record in the execution partition. The full transition history is a single Query.

The state transition: advancing an execution
async function advanceExecution(
executionId: string,
fromStep: number,
toStep: number,
result: string,
data?: any
) {
const transitionId = ulid();
await client.transactWrite({
TransactItems: [
{
// Update execution: advance currentStep
Update: {
TableName: TABLE_NAME,
Key: { pk: `EXECUTION#${executionId}`, sk: "#METADATA" },
UpdateExpression: "SET currentStep = :toStep, #status = :status",
// Guard: only advance if we're at the expected step
ConditionExpression: "currentStep = :fromStep AND #status = :running",
ExpressionAttributeNames: { "#status": "status" },
ExpressionAttributeValues: {
":fromStep": fromStep,
":toStep": toStep,
":status": toStep === -1 ? "completed" : "running",
":running": "running",
},
},
},
{
// Record the transition
Put: {
TableName: TABLE_NAME,
Item: {
pk: `EXECUTION#${executionId}`,
sk: `TRANSITION#${transitionId}`,
fromStep, toStep, result,
data: data || null,
occurredAt: new Date().toISOString(),
},
},
},
],
});
}
The condition expression currentStep = :fromStep AND #status = :running prevents out-of-order transitions and ensures only running executions can be advanced. If two processes try to advance the same execution simultaneously, exactly one succeeds.
Sample data
| pk | sk | gsi1pk | gsi1sk | Entity Data |
|---|---|---|---|---|
WORKFLOW#wf_onboard | #METADATA | - | - | { name: "Employee Onboarding", version: 2, status: "active" } |
WORKFLOW#wf_onboard | STEP#1 | - | - | { name: "Send welcome email", type: "notification" } |
WORKFLOW#wf_onboard | STEP#2 | - | - | { name: "Manager approval", type: "approval", timeoutMin: 4320 } |
WORKFLOW#wf_onboard | STEP#3 | - | - | { name: "Provision accounts", type: "action" } |
EXECUTION#01HW... | #METADATA | WORKFLOW#wf_onboard | EXECUTION#01HW... | { workflowId: "wf_onboard", status: "running", currentStep: 2, triggeredBy: "hr_admin" } |
EXECUTION#01HW... | TRANSITION#01HW1... | - | - | { fromStep: 1, toStep: 2, result: "success", data: { emailSent: true } } |
ElectroDB entity definitions
export const WorkflowEntity = new Entity({
model: { entity: "workflow", version: "1", service: "wf" },
attributes: {
workflowId: { type: "string", required: true },
name: { type: "string", required: true },
version: { type: "number", required: true, default: 1 },
status: { type: "string", required: true, default: "active",
enum: ["active", "archived"] },
description: { 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: ["workflowId"], template: "WORKFLOW#${workflowId}" },
sk: { field: "sk", composite: [], template: "#METADATA" },
},
},
}, { client, table }); Why this design
Sequential step ordering via SK is simpler than linked-list approaches. STEP#1, STEP#2, STEP#3 sorts naturally. “What comes after step 2?” is just STEP#3. No pointer traversal.
Transitions are the audit trail. The execution’s currentStep tells you where it is now. The transition history tells you how it got there, including which steps failed, which were skipped, and how long each took (compare occurredAt timestamps).
This is not a replacement for AWS Step Functions. Step Functions handle complex branching, parallel execution, error retries with backoff, and long-running waits natively. This pattern is for simpler, linear or mostly-linear workflows where you want the state machine definition and execution history in your own database: approval chains, onboarding flows, document review pipelines.
Conditional branching lives in the execution engine, not the schema. The Step entity’s config attribute stores branching rules (e.g., “if approved, go to step 3; if rejected, go to step 5”). The engine reads the config and determines the next step. Keeping branching logic out of the key structure means you can change workflow rules without re-keying data.
Design this visually → coming soon
The definition/execution split is easy to state but hard to internalize until you see both partitions side by side - the workflow blueprint over here, the live execution with its transition log over there. That’s what I’m building at singletable.dev.
Pattern #9 of 10 in the SingleTable pattern library. The condition-based state transition technique is the same pattern used in Booking/Scheduling (prevent double-booking) and Inventory Management (prevent overselling). I’m building singletable.dev to make these patterns visual.
New to single-table design? DynamoDB fundamentals is the right starting point - it covers the data model this pattern builds on. The 5 most common mistakes is worth reading before you commit to a key structure.