kestra-io/kestra · error · PebbleException
The 'subflow' function exceeded the maximum nesting depth of
Error message
The 'subflow' function exceeded the maximum nesting depth of %s (a subflow's inputs likely call subflow() recursively).
What it means
The subflow() function uses a ThreadLocal depth counter to guard against runaway recursion — a subflow whose own inputs call subflow(), whose inputs call subflow(), and so on. Each entry increments the counter; when it reaches the configured maximum depth (SubflowFunctionConfiguration.maxDepth), the function aborts. This catches both direct self-recursion (flow A's input calls subflow(A)) and mutual recursion (A calls B, B calls A) because input resolution runs synchronously on the same thread.
Source
Thrown at core/src/main/java/io/kestra/core/runners/pebble/functions/SubflowFunction.java:139
if (flow == null) {
throw new PebbleException(
null, "The 'subflow' function can only be used in a flow context (e.g. an input's 'values'); the caller flow could not be resolved.", lineNumber, self.getName()
);
}
String tenantId = flow.get("tenantId");
String callerNamespace = flow.get(NAMESPACE_ARG);
String callerId = flow.get("id");
Optional<Integer> revision = Optional.ofNullable(args.get(REVISION_ARG)).map(r -> ((Number) r).intValue());
Map<String, Object> rawInputs = (Map<String, Object>) args.get(INPUTS_ARG);
Map<String, Object> inputs = rawInputs != null ? rawInputs : Map.of();
List<Label> labels = buildLabels(args.get(LABELS_ARG), self, lineNumber);
Duration timeout = resolveTimeout(args.get(TIMEOUT_ARG), self, lineNumber);
int depth = DEPTH.get();
if (depth >= configuration.maxDepth()) {
throw new PebbleException(
null, "The 'subflow' function exceeded the maximum nesting depth of " + configuration.maxDepth()
+ " (a subflow's inputs likely call subflow() recursively).",
lineNumber, self.getName()
);
}
DEPTH.set(depth + 1);
try {
// ACL is scoped to the caller flow (callerNamespace/callerId), matching the Subflow task trust model:
// if the caller flow may reference the target, so may subflow(). Note this is reachable at execute-form
// render time, not only at execution time, so anyone able to open the form triggers this resolution.
// resolved for runtime so a governance rejection surfaces as a FlowWithException here, rather than
// becoming a created-then-failed execution
FlowWithSource targetFlow = flowMetaStore.get()
.findByIdFromTaskForRuntime(tenantId, namespace, id, revision, tenantId, callerNamespace, callerId)
.orElseThrow(
() -> new PebbleException(
null, "Unable to find flow '" + namespace + "'.'" + id + "'"View on GitHub (pinned to 823fada927)
Solutions
- Break the cycle: ensure no chain of subflow() input-value calls forms a loop. Use static data or outputs from completed executions for dependent values.
- If you need cascading selects, fetch all options in a single subflow call rather than chaining subflow() calls across flows.
- Increase the max depth in SubflowFunctionConfiguration if legitimate deep (but non-circular) nesting is required (use with caution).
Example fix
# before — flow A calls flow B which calls flow A (circular)
# flow_a.yaml
inputs:
- id: region
type: SELECT
values: "{{ subflow(namespace='ns', id='flow_b').outputs.regions }}"
# flow_b.yaml
inputs:
- id: prefix
type: SELECT
values: "{{ subflow(namespace='ns', id='flow_a').outputs.prefixes }}"
# after — break the cycle; fetch data from a single source
# flow_a.yaml
inputs:
- id: region
type: SELECT
values: "{{ subflow(namespace='ns', id='data_provider').outputs.regions }}" Defensive patterns
Strategy: validation
Validate before calling
# Design flows so no cycle exists in subflow() input-value calls. # Map out the dependency graph: if A's input calls subflow(B) and B's input calls subflow(A), # you have a cycle — break it by sourcing data from a terminal (non-subflow) provider. # There is no runtime Pebble guard; this must be correct by design.
Prevention
- Draw the subflow() call graph and verify it is a DAG (no cycles).
- Use a single data-provider flow at the root of the dependency tree.
- Never let a flow's input values call subflow() on itself.
- Review SubflowFunctionConfiguration.maxDepth and ensure it is not set too high for your use case.
When it happens
Trigger: Flow A has a SELECT input with values: {{ subflow(namespace='ns', id='A').outputs.x }}. Flow B has a similar input calling subflow('B'). When the form for A renders, it triggers B, which triggers A, which triggers B... Two or more flows form a cycle through their input-value subflow() calls.
Common situations: Flows that mutually reference each other for dynamic input options (e.g., a cascading select where each level calls a different flow that itself has subflow()-driven inputs). Accidental self-reference when a flow's input values call subflow on itself. Refactoring that introduced a circular dependency.
Related errors
- Maximum render() nesting depth (%s) exceeded at line %s — ch
- Too many rendering attempts
- The 'subflow' function can only be used at flow-input render
- The 'subflow' function expects the arguments 'namespace' and
- The 'subflow' function can only be used in a flow context (e
AI-assisted analysis of kestra-io/kestra@823fada927 (2026-08-14).
Data as JSON: /api/errors/3478a8af5f17ef34.
Report an issue: GitHub.