diff --git a/pkgs/website/astro.config.mjs b/pkgs/website/astro.config.mjs
index 8f85d3bf7..7f0e31859 100644
--- a/pkgs/website/astro.config.mjs
+++ b/pkgs/website/astro.config.mjs
@@ -271,6 +271,10 @@ export default defineConfig({
},
],
},
+ {
+ label: 'Conditional Steps',
+ autogenerate: { directory: 'build/conditional-steps/' },
+ },
{
label: 'Starting Flows',
autogenerate: { directory: 'build/starting-flows/' },
diff --git a/pkgs/website/src/assets/pgflow-theme.d2 b/pkgs/website/src/assets/pgflow-theme.d2
index c4794e52b..65fc375b8 100644
--- a/pkgs/website/src/assets/pgflow-theme.d2
+++ b/pkgs/website/src/assets/pgflow-theme.d2
@@ -69,7 +69,7 @@ classes: {
style.stroke: "#e85c5c"
}
- # Step state classes (created, started, completed, failed)
+ # Step state classes (created, started, completed, failed, skipped)
step_created: {
style.fill: "#95a0a3"
style.stroke: "#4a5759"
@@ -86,6 +86,11 @@ classes: {
style.fill: "#a33636"
style.stroke: "#e85c5c"
}
+ step_skipped: {
+ style.fill: "#4a5759"
+ style.stroke: "#6b7a7d"
+ style.stroke-dash: 3
+ }
# Task state classes (queued, completed, failed)
task_queued: {
diff --git a/pkgs/website/src/content/docs/build/conditional-steps/error-handling.mdx b/pkgs/website/src/content/docs/build/conditional-steps/error-handling.mdx
new file mode 100644
index 000000000..7969f3855
--- /dev/null
+++ b/pkgs/website/src/content/docs/build/conditional-steps/error-handling.mdx
@@ -0,0 +1,254 @@
+---
+title: Error Handling
+description: Handle step failures gracefully with retriesExhausted option.
+sidebar:
+ order: 3
+---
+
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+The `retriesExhausted` option controls what happens when a step fails after exhausting all retry attempts. Instead of failing the entire run, you can mark the step as skipped and continue.
+
+## Default Behavior
+
+By default, when a step fails after all retries, the entire run fails:
+
+```typescript
+.step({
+ slug: 'sendNotification',
+ maxAttempts: 3,
+ // retriesExhausted: 'fail' // This is the default
+}, async (input) => {
+ await sendEmail(input.run.email); // If this fails 3 times, run fails
+})
+```
+
+## Graceful Failure with skip
+
+Use `retriesExhausted: 'skip'` for non-critical steps that shouldn't block the workflow:
+
+```typescript
+.step({
+ slug: 'sendWelcomeEmail',
+ maxAttempts: 3,
+ retriesExhausted: 'skip', // If email fails, continue anyway
+}, async (input) => {
+ return await sendEmail(input.run.email);
+})
+.step({
+ slug: 'createAccount',
+ dependsOn: ['sendWelcomeEmail'],
+}, async (input) => {
+ // This runs even if email failed
+ // input.sendWelcomeEmail may be undefined
+ const emailSent = input.sendWelcomeEmail !== undefined;
+ return { accountId: await createUser(input.run), emailSent };
+})
+```
+
+
+
+## Cascade Failure with skip-cascade
+
+Use `retriesExhausted: 'skip-cascade'` when a failed step should skip its entire dependency chain:
+
+```typescript
+.step({
+ slug: 'fetchExternalData',
+ maxAttempts: 5,
+ retriesExhausted: 'skip-cascade', // Skip this + dependents on failure
+}, async (input) => {
+ return await fetchFromUnreliableAPI(input.run.id);
+})
+.step({
+ slug: 'processExternalData',
+ dependsOn: ['fetchExternalData'],
+}, processHandler) // Also skipped if fetch fails
+.step({
+ slug: 'useLocalFallback',
+ // No dependency on external data
+}, fallbackHandler) // This always runs
+```
+
+```d2 width="700" pad="20"
+...@../../../../assets/pgflow-theme.d2
+
+direction: right
+
+fetch: "fetchExternalData"
+fetch.class: step_skipped
+
+process: "processExternalData"
+process.class: step_skipped
+
+fallback: "useLocalFallback"
+fallback.class: step_completed
+
+fetch -> process: "cascade skip" {
+ style.stroke-dash: 3
+}
+
+note: "fetch failed after 5 attempts\nprocess cascaded to skipped\nfallback runs independently" {
+ style.fill: transparent
+ style.stroke: transparent
+}
+```
+
+## Type Safety
+
+Like `whenUnmet`, the `retriesExhausted` option affects TypeScript types:
+
+| Mode | Dependent Input Type |
+| -------------- | --------------------------- |
+| `fail` | `T` (required) |
+| `skip` | `T \| undefined` (optional) |
+| `skip-cascade` | `T` (required) |
+
+```typescript
+// With retriesExhausted: 'skip'
+.step({
+ slug: 'optional',
+ retriesExhausted: 'skip',
+}, () => ({ data: 'value' }))
+
+.step({
+ slug: 'consumer',
+ dependsOn: ['optional'],
+}, async (input) => {
+ // TypeScript knows input.optional may be undefined
+ if (input.optional) {
+ return processData(input.optional.data);
+ }
+ return processWithoutData();
+})
+```
+
+## TYPE_VIOLATION: The Exception
+
+Some errors indicate programming mistakes that should never be silently ignored. These **TYPE_VIOLATION** errors always fail the run, regardless of `retriesExhausted`:
+
+```typescript
+// This will ALWAYS fail the run, even with retriesExhausted: 'skip'
+.step({
+ slug: 'fetchItems',
+ retriesExhausted: 'skip',
+}, () => "not an array") // Returns string instead of array!
+.map({
+ slug: 'processItems',
+ array: 'fetchItems', // Expects array, gets string
+}, (item) => item * 2)
+```
+
+
+
+## Skip Reason for Failed Steps
+
+When `retriesExhausted: 'skip'` triggers, the step gets:
+
+- `status: 'skipped'`
+- `skip_reason: 'handler_failed'`
+- Original error preserved in `error_message`
+
+Query failed-but-skipped steps:
+
+```sql
+SELECT step_slug, error_message, skip_reason
+FROM pgflow.step_states
+WHERE run_id = 'your-run-id'
+ AND skip_reason = 'handler_failed';
+```
+
+## Combining with Conditions
+
+You can use both condition options and failure handling on the same step:
+
+```typescript
+.step({
+ slug: 'premiumNotification',
+ if: { plan: 'premium' }, // Only for premium users
+ whenUnmet: 'skip', // Skip if not premium
+ maxAttempts: 3,
+ retriesExhausted: 'skip', // Skip if notification fails
+}, async (input) => {
+ return await sendPremiumEmail(input.run.email);
+})
+```
+
+The step can be skipped for two different reasons:
+
+1. `condition_unmet` - User isn't premium
+2. `handler_failed` - Email service failed after 3 attempts
+
+## Best Practices
+
+### Do Use skip for Side Effects
+
+```typescript
+// Good: Notifications shouldn't block core logic
+.step({
+ slug: 'sendSlackNotification',
+ maxAttempts: 2,
+ retriesExhausted: 'skip',
+}, notifySlack)
+```
+
+### Don't Use skip for Critical Steps
+
+```typescript
+// Bad: Payment processing should never silently fail
+.step({
+ slug: 'chargePayment',
+ maxAttempts: 3,
+ retriesExhausted: 'skip', // Don't do this!
+}, chargeCard)
+```
+
+### Use skip-cascade for Feature Branches
+
+```typescript
+// Good: If enrichment source is down, skip all enrichment
+.step({
+ slug: 'fetchEnrichmentData',
+ retriesExhausted: 'skip-cascade',
+}, fetchEnrichment)
+.step({
+ slug: 'applyEnrichment',
+ dependsOn: ['fetchEnrichmentData'],
+}, applyEnrichment) // Skipped if fetch failed
+```
+
+## Learn More
+
+
+
+
+
+
diff --git a/pkgs/website/src/content/docs/build/conditional-steps/index.mdx b/pkgs/website/src/content/docs/build/conditional-steps/index.mdx
new file mode 100644
index 000000000..23d3f7129
--- /dev/null
+++ b/pkgs/website/src/content/docs/build/conditional-steps/index.mdx
@@ -0,0 +1,117 @@
+---
+title: Conditional Steps
+description: Control which steps execute based on input patterns and handle failures gracefully.
+sidebar:
+ order: 27
+---
+
+import { Aside, CardGrid, LinkCard } from '@astrojs/starlight/components';
+
+pgflow supports conditional step execution based on input data patterns and configurable failure handling. This enables dynamic workflows that adapt to runtime conditions.
+
+## Two Systems, One Goal
+
+pgflow provides two orthogonal systems for controlling step execution:
+
+| System | When Evaluated | Purpose |
+| ----------------------------------------- | ---------------- | ----------------------------------------------------- |
+| **Pattern Matching** (`if`/`ifNot`) | Before step runs | Skip steps based on input values |
+| **Failure Handling** (`retriesExhausted`) | After step fails | Handle failures gracefully instead of failing the run |
+
+Both systems share the same three behavior modes: `fail`, `skip`, and `skip-cascade`.
+
+## Behavior Modes
+
+When a condition is unmet or a step fails, you control what happens:
+
+| Mode | Behavior |
+| -------------- | ----------------------------------------------------------------------------------------------- |
+| `fail` | Step fails, entire run fails (default for `retriesExhausted`) |
+| `skip` | Step marked as skipped, run continues, dependents receive `undefined` (default for `whenUnmet`) |
+| `skip-cascade` | Step AND all downstream dependents skipped, run continues |
+
+## Quick Examples
+
+### Conditional Execution
+
+Run premium-only features based on input:
+
+```typescript
+new Flow<{ userId: string; plan: 'free' | 'premium' }>({
+ slug: 'userOnboarding',
+})
+ .step({ slug: 'createAccount' }, async (input) => {
+ return { accountId: await createUser(input.run.userId) };
+ })
+ .step(
+ {
+ slug: 'setupPremiumFeatures',
+ dependsOn: ['createAccount'],
+ if: { plan: 'premium' }, // Only run for premium users
+ whenUnmet: 'skip', // Skip (don't fail) for free users
+ },
+ async (input) => {
+ return await enablePremium(input.createAccount.accountId);
+ }
+ );
+```
+
+### Graceful Failure Handling
+
+Continue the workflow even if an optional step fails:
+
+```typescript
+.step({
+ slug: 'sendWelcomeEmail',
+ dependsOn: ['createAccount'],
+ maxAttempts: 3,
+ retriesExhausted: 'skip', // If email fails, continue anyway
+}, async (input) => {
+ return await sendEmail(input.createAccount.accountId);
+})
+```
+
+
+
+## Type Safety
+
+pgflow's type system tracks which steps may be skipped:
+
+- **`skip` mode**: Dependent steps receive `T | undefined` - you must handle the missing case
+- **`skip-cascade` mode**: Dependents are also skipped, so if they run, output is guaranteed
+
+```typescript
+.step({
+ slug: 'processResults',
+ dependsOn: ['optionalEnrichment'],
+}, async (input) => {
+ // TypeScript knows this may be undefined
+ if (input.optionalEnrichment) {
+ return processWithEnrichment(input.optionalEnrichment);
+ }
+ return processBasic(input.run);
+})
+```
+
+## Learn More
+
+
+
+
+
+
diff --git a/pkgs/website/src/content/docs/build/conditional-steps/pattern-matching.mdx b/pkgs/website/src/content/docs/build/conditional-steps/pattern-matching.mdx
new file mode 100644
index 000000000..fe8f44a3f
--- /dev/null
+++ b/pkgs/website/src/content/docs/build/conditional-steps/pattern-matching.mdx
@@ -0,0 +1,239 @@
+---
+title: Pattern Matching
+description: Use if/ifNot conditions to control step execution based on input patterns.
+sidebar:
+ order: 1
+---
+
+import { Aside, Code } from '@astrojs/starlight/components';
+
+Pattern matching lets you conditionally execute steps based on input data. pgflow uses PostgreSQL's `@>` JSON containment operator for pattern matching.
+
+## Basic Syntax
+
+Use `if` to run a step only when input contains a pattern:
+
+```typescript
+.step({
+ slug: 'premiumFeature',
+ if: { plan: 'premium' }, // Run if input contains plan: 'premium'
+ whenUnmet: 'skip', // Skip if condition not met
+}, handler)
+```
+
+Use `ifNot` to run a step only when input does NOT contain a pattern:
+
+```typescript
+.step({
+ slug: 'trialReminder',
+ ifNot: { plan: 'premium' }, // Run if input does NOT contain plan: 'premium'
+ whenUnmet: 'skip',
+}, handler)
+```
+
+
+
+## What Gets Checked
+
+The pattern is checked against different data depending on the step type:
+
+| Step Type | Pattern Checked Against |
+| -------------------------- | ------------------------------------------------------------ |
+| Root step (no `dependsOn`) | Flow input |
+| Dependent step | Aggregated dependency outputs: `{ depSlug: depOutput, ... }` |
+
+### Root Step Example
+
+For root steps, the pattern matches against the flow input:
+
+```typescript
+type Input = { userId: string; plan: 'free' | 'premium' };
+
+new Flow({ slug: 'onboarding' }).step(
+ {
+ slug: 'setupPremium',
+ if: { plan: 'premium' }, // Checks flow input.plan
+ whenUnmet: 'skip',
+ },
+ async (input) => {
+ // input.run.plan is guaranteed to be 'premium' here
+ return await enablePremiumFeatures(input.run.userId);
+ }
+);
+```
+
+### Dependent Step Example
+
+For dependent steps, the pattern matches against an object containing all dependency outputs:
+
+```typescript
+new Flow<{ url: string }>({ slug: 'contentPipeline' })
+ .step({ slug: 'analyze' }, async (input) => {
+ const result = await analyzeContent(input.run.url);
+ return { needsModeration: result.flagged, content: result.text };
+ })
+ .step(
+ {
+ slug: 'moderate',
+ dependsOn: ['analyze'],
+ if: { analyze: { needsModeration: true } }, // Check analyze output
+ whenUnmet: 'skip',
+ },
+ async (input) => {
+ return await moderateContent(input.analyze.content);
+ }
+ );
+```
+
+The pattern `{ analyze: { needsModeration: true } }` matches the object `{ analyze: