How does a Haystack pipeline branch on a runtime value and rejoin the branches?
answer
- Decisions are components, not keywords
- Only the matching route emits
- Untaken branches never execute
- Fan-out free, fan-in needs help
- Variadic input accepts many connections
basics
~20 sConditionalRouter evaluates Jinja conditions over its inputs and emits on only the matching route's output socket, so the untaken branch never runs. To merge branches back, route them through a joiner with a variadic input, because a plain input socket accepts one connection.
solid answer
~50 sBranching is a component, not a control-flow keyword. `ConditionalRouter(routes=[...])` takes a list of route dicts, each with a `condition` (a Jinja expression over the router's inputs), an `output` expression, an `output_name` that becomes an output socket, and an `output_type` used for the wiring-time type check. At run time the first matching condition fires and the value leaves on that socket alone; components downstream of the other sockets simply never become runnable, so you pay nothing for the branch you did not take. Rejoining is the asymmetric half: a normal input socket accepts exactly one incoming connection, so you cannot point two branches at the same downstream input. `BranchJoiner(str)` — constructed with the type it carries — exposes a variadic input and forwards whichever branch produced a value, giving you one edge into the downstream component. `pipe.draw()` renders the resulting graph, which is how you sanity-check a branch-heavy wiring block in review.
code
python · 15 linesfrom haystack.components.joiners import BranchJoiner
from haystack.components.routers import ConditionalRouter
router = ConditionalRouter(routes=[
{"condition": "{{ hits|length > 0 }}", "output": "{{ query }}",
"output_name": "grounded", "output_type": str},
{"condition": "{{ hits|length == 0 }}", "output": "{{ query }}",
"output_name": "fallback", "output_type": str},
])
print(router.run(hits=[], query="what is haystack?"))
# {'fallback': 'what is haystack?'}
joiner = BranchJoiner(str) # variadic input, single str output
print(joiner.run(value=["from whichever branch ran"]))go deeper
Know that Haystack expresses an if/else as a router component with routes, not as Python control flow, and that each route names an output socket you connect from.
Explain the four keys of a route — condition, output, output_name, output_type — and why merging branches needs a joiner: a plain input socket takes one connection.
Show the operational consequences: untaken branches cost nothing, a run with no matching condition errors rather than passing through, and the router's own output is the evidence you pull with include_outputs_from when the wrong branch fires.
Judge when branching stops paying. Every router adds edges to a graph the team must read; past a handful, splitting into separate pipelines or lifting the decision into calling code is usually the cheaper design.
## Branching is wiring, not code In an imperative chain you would write `if`. In Haystack the decision has to live in the graph, because the graph is what gets validated, serialized and drawn. The component for it is `ConditionalRouter`. ``` routes = [ {"condition": "{{ hits|length > 0 }}", "output": "{{ hits }}", "output_name": "found", "output_type": list}, {"condition": "{{ hits|length == 0 }}", "output": "{{ query }}", "output_name": "fallback", "output_type": str}, ] ``` Each route is a dict with four keys that do distinct jobs: - **condition** — a Jinja expression evaluated against the router's inputs. The variables you reference *become* the router's input sockets, which is how the router gets wired into the graph. - **output** — a Jinja expression producing the value to emit; often just a passthrough of one input. - **output_name** — becomes an output socket name you connect from. - **output_type** — the declared type of that socket, so the usual wiring-time compatibility check still applies to a branch edge. At run time the router evaluates conditions in order and emits on exactly one socket. Everything reachable only through the other sockets never receives its mandatory inputs and therefore never runs. That is the important operational property: an untaken branch costs nothing — no LLM call, no embedding, no latency. A route whose condition can never be true is dead wiring the framework will not warn you about, and if *no* condition matches you get a runtime error rather than a silent no-op. Write a catch-all last route when the input domain is open. ## Why joining needs a component Fan-out is free: one output socket may feed many receivers. Fan-in is not. A plain input socket holds exactly one incoming connection, so wiring both the `found` and the `fallback` branch into the same downstream input is rejected at `connect()` time. The rule exists because the pipeline must know unambiguously where a socket's value comes from. `BranchJoiner` is the escape hatch. You construct it with the type it carries — `BranchJoiner(str)` — and it exposes a **variadic** input (many connections allowed) and a single output of that same type. Whichever branch produced a value, the joiner forwards it, and the downstream component sees one ordinary edge. Because it is typed, the branches must agree on a type; that constraint is a feature, since it forces you to normalize divergent branches before they merge instead of downstream. Haystack ships other joiners for specific payloads — merging lists of answers, lists of strings, generic lists — and choosing the right one matters: a joiner that concatenates is not the same as one that forwards the first arrival. `BranchJoiner` is the one you reach for when the semantics are "exactly one of these branches ran". ## Other routers Not every branch needs a Jinja expression. `FileTypeRouter` sends files down per-MIME-type branches in an indexing pipeline; `MetadataRouter` splits documents by a metadata filter; there are model-based routers that classify text before branching. Use a purpose-built router when one exists — its conditions are declarative and self-documenting — and `ConditionalRouter` when the decision is genuinely bespoke. ## Shape of a real branching pipeline A typical grounded-answer flow: retrieve → router. If enough documents cleared the threshold, go down the RAG branch (prompt builder → generator). If not, go down a fallback branch (a canned "I don't know" builder, or a web-search component). Both branches ultimately produce a reply, so both feed a joiner, and the joiner feeds whatever formats the response. Diagrammed, this is four boxes and two edges more than a linear pipeline — and the diagram is exactly what `pipe.draw(path="pipeline.png")` gives you to put in a design review. (`draw()` renders through a Mermaid service, so it needs network access; in a notebook `pipe.show()` displays it inline.) ## Debugging branches When the wrong branch fires, the router's own output is the evidence you want, and by default it is consumed downstream and therefore absent from the result. Add the router's name to `include_outputs_from` and you see which socket carried a value. Second most common cause: a condition referencing a variable that is never supplied, so the router's input socket is unsatisfied and the router itself never runs — which looks identical to "the branch didn't fire" until you notice nothing downstream ran either. ## What to say about the tradeoff Branching in the graph is more verbose than an `if`, and it buys three things you cannot get from an `if`: the branch is visible in the drawn diagram, it survives serialization to YAML, and its edges are type-checked at wiring time. When a pipeline accumulates many routers, that verbosity stops paying and it is a signal to split the pipeline or push the decision up into calling code.
- What happens to components downstream of the route that was not selected?They never run. Their mandatory inputs never arrive, so the scheduler never marks them runnable, and their outputs are absent from the result. That is what makes routing cheap: the expensive components on the untaken branch cost no tokens and no latency. It also means "my generator produced nothing" is often a routing bug, not a generator bug.
- Why does BranchJoiner have to be constructed with a type?Because its variadic input and its single output are typed sockets like any other, and Haystack still type-checks every edge at wiring time. `BranchJoiner(str)` accepts many `str` senders and emits a `str`. The side effect is useful discipline: branches that merge must agree on a type, so you normalize divergent shapes before the join instead of leaving a downstream component to handle both.
- How do you find out which route actually fired on a given run?Add the router's component name to `include_outputs_from` on that run. Its output dict then appears in the result showing which output socket carried a value. If neither the router's output nor anything downstream appears, the router itself never ran — usually because a variable referenced in a condition was never supplied, leaving one of its input sockets unsatisfied.
saying these in an interview costs you the question
- Thinks all routes emit and downstream filters them
- Wires two branches into the same plain input socket
- Believes the untaken branch still runs and is discarded
- Says branching needs a custom component, not a router
- Assumes a router with no matching condition is a no-op