Querying OQL — quick reference
An OQL canister exposes two read-only methods:
| Method | Returns | Purpose |
|---|
| one JSON | Catalogue of the canister's entities: each entity's primary key, fields, and edges. |
| typed Candid | Runs a JSON-encoded query and returns matching rows. |
Calling the canister
The
CLI is already installed and configured in the sandbox; the
canister name
resolves to the project's canister (no identity,
no canister ID). Both methods are
calls, so every invocation
uses
:
bash
icp canister call backend schema '()' --query
icp canister call backend execute '("<json-query>")' --query
takes one
argument — the JSON query embedded as a
Candid text literal. Wrap the JSON in
and escape every
as
. The query
{"start":"customer","limit":3}
becomes:
bash
icp canister call backend execute '("{\"start\":\"customer\",\"limit\":3}")' --query
returns its JSON the same way — a Candid
literal
; unescape
→
(and
→
) to read
it. Add
to read the deployed canister instead of the
draft (live is query-only). If a string value contains a single quote,
escape it for the shell with
.
Recipe
- Get the schema once.
icp canister call backend schema '()' --query
— cache it for the session; it changes only between deployments (§1).
- Map the request to entities. Pick the entity that holds the answer. Use each field's and to choose literal types, and to see how entities connect.
- Translate into one or more queries. Start from the entity whose rows you want (§2). Add (§2.1), / / , , and / (§2.2). Cross a forward edge with a dotted path in a single query (§4.1); a reverse one-to-many needs the parent keys first, then (§4.2).
- Run and read.
icp canister call backend execute '("<json>")' --query
— parse the Candid rows by cell (§3); if , page with (§5).
- Retry on traps. There is no error envelope — re-read the schema, fix the query, rerun (§6).
1. Discover —
Fetch once and cache for the session — it only changes between canister
deployments.
bash
icp canister call backend schema '()' --query
Read it like this:
- → entity name; use it as in queries.
- → field whose value identifies a row. An edge
value is a primary-key value in that target.
- → each field's , scalar , and :
(plain field) or
{"edge": {"to": "<entity>"}}
(a foreign
key — how you traverse the graph). Names may carry a , , …
suffix when two columns would share a name — use the exact names
reports.
- (optional) → the exact literals a field can hold
(typically a variant's arms). Filter with those literals, not guesses:
["free","pro","enterprise"]
means query , not
. Absent ⇒ unbounded — sample it with a query if you
need candidates.
- → JSON literal type for :
- → unsigned integer (, , …)
- → signed integer (, , , …)
- → JSON number with a decimal point (, ,
). A bare integer () is also accepted — numeric variants
bridge, so matches a row.
Float equality is bitwise IEEE-754; use a range ( + ) for
decimals like with no exact binary form.
- → /
- → JSON string. fields report as
(canonical textual form) — filter them with a string value.
2. Form a query —
A query is a single JSON object. Only
is required.
json
{
"start": "<entityName>",
"where": <Predicate>,
"groupBy": ["<fieldName>", ...],
"aggregate": [{ "fn": "count|sum|avg|min|max", "field": "<fieldName>", "as": "<outName>" }, ...],
"orderBy": [{ "field": "<fieldName>", "dir": "asc|desc" }, ...],
"offset": <Nat>,
"limit": <Nat>,
"select": ["<fieldName>", ...]
}
| Field | Default | Notes |
|---|
| (required) | An entity from . |
| omit ⇒ no filter | A single predicate (§2.1) — not wrapped in . |
| | Bucket rows by these fields; one output row per distinct combination (§2.2). |
| | Aggregates per bucket, or over all rows when is empty (§2.2). |
| (canister-defined order, typically insertion order) | Multi-key sort, first clause primary. defaults . |
| | Drop the first N matches. |
| every match | Keep at most N. in the result tells you if more exist. |
| every non-hidden field (or, when aggregating, group-key + aggregate columns) | Subset projection. |
bash
icp canister call backend execute '("{\"start\":\"customer\",\"limit\":3}")' --query
Filter + sort + project — the core shape (
+
+
+
):
bash
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"plan\",\"value\":\"enterprise\"}},\"orderBy\":[{\"field\":\"monthlyRevenueUsd\",\"dir\":\"desc\"}],\"limit\":5,\"select\":[\"companyName\",\"monthlyRevenueUsd\",\"accountManagerName\"]}")' --query
2.1 Predicate operators
A
is a JSON object with
exactly one key that names the
operator.
| Operator | Shape | Meaning |
|---|
| / / / / / | {"<op>": { "field": "<name>", "value": <scalar> } }
| Scalar relation. |
| {"in": { "field": "<name>", "value": [<scalar>, ...] } }
| Membership; empty array matches nothing. |
| / / | {"<op>": { "field": "<name>", "value": "<text>" } }
| Case-sensitive substring / prefix / suffix on — server-side scan, no need to page rows into context. |
| {"icontains": { "field": "<name>", "value": "<text>" } }
| Case-insensitive . Prefer this for user-typed search terms. |
| / / | / / | Boolean composition. |
Text search runs server-side — "the customer whose name mentions north" is one query, not a row scan into context:
bash
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"icontains\":{\"field\":\"companyName\",\"value\":\"north\"}},\"select\":[\"companyName\",\"accountManagerName\"]}")' --query
| JSON | Maps to | Use for fields with typeName |
|---|
| | any nullable field (rare in ) |
| / | | |
| , , | | (also matches via numeric bridging) |
| , | | (also matches via numeric bridging) |
| , , | | |
| | |
A row whose field is
fails every relation
except . Filter
by relationship with
=
and
= the target
entity's
primary-key value; or read
through an edge with
(§4.1).
2.2 Aggregate — count, groupBy, sum/avg/min/max
Compute on the canister instead of fetching every row and tallying
client-side.
is
/
/
/
/
;
is required
for every fn except
;
/
also work on text.
renames
the output column (default
,
, …) and must not
contain
(dots are the edge-traversal separator — parse error). For a
dotted
the default joins segments with
(
of
→
).
with no
→ one
row over the whole filtered set (
of an empty match is
).
with no
→ a server-side DISTINCT. Output rows
contain only the group-key + aggregate columns.
"How many enterprise customers?" —
over a filtered set, one row out:
bash
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"plan\",\"value\":\"enterprise\"}},\"aggregate\":[{\"fn\":\"count\"}]}")' --query
"Which account manager has the most customers, and total MRR?" —
+
+
:
bash
icp canister call backend execute '("{\"start\":\"customer\",\"groupBy\":[\"accountManager\"],\"aggregate\":[{\"fn\":\"count\"},{\"fn\":\"sum\",\"field\":\"monthlyRevenueUsd\",\"as\":\"mrr\"}],\"orderBy\":[{\"field\":\"count\",\"dir\":\"desc\"}],\"limit\":1}")' --query
3. Read the result
candid
type Value = variant { null_; bool : bool; nat : nat; int : int; float : float; text : text };
type Cell = record { name : text; value : Value };
type Result = record { rows : vec vec Cell; hasMore : bool };
The outer
is the row list; each inner
is one row. Each
record { value = variant { "<tag>" = <payload> }; name = "<field>" }
is one cell —
tells you which field, the
tells you the
scalar type, the payload is the value.
underscores are
digit separators — strip them if parsing.
⇒ you got
every match;
⇒ truncated, fetch the next page. Look
cells up by
, not position — order shifts if
changes.
4. Walk edges (joins)
Forward (single-valued) relationships are one query: a dotted path
crosses a declared edge, in any field position.
Reverse (one-to-many)
relationships stay two queries with the
pattern (§4.2).
4.1 Forward (child → parent): dotted paths
"<edgeField>.<targetField>"
reads through the edge server-side — in
,
,
,
, and
. Project
through an edge in one query:
bash
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"companyName\",\"value\":\"Northstar Public\"}},\"select\":[\"companyName\",\"accountManager.name\",\"accountManager.office\"]}")' --query
Multi-hop chains work (
"manager.department.name"
, max 4 hops), and it
composes with aggregation — "average revenue by the account manager's
office" is one call:
bash
icp canister call backend execute '("{\"start\":\"customer\",\"groupBy\":[\"accountManager.office\"],\"aggregate\":[{\"fn\":\"avg\",\"field\":\"monthlyRevenueUsd\",\"as\":\"avg_mrr\"}],\"orderBy\":[{\"field\":\"avg_mrr\",\"dir\":\"desc\"}]}")' --query
Rules:
- The head segment must be a field whose is
in — a dotted path into a
non-edge field traps, even if its values look like foreign keys
(traversal is schema-driven, not name-guessed). If the author didn't
declare the edge, fall back to the two-query pattern below.
- A null or dangling FK resolves the whole dotted path to
(left-join): the row fails every relation except , and projects
the cell as .
- Aggregate from the many side. Cross-entity aggregates run over the
start entity's rows: of from is
employee-weighted. For per-department numbers, start from
— or group by the dotted path and aggregate start-entity
fields.
- Selecting the bare edge field () still returns the
FK scalar; there is no — name each target field you want.
4.2 Reverse (one parent → many children)
for one parent primary key,
for a batch — on the edge field,
with the target entity's primary-key values.
bash
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"accountManager\",\"value\":\"daniel@helix.systems\"}},\"select\":[\"companyName\",\"monthlyRevenueUsd\"]}")' --query
When the parent condition is a plain predicate, you don't need the batch
— it's a forward filter through the edge (§4.1). "All customers managed
by anyone in the Berlin office" is one query:
bash
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"eq\":{\"field\":\"accountManager.office\",\"value\":\"Berlin\"}},\"select\":[\"companyName\",\"monthlyRevenueUsd\"]}")' --query
The batch
pattern is required when the parent set needs its own
query shape (top-N, ordered, paginated): collect the keys first, then
on the edge field. "Customers managed by the three most senior
employees" is two queries:
bash
icp canister call backend execute '("{\"start\":\"employee\",\"orderBy\":[{\"field\":\"level\",\"dir\":\"desc\"}],\"limit\":3,\"select\":[\"email\"]}")' --query
# collect the three emails from the rows, then:
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"in\":{\"field\":\"accountManager\",\"value\":[\"alex@helix.systems\",\"james@helix.systems\",\"sarah@helix.systems\"]}},\"select\":[\"companyName\",\"monthlyRevenueUsd\"]}")' --query
Always batch with
rather than running N separate
queries.
4.3 Compound conditions
bash
icp canister call backend execute '("{\"start\":\"customer\",\"where\":{\"and\":[{\"eq\":{\"field\":\"plan\",\"value\":\"enterprise\"}},{\"in\":{\"field\":\"country\",\"value\":[\"US\",\"CA\",\"DE\"]}},{\"ge\":{\"field\":\"monthlyRevenueUsd\",\"value\":20000}}]},\"orderBy\":[{\"field\":\"monthlyRevenueUsd\",\"dir\":\"desc\"}]}")' --query
4.4 Two-hop / self-edge join
When the parent key isn't given but must be looked up first — e.g. "who
reports to the lead of project
?" — run two queries. The second
filters on a self-edge (
→
) by the key the
first query returned:
bash
icp canister call backend execute '("{\"start\":\"project\",\"where\":{\"eq\":{\"field\":\"codename\",\"value\":\"forge20\"}},\"select\":[\"lead\"]}")' --query
# the row's `lead` cell is the lead's email, e.g. priya@helix.systems — use it as the parent key:
icp canister call backend execute '("{\"start\":\"employee\",\"where\":{\"eq\":{\"field\":\"manager\",\"value\":\"priya@helix.systems\"}},\"select\":[\"name\",\"jobTitle\",\"level\"]}")' --query
5. Pagination
caps results.
reports truncation. Walk pages with
:
text
offset = 0
limit = 25
loop:
result = icp canister call backend execute '("{\"start\":\"...\",\"limit\":25,\"offset\":<offset>,...}")' --query
consume result.rows
if not result.hasMore: break
offset += limit
Always set
explicitly. OQL itself imposes no cap (omitting
returns every match), and a canister author may add one — in
which case over-asking is silently truncated.
6. Pitfalls
| Symptom | Cause / Fix |
|---|
traps OQL: unknown entity '...'
| doesn't match any from — entity names are case-sensitive. Re-read the schema. |
| traps with a parse error | The JSON was malformed (trailing comma, single quotes), or not escaped as a Candid text literal — wrap as with every inner escaped as . Validate the JSON with first. |
| No rows returned for a filter you expect to match | (1) literal type doesn't match the field's ( for a ); (2) typo in — unknown fields are silently , so most predicates fail; (3) the field is genuinely in storage. |
| / returns weird results across types | Mixed-type comparisons aren't defined. Make sure both operands are the same . |
| misses rows you can see | / / are case-sensitive. Use for user-typed search terms. |
Dotted path traps 'x' is not an edge of 'y'
| The head segment isn't a declared edge — traversal is schema-driven even when values look like FKs. Use the two-query pattern instead. |
| Cross-entity average looks wrong | Aggregates run over the start entity's rows. Start from the entity whose rows you want averaged, or group by the dotted path and aggregate start-entity fields. |
| returns rows but missing fields | A field you -ed isn't in the entity (typo, or hidden by the author). Drop it from , or remove for the default projection. |
There is no structured error envelope. Any failure is a trap — fix
the query and retry.