For the complete documentation index optimized for AI agents, see llms.txt.This page covers the steps that branch, repeat, or fan-out inside a single traversal:
repeat, union, choose, coalesce, optional, and — at the batch
level — forEachParam. All five sub-traversal-shaped steps share one helper:
sub(), which opens a fresh traversal builder that does not start with g().
sub() is to in-line traversals what g() is to top-level ones. It has the same
chainable surface (out, where, dedup, etc.) but it is not a complete query on
its own — it is always passed into a parent step.
repeat: variable-length traversal
.repeat(RepeatConfig.new(sub()...).times(n)) walks the same sub-traversal n
times, threading the output of each iteration into the input of the next. By
default, only the final frontier is emitted. Add .emitAll() to keep every
intermediate frontier; add .emitBefore() / .emitAfter() to keep only one side.
A two-hop reachability example: from Alice, who is followed-of-followed-by within
three hops?
max_depth: 100 field in the JSON is a safety ceiling — change it by chaining
.maxDepth(n) on the RepeatConfig. To stop early when a predicate is satisfied
instead of after n steps, swap .times(3) for .until(predicate).
union: combine multiple sub-traversals
.union([sub()..., sub()...]) runs each sub-traversal from the current stream and
emits the concatenation. Use it when “the result is either A or B” — e.g. a personal
feed that mixes posts you authored and posts you liked.
choose: per-item if/else
.choose(predicate, thenSub, elseSub?) evaluates the predicate per item and routes
each item through the matching sub-traversal. The optional else arm defaults to
“drop the item” — pass null (or omit it in Rust) for that.
Route paid-tier users through a premium feed edge and everyone else through the
default feed, then project the same fields from whichever branch matched:
coalesce: first non-empty branch wins
.coalesce([sub()..., sub()..., sub()...]) tries each sub-traversal in order per
item and returns the first one that produces results. Useful for “prefer A, fall
back to B”.
optional: keep the input even if the sub-traversal is empty
.optional(sub()) runs the sub-traversal and lets each item through unchanged if the
sub-traversal produced no result. Think SQL LEFT JOIN: you get the original row
whether or not the optional walk matched anything.
Batch-level conditionals: varAsIf
Inside a readBatch / writeBatch, .varAsIf(name, BatchCondition.*, traversal)
makes a whole varAs step conditional on the result of an earlier one. This is the
upsert pattern from the Mutations
page, but it’s just as useful on the read side — fetch related rows only if the
primary lookup produced something.
condition field on the conditional Query entry carries the
constraint:
Fan-out over array parameters: forEachParam
.forEachParam("paramName", body) iterates over an array-of-object parameter and
runs body once per element. Inside body, each object’s keys are addressable as
plain PropertyInput.param(key) references. This is the standard shape for
bulk-insert routes.
forEachParam:
- The body is a whole batch (
readBatch()/writeBatch()), not a sub-traversal. This lets you bind multiple variables per iteration if needed. - Bindings inside the loop body are per-iteration. The outer
returning([...])receives the per-iteration results collected into one array. - The parameter must be typed
{"Array": "Object"}— an array of plain JSON objects.defineParams({ users: param.array(param.object(...)) })produces this shape automatically. - Each loop iteration exposes the current object’s top-level fields as scoped
parameters. If a field itself is an object, pass it through as a whole property
value, for example
PropertyInput.param("metadata"), then query the stored nested fields later with dotted paths such asmetadata.externalID.
Correlate hops in one row: bind + projectBindings
A normal terminal (.valueMap, .project) only sees the final stream, so it
can’t return a value captured earlier in the same traversal alongside a value
from a later hop. Row bindings solve this: tag elements with .bind(name) as the
traversal passes them, then assemble the output rows from those named bindings
with .projectBindings([...]) (preserves duplicate rows) or
.projectDistinctBindings([...]) (dedups identical rows).
.bind() does not change the stream — each path keeps its own row-local
bindings, so hops inside union, optional, and choose can still reference
captures made before the branch. Each projection reads from a binding (or the
current element) and emits one output column; coalesce takes the first present
non-null reference from a list.
Row bindings are available in the TypeScript, Rust, and Go SDKs. The Python
SDK does not generate them yet — from Python, hand-write the
Bind /
ProjectBindings JSON AST shown in the JSON tab below.- A
projection’ssourceaccepts stored properties and the virtual fields$id,$label,$from,$to,$distance,$score— the same set as.project(...). Thetargetis either a named binding or the current element (BindingProjection.current(...)in TS,"Current"on the wire). coalescereturns the first reference whose value is present and non-null, which is the idiomatic way to merge alternative branches of aunioninto one column.projectBindingskeeps one row per surviving path including duplicates;projectDistinctBindingsdeduplicates identical projected rows. A wideunionorrepeatfan-out can multiply rows, so prefer the distinct form unless you need the duplicates.- Binding queries serialize at query-bundle v5. SDKs still read older v4 bundles, so this is backward-compatible for existing deployments.
Next Steps
Parameters and bundles
defineParams, dynamic requests, bundles, typed call helpers.Querying overview
The conceptual story: dynamic queries, transactions, and the HTTP surface.