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

  1. 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.
  2. If you need cascading selects, fetch all options in a single subflow call rather than chaining subflow() calls across flows.
  3. 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

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


AI-assisted analysis of kestra-io/kestra@823fada927 (2026-08-14). Data as JSON: /api/errors/3478a8af5f17ef34. Report an issue: GitHub.