Tips for writing queries
The engine has no cost-based optimizer: plans are deterministic, and the same query shape always compiles to the same plan. That cuts both ways. You never have to out-guess a planner, but a slow query stays slow until you change it, and the fixes are habits rather than hints. This page collects those habits: what mnestic now fixes automatically, what still needs care, and the patterns that keep queries fast as relations grow. The machinery behind every claim here is described in Query execution.
The examples run against the agent-memory graph used throughout these docs.
Keys sit to the left of =>:
memory { id => kind, text, importance, at, v } # notes, decisions, insights
entity { id => name, kind } # people, tools, projects
mentions { memory, entity } # which memories involve whom
recalls { from, to => strength } # association edgesCheck the plan, not the clock
When a query feels slow, wrap it in ::explain { ... } before reaching for a
stopwatch: because plans are deterministic, the plan names the problem. Two
join operators carry most of the signal.
stored_prefix_join— the bound columns form a prefix of the relation's (or an index's) key, so each probe is a keyed lookup: logarithmic in the relation size, and what you want to see on large relations.stored_mat_join— the bound columns do not form a key prefix, so the relation is scanned and matched row by row.
A third pattern, the (cartesian) annotation, marks a join that shares no
bound variable with its left input; it usually means a typo (shown
below). For the column-by-column guide
to plan output, see
Reading a plan with ::explain;
plans on this page are trimmed to the interesting columns.
Filters on key columns become lookups
There are two ways to pin a key column to a value: bind first
(id = 'm4', *memory{ id, .. }) or filter after
(*memory{ id, .. }, id == 'm4'). In mnestic they compile to the same keyed
plan:
::explain { ?[kind, text] := *memory{ id, kind, text }, id == 'm4' }atom_idx op ref joins_on filters/expr
3 unify id "m4"
2 load_stored :memory
1 stored_prefix_join {"id": "**0"}
0 outThe equality was hoisted into a unify, and the access is a
stored_prefix_join — a point lookup on memory's key. Writing
id = 'm4', *memory{ id, kind, text } produces the byte-identical plan, and
the positional form *memory[id, kind, text, _, _, _], id == 'm4' is
converted the same way.
mnestic
This is the mnestic 0.8.0 equality pushdown — upstream compiled the
post-filter form to a full scan followed by an eq(..) filter, and the
classic advice was to never write it. That advice is obsolete here: the two
shapes were measured ~28–29× apart on a 5,000-row relation before the fix.
See Equality pushdown.
Two shapes still scan, by design. A non-equality predicate on a key column is a filter, not a lookup — the whole relation streams through it:
::explain { ?[id] := *memory{ id }, id > 'm5' }atom_idx op ref joins_on filters/expr
1 load_stored :memory ["gt(id, \"m5\")"]
0 outAnd a numeric equality keeps its post-filter semantics even on a key
column, because == compares across Int/Float (1 == 1.0 is true) while
key order distinguishes them — converting could silently drop cross-type
matches, so the pushdown declines. On a relation event { seq: Int => label }:
::explain { ?[label] := *event{ seq, label }, seq == 7 }atom_idx op ref joins_on filters/expr
1 load_stored :event ["eq(seq, 7)"]
0 outFor numeric keys, write the binding first — when you know the value has the
column's type; the binding-first form matches structurally, so an Int key
never matches a Float value. That form is a keyed lookup:
::explain { ?[label] := seq = 7, *event{ seq, label } }atom_idx op ref joins_on filters/expr
3 unify seq 7
2 load_stored :event
1 stored_prefix_join {"seq": "**0"}
0 outNon-key columns need an index
A filter on a value column has no key to push into — matching is prefix-only, on the relation's key or an index's key. Create an index whose key starts with the filtered column and the same query compiles to a keyed lookup, no rewrite required:
::index create memory:by_kind { kind, id }::explain { ?[id] := *memory{ id, kind: 'decision' } }atom_idx op ref joins_on filters/expr
3 unify *5 "decision"
2 load_stored :memory:by_kind
1 stored_prefix_join {"*5": "**0"}
0 outWhen the rule also needs columns the index does not carry, the engine selects on the index and joins back to the base relation by primary key — both joins keyed:
::explain { ?[id, text] := *memory{ id, text, kind: 'decision' } }atom_idx op ref joins_on filters/expr
5 unify *4 "decision"
4 load_stored :memory:by_kind
3 stored_prefix_join {"*4": "**1"}
2 load_stored :memory
1 stored_prefix_join {"**2": "id"}
0 outAn index is chosen only when a prefix of its key is bound — the engine never gambles on a bad index, and you can always query the index directly. See Indices.
Atom order and the greedy reorder
Upstream, join order was exactly the order you wrote the atoms in, and a
conjunction listed in schema order rather than join order could spin on an
enormous intermediate. Since 0.10.5, mnestic reorders the positive
stored-relation atoms of a conjunction with a deterministic, stat-free
heuristic: fewest new variables first, preferring an atom whose entire key is
bound (a point lookup) on ties, falling back to written position. On any
written order the greedy heuristic would itself choose, the pass is the
identity — such hand-tuned queries compile byte-identically; an order that
deliberately disagrees with it can be pinned with :reorder written.
Know what it does not do:
- It consults no statistics — no cardinalities, no cost model. It removes one structural pathology (a disconnected or badly interleaved join order). When two orders are structurally equivalent but one touches far more rows, the heuristic cannot see that; you are still the optimizer, and the working rule is unchanged from upstream: put the most restrictive atoms that introduce new bindings first.
- It declines rules with fewer than three stored-relation atoms, rules
containing derived-rule or index-search (HNSW/FTS/LSH) atoms, and rules with
a multi-valued
inunification. - A bare
:limitwithout:ordercompiles the written order, so the returned subset stays the one your written order would produce. :reorder writtenopts a query out entirely.
mnestic
The greedy join reorder is specific to mnestic 0.10.5 (default on; tie-break fix in 0.10.7) and was measured 54.5× on the motivating repro. A worked plan comparison, including the opt-out, is in Query execution; the full design is in Greedy join reorder.
One ordering problem no reorder can fix is a conjunction that is genuinely
disconnected — and the usual cause is a misspelled variable. Here the second
atom was meant to say from: m:
::explain {
?[entity, next] := *mentions{ memory: m, entity },
*recalls{ from: mm, to: next }
}atom_idx op ref joins_on
3 load_stored :mentions
2 load_stored :recalls
1 stored_prefix_join (cartesian) {}
0 outjoins_on is empty and the operator is annotated (cartesian): every
mentions row pairs with every recalls row. A Cartesian product you did not
intend is worth grepping your variable names for.
Break long join chains into smaller rules
Say you want the memories exactly four association hops downstream of m1.
One way:
?[who] := *recalls{ from: 'm1', to: f1 },
*recalls{ from: f1, to: f2 },
*recalls{ from: f2, to: f3 },
*recalls{ from: f3, to: who }Another:
h1[who] := *recalls{ from: 'm1', to: who }
h2[who] := h1[m], *recalls{ from: m, to: who }
h3[who] := h2[m], *recalls{ from: m, to: who }
?[who] := h3[m], *recalls{ from: m, to: who }Both return the same rows (here, m5). But on a dense graph, where many paths
lead to the same node, the second form is exponentially faster: relations obey
set semantics, so each hN deduplicates its frontier before the next hop,
while the single rule enumerates every path to the final layer. Note that this
is not an atom-ordering problem — the chain's written order is already the
greedy one, so the reorder leaves it
untouched; only rule boundaries deduplicate. Rules in the same stratum are
also evaluated in parallel where the platform allows.
The moral: prefer breaking a query into smaller rules. It reads better, and in this engine it almost always runs faster too. And when the query is genuinely recursive, make it a recursive rule:
f_n[who, min(layer)] := *recalls{ from: 'm1', to: who }, layer = 1
f_n[who, min(layer)] := f_n[m, last], *recalls{ from: m, to: who },
layer = last + 1, layer <= 4
?[who, layer] := f_n[who, layer]
:order layer, whowho layer
m2 1
m3 2
m5 2
m4 3Two things to keep straight about this pattern:
- It computes minimum hop distance, not "exactly N hops". Filtering with
?[who] := f_n[who, 4]returns nothing on this graph: everything four hops out is also reachable sooner (m5sits at minimum layer 2, via the shortcut edgem2 → m5). Only the chain forms above answer the exactly-N-hops question. - The
layer <= 4guard caps the work, not the termination. Becauseminis a semi-lattice aggregation, this recursion reaches a fixpoint even on cyclic graphs without the guard — a longer route to an already-reached node changes nothing. Drop the aggregation, though, and the same shape carries ever-growinglayervalues around any cycle forever; then the guard (or a:timeout) is what saves you.
When one big rule is right
The single-rule chain has two legitimate uses. First, with :limit 1 it stops
at the first path found (early stopping), and skips the bookkeeping of extra
rules:
?[who] := *recalls{ from: 'm1', to: f1 },
*recalls{ from: f1, to: f2 },
*recalls{ from: f2, to: f3 },
*recalls{ from: f3, to: who }
:limit 1Early stopping works when the entry rule is an inline rule and there is no
:order; with a sort, all rows must exist before the limit applies. See
Early stopping.
Second, counting paths requires the single rule, because set semantics in a smaller rule collapses the duplicates you are trying to count. Two-hop paths through the association graph:
?[count(c)] := *recalls{ from: a, to: b }, *recalls{ from: b, to: c }count(c)
6Route the endpoint through an intermediate rule and the count silently becomes "distinct endpoints" instead:
hop2[c] := *recalls{ from: a, to: b }, *recalls{ from: b, to: c }
?[count(c)] := hop2[c]count(c)
4The single-rule count is safe on memory even at scale: rows stream through the counter as the join produces them, and no intermediate path relation is materialized.
mnestic
A count() over a large join can still take a while to enumerate. mnestic
0.10.5 ships an opt-in factorized-counting rewrite that computes eligible
counts without materializing the join, and an always-on ::explain advisory
that flags eligible queries. See
Factorized counting.
Parameterize queries
Pass runtime values as parameters instead of splicing them into the query
text. Parameters are named with a $ prefix and supplied alongside the script
by every binding (run_script in Rust and Python, the params field over
HTTP):
?[text] := *memory{ id: $id, text }With {"id": "m3"}:
text
Nightly compaction stalls search-service around 03:00A parameter reaches the plan as a bound constant, so the keyed lookup is identical to writing the literal:
::explain { ?[text] := *memory{ id: $id, text } }atom_idx op ref joins_on filters/expr
3 unify *5 "m3"
2 load_stored :memory
1 stored_prefix_join {"*5": "**0"}
0 outString interpolation, by contrast, is an injection surface (a value containing a quote is now part of your query) and an escaping bug waiting to happen. This matters double when the values come from an LLM.
Stage shared work in ephemeral relations
When several queries need the same intermediate result, compute it once into
an ephemeral relation (name starting with _) inside a chained transaction,
then reuse it. Here the set of high-importance memories is staged once and
then self-joined:
{ :create _hot { id: String } }
{
?[id] := *memory{ id, importance }, importance >= 0.75
:put _hot { id }
}
{
?[a, b] := *_hot[a], *_hot[b], *recalls{ from: a, to: b }
:order a
}a b
m2 m5
m4 m5
m8 m5Ephemeral relations live exactly as long as their transaction: a standalone
:create _draft { .. } script succeeds and the relation is gone before the
next script runs. The chaining mini-language (%loop, %if, %swap, …) where
ephemeral relations do their real work is covered in
Chaining queries.
Give every unbounded query a budget
Explicit unification makes it possible to write queries with infinite results, and no compiler can reject them all without also rejecting valid queries:
count_up[n] := n = 1
count_up[n] := count_up[m], n = m + 1
?[n] := count_up[n]
:timeout 1eval::timeout
× Query exceeded its time budget
help: The query ran past its wall-clock budget, set by a `:timeout`
option, a per-call timeout, or the Db default query timeout.
Narrow the query or raise the budget.Make the budget a habit for any query whose termination depends on data you
do not control — recursion over user-supplied graphs, exploratory joins, and
especially queries authored by an LLM. A runaway query can also be stopped
from outside with ::kill (see
System ops).
mnestic
Since mnestic 0.10.5 the timeout is a real wall-clock budget that interrupts
a query mid-join (upstream, a ::kill could queue behind the very query it
targeted, and a :timeout was only honoured between rule applications). A
Db-wide default can be set from Rust, Python,
or the server binary, and the effective deadline is the minimum of whatever
is set — a :timeout can tighten the budget, never extend it. See
Interruptibility & query budgets and
:timeout.
Dealing with nulls
The engine is strict about types: comparisons are only defined between values
of the same type. Given confidence { memory => score: Float? } where some
scores are null, this query throws as soon as it meets one:
?[memory] := *confidence[memory, score], score > 0.5eval::throw
× Evaluation of expression failed
help: comparison can only be done between the same datatypes, got null
and 0.5One solution is to treat null as equivalent to a default value with ~, the
coalesce operator:
?[memory] := *confidence[memory, score], (score ~ -1) > 0.5memory
m2The parentheses are not necessary — ~ binds tighter than the comparison —
but it reads better this way. You can also check for null explicitly:
?[memory] := *confidence[memory, score], if(is_null(score), false, score > 0.5)cond is also helpful in this case; see
Functions for coalesce, if and cond.
Adapted from the CozoDB documentation by Ziyang Hu and the Cozo Project Authors, used under CC‑BY‑SA‑4.0. Adaptations for mnestic are released under the same license. mnestic is an independent fork and is not affiliated with or endorsed by the original authors.