helix-query-authoring
Helix Query Authoring
Write Helix Rust DSL queries in a way that is schema-aware, explicit, and easy for agents to reason about.
When To Use
Use this skill when the task is to:
- write a new Helix query in Rust
- revise an existing Helix Rust DSL route
- add a stored query for deployment
- choose between
read_batch()andwrite_batch() - add traversal, projection, pagination, BM25 search, or vector search to an existing query
Do not use this skill as the main guide for inline POST /v1/query payloads. Use the dynamic-query skill for that.
First Steps
Before writing any query code:
- Inspect the local repo for existing labels, edge labels, properties, and route patterns.
- Find the closest existing query and reuse its naming, projection, and scoping style.
- Decide whether the route is a read or a write.
- Identify the narrowest indexed anchor before planning the traversal.
If the local repo is thin on Helix examples, use the companion files in this skill:
EXAMPLES.md— working end-to-end Rust queries (reads, writes, search, repeat, branching, upsert,for_each_param).REFERENCE.md— full builder catalog organized by category, with typestate notes.
Open REFERENCE.md whenever you need a builder beyond the common surface (add_e, drop_edge_by_id, create_vector_index_nodes, repeat, choose, coalesce, optional, aggregate_by, group_count, inject, order_by_multiple, expression case, etc.) — do not invent method names from memory.
Core Authoring Rules
1. Start With The Right Batch Type
Use:
read_batch()for read-only routeswrite_batch()for any mutation
If the query adds nodes, adds edges, updates properties, or deletes graph data, it is a write route.
2. Anchor Narrow, Then Traverse
Prefer this anchor order:
- node ID or edge ID
- unique property lookup
- equality-indexed property lookup
- scoped label scan
- broad label scan as a last resort
Do not start from a broad label scan when the application already has an indexed identifier like entityId, externalId, userId, tenantId, or a similar key.
3. Reuse Existing Property And Label Casing
Do not normalize names to your own preferred style.
If the application uses entityId, updatedAt, FOLLOWS, or RelatesTo, reuse those exact names.
4. Filter Early
Apply scope and status filters before broad traversal whenever possible.
Common examples:
- tenant filters like
tenantIdoruserId - soft-delete or archived filters such as empty or null
deletedAt - specific ID filters before
both,out, orin_
5. Keep Output Shape Intentional
Use:
project(...)for stable service-facing response shapesvalue_map(...)when returning all or many properties is acceptableedge_properties()for edge streams
Do not return oversized properties like embeddings unless the caller explicitly needs them.
6. Preserve Search Scope
For BM25 and vector search:
- keep the chosen text or vector property explicit
- preserve tenant scope when the index is scoped
- post-filter only when the search API cannot express the scope directly
7. Use Traversal Controls Deliberately
Apply dedup, limit, range, skip, count, and first because the route needs them, not by habit.
repeat(...) is often used with a deliberate bounded depth. Do not assume arbitrary runtime repeat depth unless the local code already supports it.
8. Prefer Explicit Write Branching Over Invented MERGE Semantics
When you need create-or-update behavior, follow this pattern:
- load existing nodes
- branch with
var_as_if - update when found
- create when missing
9. Know The Full Builder Surface
The DSL is larger than the canonical examples below suggest. Before reaching for a workaround, check REFERENCE.md — there is likely a direct builder.
| Category | Primary builders | Notes |
|---|---|---|
| Sources | g().n(...), n_where, n_with_label, n_with_label_where, e, e_where, e_with_label, e_with_label_where, vector_search_nodes_with, text_search_nodes_with, vector_search_edges_with, text_search_edges_with |
Anchor narrowly — indexed ID first, then label scope. |
| Traversal | out, in_, both, out_e, in_e, both_e, out_n, in_n, other_n |
Edge-valued forms (*_e) switch the stream type. |
| Filters | has, has_label, has_key, where_, dedup, within, without, edge_has, edge_has_label |
Predicate::* + Predicate::*_param for parameterized comparisons. |
| Limits | limit, skip, range |
All accept usize or Expr. |
| Variables | as_ / store, select, inject |
Cross-query refs via NodeRef::var, EdgeRef::var, NodeRef::param, EdgeRef::param. |
| Ordering | order_by, order_by_multiple |
Use Order::Desc for descending. |
| Aggregation | count, exists, group, group_count, aggregate_by |
AggregateFunction::{Count,Sum,Min,Max,Mean}. |
| Branching | union, choose, coalesce, optional |
Each arm is a sub() sub-traversal. |
| Repeat | repeat(RepeatConfig::new(sub).times(n).until(pred).emit_all().max_depth(100)) |
Always bound with times or until; default max_depth is 100. |
| Projection | values, value_map, project, edge_properties |
project mixes PropertyProjection (incl. renames) and ExprProjection. |
| Expressions | Expr::prop, Expr::val, Expr::id, Expr::timestamp, Expr::datetime, Expr::param, .add/.sub/.mul/.div/.modulo/.neg, Expr::case |
Expr::Timestamp writes server UTC millis; Expr::DateTimeNow writes typed datetime. |
| Mutations | add_n, add_e, set_property, remove_property, drop, drop_edge, drop_edge_labeled, drop_edge_by_id |
drop_edge_by_id is multigraph-safe. |
| Indexes | IndexSpec::node_equality / node_range / edge_equality / edge_range / node_vector / node_text / edge_vector / edge_text plus create_index / drop_index; convenience: create_vector_index_nodes, create_text_index_nodes, edge variants |
Use .create_index(spec) from a write batch. |
| Transport | DynamicQueryRequest::{read,write}(batch).with_parameter_value(...).with_parameter_type(...).to_json_string() |
Bridge from Rust DSL to the JSON payload (helix-query-json-dynamic). |
See REFERENCE.md for signatures and typestate constraints.
Canonical Examples
Read By Indexed Identifier
read_batch()
.var_as(
"user",
g().n_with_label("User")
.where_(Predicate::eq_param("userId", "userId"))
.project(vec![
PropertyProjection::new("$id"),
PropertyProjection::new("userId"),
PropertyProjection::new("name"),
]),
)
.returning(["user"])
Explicit Create Or Update
write_batch()
.var_as(
"existing",
g().n_with_label("User")
.where_(Predicate::eq_param("userId", "userId")),
)
.var_as_if(
"updated",
BatchCondition::VarNotEmpty("existing".to_string()),
g().n(NodeRef::var("existing"))
.set_property("name", PropertyInput::param("name")),
)
.var_as_if(
"created",
BatchCondition::VarEmpty("existing".to_string()),
g().add_n(
"User",
vec![
("userId", PropertyInput::param("userId")),
("name", PropertyInput::param("name")),
],
),
)
.returning(["updated", "created"])
Scoped Search Route
read_batch()
.var_as(
"results",
g().vector_search_nodes_with(
"Document",
"embedding",
PropertyInput::param("queryVector"),
Expr::param("limit"),
Some(PropertyInput::param("tenantId")),
)
.project(vec![
PropertyProjection::new("$id"),
PropertyProjection::new("title"),
PropertyProjection::renamed("$distance", "distance"),
]),
)
.returning(["results"])
Anti-Patterns
Do not:
- invent labels, edge labels, or property names without checking the codebase
- start from broad scans when an indexed ID or scoped predicate exists
- return embeddings by default in search results
- ignore tenant scope on text or vector search
- add
deduporlimitwithout a reason - assume dynamic inline-query rules apply to stored Rust DSL routes
- treat BM25 as if it searches every property automatically
Validation Checklist
Before finishing:
- verify
read_batch()versuswrite_batch()is correct - verify labels, edge labels, and properties match the repo exactly
- verify the first anchor is the narrowest practical indexed set
- verify scope filters happen before or as early as possible
- verify the returned variable names and shape match service expectations
- verify text and vector routes preserve tenant scope when required
- verify large properties are omitted unless needed
- verify the query matches surrounding local style more than any generic example
Reference Files
REFERENCE.md— full builder catalog (sources, traversal, predicates, expressions, projections, branching, repeat, mutations, indexes, dynamic-request transport).EXAMPLES.md— end-to-end Rust queries mirroring the scenarios inhelix-query-json-dynamic/EXAMPLES.md, so you can move fluently between Rust DSL and JSON forms.
More from helixdb/skills
helix-query-json-dynamic
Build and validate HelixDB dynamic inline-query requests for POST /v1/query. Use when the task involves dynamic queries, inline query JSON, the inline AST (steps, predicates, expressions, projections), parameter_types, DateTime coercion, query warming, or debugging a request body sent directly to the Helix gateway. See REFERENCE.md for every AST variant and EXAMPLES.md for copy-pasteable payloads.
10helix-query-optimize
Review and improve HelixDB query performance and query shape. Use when the task is to optimize a slow Helix query, improve anchor choice, tighten index usage, reduce traversal breadth, slim projections, fix BM25 or vector search scope, or decide between stored and dynamic routes.
8helix-query-from-gremlin
Translate Gremlin and TinkerPop-style traversals into HelixDB Rust DSL stored queries. Use when the input contains Gremlin, TinkerPop, g.V, g.E, hasLabel, has, out, in, both, outE, inE, repeat, emit, dedup, valueMap, count, range, or limit and the goal is to produce an equivalent Helix Rust query.
7helix-query-from-cypher
Translate Cypher and Neo4j-style queries into HelixDB Rust DSL stored queries. Use when the input contains Cypher, Neo4j, MATCH, OPTIONAL MATCH, WHERE, RETURN, ORDER BY, LIMIT, DISTINCT, MERGE, CASE, UNWIND, FOREACH, DETACH DELETE, IS NULL, or variable-length path patterns and the goal is to produce an equivalent Helix Rust query.
7