mastra-ai/mastra · error · Error

Foreach step cannot iterate a mapping: mappings project data

Error message

Foreach step cannot iterate a mapping: mappings project data, they don't execute per item. Use an agent, tool, or plain step as the foreach body.

What it means

A `foreach` entry's body must be an executable step (agent, tool, or plain step) that runs once per item. A `mapping` entry is a data projection — it reshapes input once, not per item — so using it as a foreach body is semantically invalid. The serializer rejects it at `toStorableGraph` time instead of producing a persisted workflow that behaves incorrectly on reload.

Source

Thrown at packages/core/src/workflows/dynamic/serialize.ts:60

    case 'agent':
    case 'tool':
    case 'mapping':
      return serializeSingleEntry(entry);
    case 'sleep':
      if (typeof entry.duration !== 'number') {
        throw new Error(`Sleep step "${entry.id}" cannot be stored: dynamic duration (function) is not supported.`);
      }
      return { type: 'sleep', id: entry.id, duration: entry.duration };
    case 'sleepUntil':
      if (!(entry.date instanceof Date)) {
        throw new Error(`SleepUntil step "${entry.id}" cannot be stored: dynamic date (function) is not supported.`);
      }
      return { type: 'sleepUntil', id: entry.id, date: entry.date };
    case 'parallel':
      return { type: 'parallel', steps: entry.steps.map(s => serializeSingleEntry(s)) };
    case 'foreach':
      if (entry.step.type === 'mapping') {
        throw new Error(
          `Foreach step cannot iterate a mapping: mappings project data, they don't execute per item. Use an agent, tool, or plain step as the foreach body.`,
        );
      }
      return {
        type: 'foreach',
        step: serializeSingleEntry(entry.step),
        opts:
          typeof entry.opts.concurrency === 'function'
            ? { fn: entry.opts.concurrency.toString() }
            : { concurrency: entry.opts.concurrency },
      };
    case 'conditional': {
      const predicates = entry.predicates;
      if (!predicates || predicates.some(p => !p || typeof p !== 'object')) {
        throw new Error(
          `Conditional (branch) step cannot be stored: closure predicates do not round-trip. Use the declarative form ({ predicate: {...} }) for each branch.`,
        );
      }

View on GitHub (pinned to 75dd419e61)

Solutions

  1. Use an agent, tool, or plain step as the foreach body; move the per-item projection into that step's logic or into a mapping step placed before/after the foreach.
  2. Restructure: `mapping` (project the list) -> `foreach(agent/tool/step)` -> `mapping` (collect results).
  3. Guard before persisting: `entry.type === 'foreach' && entry.step.type !== 'mapping'`.

Example fix

// before
foreach({ type: 'mapping', id: 'proj', mapConfig: {...} })

// after
foreach({ type: 'tool', id: 'process-item', toolId: 'myTool' })
Defensive patterns

Strategy: validation

Validate before calling

for (const e of stepFlow) {
  if (e.type === 'foreach' && e.step.type === 'mapping') {
    throw new Error('foreach body must be agent/tool/step, not a mapping');
  }
}

Type guard

const hasExecutableBody = (e: StepFlowEntry): boolean =>
  e.type !== 'foreach' || (e.step.type === 'agent' || e.step.type === 'tool' || e.step.type === 'step');

Try / catch

try {
  storable = toStorableGraph(stepFlow);
} catch (e) {
  if (/Foreach step cannot iterate a mapping/.test(e.message)) {
    // rebuild the foreach with an executable body
  } else throw e;
}

Prevention

When it happens

Trigger: Building a workflow where `.foreach()` (or the dynamic `foreach` entry constructor) is given a `mapping`-typed step entry as its body, then calling `toStorableGraph` on the resulting `stepFlow`.

Common situations: Developers want to transform each item of a list and reach for a mapping step inside foreach, confusing 'project data' with 'execute per item'. Usually surfaces when persisting a dynamically built batch-processing workflow.

Related errors


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/532b6ad26b25f72e. Report an issue: GitHub.