Skip to main content
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?
import {
  g,
  NodeRef,
  readBatch,
  RepeatConfig,
  SourcePredicate,
  sub,
} from "@helix-db/helix-db";

const aliceReach = readBatch()
  .varAs("alice", g().nWhere(SourcePredicate.eq("username", "alice")))
  .varAs(
    "within_3",
    g()
      .n(NodeRef.var("alice"))
      .repeat(RepeatConfig.new(sub().out("FOLLOWS")).times(3).emitAll())
      .dedup()
      .valueMap(["$id", "username"]),
  )
  .returning(["within_3"]);
The 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.
import { g, NodeRef, readBatch, SourcePredicate, sub } from "@helix-db/helix-db";

const feed = readBatch()
  .varAs("user", g().nWhere(SourcePredicate.eq("username", "alice")))
  .varAs(
    "posts",
    g()
      .n(NodeRef.var("user"))
      .union([sub().out("AUTHORED"), sub().out("LIKED")])
      .dedup()
      .valueMap(["$id", "title"]),
  )
  .returning(["posts"]);

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:
import { g, Predicate, readBatch, sub } from "@helix-db/helix-db";

const recommendedPosts = readBatch()
  .varAs(
    "posts",
    g()
      .nWithLabel("User")
      .choose(
        Predicate.eq("tier", "pro"),
        sub().out("PRO_FEED"),
        sub().out("DEFAULT_FEED"),
      )
      .valueMap(["$id", "title"]),
  )
  .returning(["posts"]);

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”.
g()
  .n(NodeRef.var("user"))
  .coalesce([
    sub().out("PRIMARY_FEED"),
    sub().out("DEFAULT_FEED"),
  ])
The JSON form:
{
  "Coalesce": [
    { "steps": [{ "Out": "PRIMARY_FEED" }] },
    { "steps": [{ "Out": "DEFAULT_FEED" }] }
  ]
}

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.
g().nWithLabel("User").optional(sub().out("POSTED"));
{ "Optional": { "steps": [{ "Out": "POSTED" }] } }

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.
readBatch()
  .varAs("user", g().nWhere(SourcePredicate.eq("username", "alice")))
  .varAsIf(
    "posts",
    BatchCondition.varNotEmpty("user"),
    g().n(NodeRef.var("user")).out("AUTHORED").valueMap(["$id", "title"]),
  )
  .returning(["user", "posts"]);
In the JSON, the condition field on the conditional Query entry carries the constraint:
{
  "Query": {
    "name": "posts",
    "steps": [
      { "N":         { "Var": "user" } },
      { "Out":       "AUTHORED" },
      { "ValueMap": ["$id", "title"] }
    ],
    "condition": { "VarNotEmpty": "user" }
  }
}

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.
import {
  defineParams,
  g,
  param,
  PropertyInput,
  writeBatch,
} from "@helix-db/helix-db";

const params = defineParams({
  users: param.array(param.object(param.string())),
});

function bulkAddUsers(p = params) {
  return writeBatch()
    .forEachParam(
      "users",
      writeBatch().varAs(
        "created",
        g().addN("User", {
          username: PropertyInput.param("username"),
          tier:     PropertyInput.param("tier"),
        }),
      ),
    )
    .returning(["created"]);
}

const body = bulkAddUsers().toDynamicJson(params, {
  users: [
    { username: "alice", tier: "pro" },
    { username: "bob",   tier: "free" },
  ],
});
A few notes specific to 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 as metadata.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.
import { BindingProjection, g, readBatch, sub } from "@helix-db/helix-db";

function serviceTopology() {
  return readBatch()
    .varAs(
      "rows",
      g()
        .nWithLabel("Service")
        .bind("service")
        .out("ROUTES_TO").bind("pod")
        .optional(sub().in("CREATES").bind("deployment"))
        .union([
          sub().in("MANAGES").bind("owner"),
          sub().out("ROUTES_TO").bind("workload"),
        ])
        .projectDistinctBindings([
          BindingProjection.binding("service", "$id", "service_id"),
          BindingProjection.binding("pod", "name", "pod_name"),
          BindingProjection.coalesce([
            BindingProjection.bindingRef("deployment", "$id"),
            BindingProjection.bindingRef("owner", "$id"),
          ], "workload_id"),
        ]),
    )
    .returning(["rows"]);
}
A few notes on row bindings:
  • A projection’s source accepts stored properties and the virtual fields $id, $label, $from, $to, $distance, $score — the same set as .project(...). The target is either a named binding or the current element (BindingProjection.current(...) in TS, "Current" on the wire).
  • coalesce returns the first reference whose value is present and non-null, which is the idiomatic way to merge alternative branches of a union into one column.
  • projectBindings keeps one row per surviving path including duplicates; projectDistinctBindings deduplicates identical projected rows. A wide union or repeat fan-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.