Skip to main content
For the complete documentation index optimized for AI agents, see llms.txt.
So far every parameterized example on the prior pages has shown p = paramsObject / Predicate.eqParam("prop", "name") / toDynamicJson(params, values, options) without dwelling on the parameter system itself. This page is the dedicated treatment: how to declare parameters, how to refer to them in traversals, and how to ship a parameterized query to the runtime.

SDK workflows

TypeScript, Rust, and Python support both direct dynamic requests and registered bundle workflows. Go v1 is dynamic-first: write normal Go functions that return helix.Request, set the query name with helix.ReadQuery("name") or helix.WriteQuery("name"), and declare runtime parameters inline on the query builder.
func FindUsers(tenantID string, limit int64) helix.Request {
	q := helix.ReadQuery("find_users")

	tenant := q.ParamString("tenant_id", tenantID)
	maxRows := q.ParamI64("limit", limit)

	return q.
		VarAs("users",
			helix.G().
				NWithLabel("User").
				Where(helix.PredEq("tenantId", tenant)).
				Limit(maxRows).
				ValueMap("$id", "name", "tenantId"),
		).
		Returning("users")
}

var out FindUsersResponse
err := client.Exec(ctx, FindUsers("acme", 25), &out)
The Go helpers insert both parameters and parameter_types in the dynamic envelope. Use q.ParamString, q.ParamI64, q.ParamF64, q.ParamF32, q.ParamBool, q.ParamDateTime, q.ParamValue, q.ParamObject, and q.ParamArray. Do not use .With(...), WithQueryName(...), or stored-query bundle APIs in the Go v1 workflow. Use MarshalRequest(req) only for tests, parity fixtures, or debugging. In Go, direct values are literals in the inline AST. helix.SourceEq("id", "foo") and helix.PredEq("id", "foo") embed "foo" directly; they do not create a runtime parameter. For values that change per request, declare a q.Param* value and pass the returned ref, for example id := q.ParamString("id", userID) and helix.SourceEq("id", id). That keeps the request shape stable for server-cache reuse. Python mirrors the TypeScript/Rust direct dynamic and bundle workflow with snake_case helpers:
from helixdb import Predicate, define_params, g, param, read_batch

params = define_params({"tenant_id": param.string(), "limit": param.i64()})

def find_users(p=params):
    return (
        read_batch()
        .var_as(
            "users",
            g()
            .n_with_label("User")
            .where(Predicate.eq("tenantId", p.tenant_id))
            .limit(p.limit)
            .value_map(["$id", "name", "tenantId"]),
        )
        .returning(["users"])
    )

request = find_users().to_dynamic_request(params, {"tenant_id": "acme", "limit": 25}, query_name="find_users")

Declaring parameters with defineParams / define_params

This section applies to TypeScript and Python bundle and dynamic helpers. Rust derives a similar schema from registered function arguments. Go declares the same schema inline through q.Param* methods as shown above. defineParams({...}) in TypeScript and define_params({...}) in Python take a record of parameter names to ParamSchema constructors and return a proxy. Each property of the returned proxy is a ParamRef you can pass into builder methods that expect one.
TypeScript
import { defineParams, param } from "@helix-db/helix-db";

const params = defineParams({
  tenant_id:     param.string(),
  username:      param.string(),
  limit:         param.i64(),
  query_vector:  param.array(param.f32()),
  created_after: param.dateTime(),
});
Python
from helixdb import define_params, param

params = define_params({
    "tenant_id": param.string(),
    "username": param.string(),
    "limit": param.i64(),
    "query_vector": param.array(param.f32()),
    "created_after": param.date_time(),
})
The supported constructors share the same wire types; Python uses snake_case for the datetime helper:
TypeScriptPythonWire typeAccepted dynamic values
param.bool()param.bool()Boolboolean / bool
param.i64()param.i64()I64safe integer, bigint, or Python int
param.f64() / param.f32()param.f64() / param.f32()F64 / F32finite number / float
param.string()param.string()Stringstring / str
param.dateTime()param.date_time()DateTimeDateTime, RFC3339 string, or epoch-millis integer; Python also accepts datetime.datetime
param.bytes()param.bytes()Bytesdeclarable, but dynamic JSON requests reject bytes values
param.value()param.value()Valueany JSON value
param.object(inner?)param.object(inner=None)Objectobject values matching the optional inner schema
param.array(inner)param.array(inner){Array: inner}arrays/lists matching the inner schema
param.array(param.f32()) is what produces the {"Array": "F32"} shape required for vector parameters. Use param.array(param.object(...)) for row-style inputs to forEachParam / for_each_param.

Referring to parameters inside a traversal

There are three ways a parameter shows up inside a query, depending on what kind of value the builder expects:
  1. Bound to a step argument that takes a ParamRef - for .limit(p.limit), .skip(p.skip), etc. The proxy returned by defineParams / define_params is the value to pass.
  2. Inside a predicate - TypeScript offers Predicate.eqParam("prop", "param_name") and the rest of the *Param family for string-keyed parameter refs. Python can pass the ParamRef directly, for example Predicate.eq("tenantId", p.tenant_id).
  3. As a property value or expression - PropertyInput.param("name"), Expr.param("name"), NodeRef.param("name"), and EdgeRef.param("name") all take the name as a string. In Python, p.tenant_id.input() is a shorthand when a method expects a PropertyInput.
In practice the proxy is mostly used to keep parameter names in sync - if you rename the field in defineParams / define_params, every reference through the proxy moves with it. For string-keyed refs (PropertyInput.param("name")), the name has to match exactly. A common convention is to assign the proxy to a p argument and read both forms from there:
function findUsers(p = params) {
  return readBatch().varAs(
    "users",
    g()
      .nWithLabel("User")
      .where(Predicate.eqParam("tenantId", "tenant_id"))   // string name
      .limit(p.limit)                                       // proxy ref
      .valueMap(["$id", "username", "tenantId"]),
  ).returning(["users"]);
}

Shipping a parameterized query

A query built with readBatch() / writeBatch() is sent to the runtime as a dynamic request. There are a few ways to produce that request — straight from a batch, from a registered bundle, or through the typed call helpers — but they all serialize to the same dynamic envelope you POST to /v1/query.

1. From a batch directly

The simplest case: build the batch, call .toDynamicJson(params, values, options) in TypeScript or .to_dynamic_json(params, values, query_name=...) in Python, then POST the resulting body to /v1/query. No deploy step. Best for ad-hoc queries, prototyping, and one-off integrations.
import { defineParams, g, param, Predicate, readBatch } from "@helix-db/helix-db";

const params = defineParams({ username: param.string() });

function userByUsername(p = params) {
  return readBatch()
    .varAs(
      "user",
      g().nWithLabel("User")
         .where(Predicate.eqParam("username", "username"))
         .valueMap(["$id", "username", "tier"]),
    )
    .returning(["user"]);
}
The dynamic envelope produced by .toDynamicJson(...) / .to_dynamic_json(...) is the body for POST /v1/query. Below are the five equivalent ways to send it:
import { Client } from "@helix-db/helix-db";

const client = new Client("https://helix.example.com");

const request = userByUsername().toDynamicRequest(
  params,
  { username: "alice" },
  { queryName: "user_by_username" },
);

const { user } = await client
  .query<{ user: unknown }>()
  .dynamic(request)
  .send();
For larger envelopes, write the JSON to a file and use --data-binary @file.json — the inline --data-raw form works for small bodies but gets unwieldy fast. The three serialization helpers on a batch:
TypeScriptPythonReturns
.toDynamicJson(params?, values?, options?).to_dynamic_json(params=None, values=None, query_name=...)JSON string (full envelope: request_type, query_name, query, parameters, parameter_types)
.toDynamicRequest(params?, values?, options?).to_dynamic_request(params=None, values=None, query_name=...)DynamicQueryRequest instance - useful when you want to mutate it before serializing
.toDynamicBytes(params?, values?, options?).to_dynamic_bytes(params=None, values=None, query_name=...)UTF-8 bytes of the JSON encoding
Pass { queryName: "..." } as the final TypeScript options argument, or query_name="..." in Python, when you want gateway logs and query diagnostics to identify a direct inline request. Direct requests without a name serialize query_name: null, which the gateway records as the fallback dynamic name __dynamic__. For a parameter-less batch, omit params and values; pass only the name option if you want to name the request:
readBatch().varAs("count", g().nWithLabel("User").count()).toDynamicJson({
  queryName: "count_users",
});
.toJsonString() / .to_json_string() returns the raw batch (no envelope, no parameters), the form that goes into a bundle - covered below.

2. Registered bundles

Registered bundles are available in TypeScript, Rust, and Python. Go v1 does not generate or register bundles; keep Go application queries dynamic-first with client.Exec(ctx, request, &out). To keep a set of queries organized and generate a single queries.json artifact, wrap each query function with registerRead or registerWrite and pass them all to defineQueries. The bundle’s generate(path) method writes a single queries.json in the exact shape the runtime expects.
import {
  defineParams,
  defineQueries,
  g,
  param,
  Predicate,
  PropertyInput,
  readBatch,
  registerRead,
  registerWrite,
  writeBatch,
} from "@helix-db/helix-db";

const readParams = defineParams({ username: param.string() });

function userByUsername(p = readParams) {
  return readBatch()
    .varAs(
      "user",
      g().nWithLabel("User")
         .where(Predicate.eqParam("username", "username"))
         .valueMap(["$id", "username", "tier"]),
    )
    .returning(["user"]);
}

const writeParams = defineParams({
  username: param.string(),
  tier:     param.string(),
});

function addUser(p = writeParams) {
  return writeBatch()
    .varAs(
      "newUser",
      g().addN("User", {
        username: PropertyInput.param("username"),
        tier:     PropertyInput.param("tier"),
      }),
    )
    .returning(["newUser"]);
}

export const queries = defineQueries({
  read:  { user_by_username: registerRead(userByUsername, readParams) },
  write: { add_user:         registerWrite(addUser, writeParams) },
});

// Build queries.json at deploy time.
await queries.generate("queries.json");
The resulting bundle has this top-level shape:
{
  "version": 4,
  "read_routes":  { "user_by_username": { "queries": [...], "returns": ["user"] } },
  "write_routes": { "add_user":         { "queries": [...], "returns": ["newUser"] } },
  "read_parameters": {
    "user_by_username": [{ "name": "username", "ty": "String" }]
  },
  "write_parameters": {
    "add_user": [
      { "name": "username", "ty": "String" },
      { "name": "tier",     "ty": "String" }
    ]
  }
}
Each read_routes.<name> / write_routes.<name> is exactly what .toJsonString() would have produced for the underlying batch — no envelope. To execute one of these queries, produce its dynamic envelope through the typed call helpers below and POST it to /v1/query. Route names must be unique across reads and writes; duplicates throw GenerateError at bundle time.

3. Typed call helpers

defineQueries(...) / define_queries(...) also exposes a call map. Each entry is a function that takes the route’s parameter values and returns a serializable DynamicQueryRequest. TypeScript infers the input type from defineParams; Python validates unknown keys, missing keys, and wrong value types at call time.
queries.call.user_by_username({
  username: "alice",
  unexpected: true,  // ts(...): Object literal may only specify known properties
});

queries.call.user_by_username({
  username: 1,  // ts(...): Type 'number' is not assignable to type 'string'
});
The returned object serializes to the same dynamic envelope you would build by hand, so you POST it to /v1/query:
import { Client } from "@helix-db/helix-db";

const client = new Client("https://helix.example.com");

const { user } = await client
  .query<{ user: unknown }>()
  .dynamic(queries.call.user_by_username({ username: "alice" }))
  .send();
queries.call.* keeps parameter names and types in sync with defineParams / define_params, so it is the most convenient way to send a registered query - in application code, tests, and scripts alike. The returned dynamic request sets query_name to the route key (user_by_username in this example) automatically.

How parameters serialize

Inside the AST, parameter references appear as {"Param": "name"} wrapped in the spot the value would go: {"Limit": ... } becomes {"LimitBy": {"Param": "n"}}, {"Has": ["k", value]} becomes {"Where": {"Compare": {"left": {"Property": "k"}, "op": "Eq", "right": {"Param": "k"}}}}, and so on. The Filtering page tabulates every shape. At envelope position, the actual value is a bare JSON value, not a tagged PropertyValue. The shape {"Array": "F32"} you see in the parameter_types map is QueryParamType, the runtime coercion schema. A complete envelope produced by toDynamicJson / to_dynamic_json:
{
  "request_type": "read",
  "query_name": "user_by_username",
  "query": { "queries": [...], "returns": [...] },
  "parameters": {
    "username":      "alice",
    "limit":         25,
    "created_after": "2026-04-05T10:34:56.789Z",
    "labels":        { "status": "active" }
  },
  "parameter_types": {
    "username":      "String",
    "limit":         "I64",
    "created_after": "DateTime",
    "labels":        "Object"
  }
}
Nested object and array parameter values are still bare JSON at envelope position. When a parameter is used as a property value, the runtime converts that JSON object into a stored object property:
const params = defineParams({ metadata: param.object(param.value()) });

writeBatch().varAs(
  "user",
  g().addN("User", { metadata: PropertyInput.param("metadata") }),
);
{
  "parameters": {
    "metadata": {
      "externalID": "crm-42",
      "preferences": { "locale": "en-US" },
      "tags": ["trial", 7]
    }
  },
  "parameter_types": { "metadata": "Object" }
}
After storage, query nested object fields with dotted paths such as metadata.externalID. The top-level parameters map itself does not use tagged PropertyValue wrappers.

Number handling: bigint for i64

JavaScript number only has 53 bits of integer precision. The TypeScript DSL accepts a plain number wherever it fits in a safe integer range, but for full i64 (node ids, large counts, epoch nanos) you should pass a bigint:
g().n(9223372036854775807n);
PropertyValue.i64(9223372036854775807n);
Python int values are arbitrary precision, so the Python SDK validates the value against the i64 range and serializes it directly. stringifyJson, .toJsonString() / .to_json_string(), and serializeQueryBundle preserve large integers without loss. Plain JSON.stringify does not - use the package serializers when your TypeScript payload may contain large integers.

DateTime handling

DateTime is the dedicated value type for timestamps. It stores epoch milliseconds and round-trips losslessly through the dynamic envelope as a UTC RFC3339 string.
import { DateTime } from "@helix-db/helix-db";

DateTime.fromMillis(-1).toRfc3339();
// "1969-12-31T23:59:59.999Z"

DateTime.parseRfc3339("2026-04-05T12:34:56.789+02:00").toRfc3339();
// "2026-04-05T10:34:56.789Z"  (normalized to UTC)
When a parameter is declared as param.dateTime() in TypeScript or param.date_time() in Python, the call helpers accept DateTime, an RFC3339 string, or an epoch-millis integer. Python also accepts datetime.datetime:
queries.call.recent_posts({
  since: DateTime.fromMillis(0),
});
queries.call.recent_posts({
  since: "2026-01-01T00:00:00Z",  // auto-parsed
});
queries.call.recent_posts({
  since: 1735689600000n,
});
All of these values serialize to the same envelope:
{
  "parameters":      { "since": "2026-01-01T00:00:00.000Z" },
  "parameter_types": { "since": "DateTime" }
}

Bytes parameters

param.bytes() is declarable in defineParams / define_params, but the dynamic envelope cannot round-trip binary values faithfully. Attempting to call toDynamicJson(params, { payload: someBytes }), to_dynamic_json(params, {"payload": b"..."}), or to build a request with a bytes parameter through queries.call.* throws an unsupported-bytes dynamic query error. If you need to pass binary data, send it through a binary-aware client (the runtime’s RPC interface) rather than the JSON dynamic envelope, which is the bottleneck.

Generating the bundle from a script

The queries.generate(path) shortcut is enough for most projects. If you want to inspect or post-process the bundle first, the lower-level helpers are exported too:
import {
  serializeQueryBundle,
  writeQueryBundleToPath,
  deserializeQueryBundle,
  readQueryBundleFromPath,
} from "@helix-db/helix-db";

const bundle = queries.buildQueryBundle();
const json = serializeQueryBundle(bundle);
await writeQueryBundleToPath(bundle, "queries.json");

// Reading and validating an existing bundle:
const loaded = deserializeQueryBundle(json);              // throws if invalid
const fromFile = await readQueryBundleFromPath("queries.json");
deserializeQueryBundle / deserialize_query_bundle validates version === 4 (the current bundle version); it will reject older or newer bundles so you catch mismatches at load time rather than at deploy time.

Next Steps

Working with HelixDB

Deploy queries.json and the runtime workflow.

Querying overview

Conceptual model: dynamic queries, transactions, and the HTTP surface.

Querying Guide: overview

Back to the start of the guide.

npm package

@helix-db/helix-db on npm.