How the Query Planner Works
This chapter is the structured reference for the patinaDB query planner. It is
written for application developers and operators who write Cypher and read
EXPLAIN and PROFILE. It explains which stage makes each decision, what
inputs that stage uses, and which settings change the outcome. With it you
should be able to predict the plan for a query and explain it.
Query Planning & Performance keeps the detailed, feature-by-feature notes. This chapter links to it where that chapter has more detail.
Two rules hold throughout:
- Plans never change results. Every alternative described here returns the
same rows. Row order is the same wherever
ORDER BYdefines it. WithoutORDER BY, row order is unspecified and can differ between plans and between cluster nodes. Statistics only choose plans; they never filter rows, and they are never replicated. Different access paths can visit different records, so which storage error a damaged record raises, and when, can depend on the plan. - The RFC 0042 cost model and operators are on by default. Since
2026-10-17 the cost model (
join), factorized execution and theautomodes of worst-case optimal joins and the CSR adjacency cache are the shipped defaults (#1442). The reference planner, with heuristic rules over cardinality estimates, remains: it decides wherever the cost model declines or keeps it by margin, and setting all four switches tooffrestores it exactly (see Specialised operators and switches).
The pipeline at a glance
A read query passes through these stages, in this order:
| Stage | What happens | When |
|---|---|---|
| Parse | Cypher text becomes an AST. A parse cache holds up to 1,024 parsed queries. | per text |
| Literal normalization | Literals become internal parameters ($__pdb_lit_N), so queries that differ only in literal values share one plan (see The plan cache). | per text |
| Semantic checks | Scope, aggregation and type rules run on the original text, so error messages keep your literals. | every execution |
| WHERE folding | Simple predicates (n.p = $v, n:Label, comparisons with literals or parameters) move into the node patterns, where an index can use them. | plan build |
| Lowering | The AST becomes an operator tree (Op). Comma patterns and consecutive MATCH clauses that share endpoints fold into one path. The remaining patterns form a left-deep join chain. OPTIONAL MATCH becomes LeftJoin. A non-MATCH clause between two MATCH clauses (UNWIND, CALL, WITH …) makes the later part an Apply seeded by the earlier rows. | plan build |
| Access-path rewrite | Join ordering and the hash-or-nested-loop choice per join step, filter push-down, label rewrites, compound multi-key sorts, and the partial COUNT rewrite. | plan build |
| Plan cache | The result of the stages above is cached. | per text |
| Execution | Before the operator tree runs, the executor tries the whole-query fast paths (label counts, filtered counts, group counts, edge-sorted top-k, spatial kNN). Each MatchScan then chooses its entry node and access path. Specialised operators (path counts, factorized aggregates, intersections, parallel morsels) are admitted by their own checks. | every execution |
Read this table as two halves. Everything up to the plan cache is decided once per query text and then reused. Everything in the execution row is decided again on every run, using the current statistics.
Figure 1. The pipeline. The plan cache stores the rewritten operator tree; the entry choice (with the A2 entry-decision cache, introduced in RFC 0042 A6), fast paths and operator admission are decided on every execution.
The plan cache
The plan cache holds the operator tree after the access-path rewrite. That tree fixes the join order and the per-step hash or nested-loop choice.
-
Scope. One cache per process holds up to 512 plans, keyed per database, and evicts the least recently used. There is no setting for its size.
PATINADB_PLAN_CACHE=off(also0,falseorno) disables it, together with literal normalization. -
Key. A plan is reused only for the same normalized text. User parameters (
$personId) are kept by name, so every value of$personIdshares one plan. -
Literals. Literals are replaced by internal parameters. Normalization is skipped, and the plan is keyed by the exact text, when:
- the query has two or more
MATCHsteps that reach join reordering; - the text contains comments, backticks or non-ASCII characters;
- the normalized text does not parse.
- the query has two or more
-
Decided per execution, not cached.
- the entry node and the access path of each
MatchScan; - the order of a multi-pattern
MatchScan; - all fast paths, limit rules and aggregate specialisations;
- parallel admission and the
automodes.
- the entry node and the access path of each
-
Invalidation. Cached plans are dropped by:
- any DDL, such as
CREATE INDEXorCREATE EDGE SORTED INDEX; - clearing the graph (restore, snapshot install, reset);
DROP DATABASE.
Under
PATINADB_COST_MODEL=join, an accepted degree-catalog publication for the same database also makes the cached plan rebuild on its next lookup. Refused refreshes, ordinary data writes and publications in another database do not trigger this rebuild. Reference/entry plans retain their previous policy. Parameter values still share a plan; parameter-sensitive join planning remains a limitation between replans. - any DDL, such as
-
EXPLAIN and PROFILE plan afresh. They never read the plan cache. If the data or the statistics have changed since a query was first cached,
EXPLAINcan therefore show a different join order from the one the cached plan executes. UnderPATINADB_COST_MODEL=join, the decisions show aplan.cache … chosen=builtline, which records the schema epoch the join decisions were made under.
The L3 result cache, which stores whole result tables, is separate; see Cache memory budget.
The plan.cache wording in the example below reflects this publication policy;
the original example fixture and the other recorded decisions are unchanged.
Entry node and direction
Each MatchScan starts at one node of its path, the entry node, and
expands from there in both directions. This choice is made again on every
execution.
Figure 2. Entry choice. The margin depends on the provenance of the cheapest candidate’s inputs; the details follow in the two sections below.
Reference rules (fallback)
The reference selector decides wherever the entry cost model declines a pattern
or keeps its pick by margin, and everywhere when PATINADB_COST_MODEL=off. It
applies these rules in order:
- UUID shortcut. A node constrained by an id equality (
id(n) = $id,elementId(n) = …) is the entry, without estimating anything else. - Single node. A pattern with one node starts there.
- Sort hint. If
ORDER BYnames a property of a node in the pattern, that node is the entry. The exception is when its estimate is at least 10 times the cheapest node’s estimate (integer ratio, both estimates positive). - Minimum cardinality. Otherwise the labelled node with the smallest
estimate wins.
- Ties go to the leftmost node.
- The walk stops at the first estimate of 1 or less.
- Unlabelled nodes are never estimated.
- Direction check. For chains of 3 to 9 nodes the planner can then switch to the other endpoint. This applies only when every node has exactly one label and no properties, and every relationship has a fixed length and one type. If the two endpoint estimates are within 2× of each other, it samples up to 32 anchors per hop and direction, with one shared 16,384-edge budget. There is no setting for this step.
The node estimates come from these rules (constants as in the source; they are heuristics, not measured optima):
| Node pattern | Estimate |
|---|---|
id(n) / elementId(n) equality | 1 |
| no label | unknown; skipped while any labelled node exists (an all-unlabelled pattern starts at its first node, AllVertices) |
| label only (primary label) | the label count |
| label only, label held only as a secondary label | the total vertex count (a coarse upper bound) |
n.p IN [...] | the sum of the list lengths, capped at the label count |
n.p = v | a bounded scan of up to 1,001 index keys; above that, present / NDV from the column statistics |
n.p > v (and >=, <, <=) | histogram interpolation; the label count when no statistics exist |
STARTS WITH, CONTAINS, <>, other | the label count ÷ 10 |
The first label is the primary label. Label counts, value indexes and
compound indexes are keyed by it. A label that a node holds only as a secondary
label (for example Post in CREATE (:Message:Post)) is not counted by the
label index. The reference selector then falls back to the total vertex count.
In the example dataset this is why
(:Comment) is estimated at est rows=31070, the whole graph, instead of
20,000.
Once the entry node is chosen, the access path for it is the first rule that applies, in this priority order:
| Priority | Rule | Used for |
|---|---|---|
| 10 | entry.all_vertices | unlabelled entry |
| 12 | entry.in_seek | n.p IN [...] on one label |
| 14 | entry.uuid_point | id equality |
| 16 | entry.keyset_multisort | multi-key keyset pagination |
| 18 | entry.spatial_seek | point-index predicates |
| 20 | entry.compound_index | declared compound index |
| 30 | entry.property_value_index | equality, range, STARTS WITH, or a sorted scan on one property |
| 40 | entry.label_scan | everything else |
Priority is fixed with one exception. When both a compound index and a
single-property index apply, they are compared on an estimated
rows + io cost. If the cost beats the priority order, the Physical: footer
says so with an entry.cost_override: … line (see
Cost-based selection).
The entry cost model (PATINADB_COST_MODEL=entry)
With PATINADB_COST_MODEL=entry (or join, the default, which includes it), the minimum
cardinality rule (step 4) and the direction check (step 5) are replaced, for
eligible patterns, by a whole-path cost in calibrated nanoseconds.
-
Eligible patterns. Fixed-length paths with at least one relationship, where every relationship has exactly one type. Patterns with an id constraint, variable-length relationships,
shortestPath, untyped or type-alternative relationships, and patterns with a sort hint keep the reference rules. So do patterns whose estimates are not finite, and patterns whose planning scratch memory is refused. -
What is priced. Every labelled node competes as the entry. Each candidate costs:
- its entry access (seek or scan);
- for every entry row, the expansion of both sides of the path, including label checks and property reads at each hop;
- the construction of the cross product of both sides.
The hop sizes come from the degree catalog, using the mean degree for the (relationship type, direction, anchor label, other label) group. Filters are assumed independent.
-
Regimes. The primitive costs were measured on one host (A2-cal v1, #1442). A hop is priced as “warm L2” only when the entry cache is enabled, its expected list fits the L2 admission cap (at most 8,192 edges), and the working set fits the level cap. Otherwise it is priced as “warm redb”. The cold regime is never chosen automatically.
-
Keyed consumers. Some operators repeat work per run of one binding. Today this is only the count over independent
OPTIONALone-hop branches. For those, the expected number of runs times the per-run cost is added asentry.cost.consumer. -
Adoption margin. The model’s choice replaces the reference entry only when the predicted advantage clears a margin that depends on the inputs:
- Exact inputs: no margin; the cheapest candidate is used.
- Sampled degrees: the larger of 1.1× and the spread of the candidates’ estimated output rows.
- Missing or stale degrees (
Default,Stale): 2×.
Ties keep the reference entry. These margins are unverified policy choices, not confidence intervals.
-
Missing statistics. If a degree group is missing or stale, the model uses a cached legacy fan-out or a default of 1. It also asks for one bounded background refresh, which runs on a single worker:
- budget
PATINADB_DEGREE_STATS_EDGE_BUDGET, default 1,000,000 directional edges;0disables the refresh; - a 120-second deadline;
- a 5-second retry backoff per database.
The query that triggered the refresh does not wait for it. The
refresh=field ofentry.costreports what happened, for examplestarted,not needed,single flightorcache budget refusal. A refusal means the entry cache is disabled, or thestats.degreeregion has less than the 256-group minimum left. A 2 GiB node fits the sample (#1442). So the default cost model does not need a manualCALL patinadb.stats.degrees(): the first queries of a shape plan with the fallback, later ones with the sample once it is published. The sample is node-local RAM, lost on restart, and costs a bounded edge sample plus one pass over all vertices on a background thread. Join orders chosen before it arrived stay in the plan cache until a schema change or eviction (see The plan cache); entry choices are re-priced when the catalog changes. - budget
-
It needs the entry cache. The degree catalog lives in a region of the entry cache. A server enables the entry cache by default (
PATINADB_CACHE_LIMIT=40%). Embedded use enables it only whenPATINADB_CACHE_LIMITis set. Without it, every degree lookup falls back toDefaultand the refresh is refused. This is the same IC2-shaped query run embedded without a cache limit:
entry.cost _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"]: chosen=node[0] (est rows=1.931123270035404) (est ns=4155.373289255539) over node[2] (est rows=1.931123270035404) (est ns=844442.8018863855) over node[4] (est rows=60) (est ns=25337715.354519267); minimum calibrated primitive ns; dominant=entry access; warm redb default, L2 only within list and working-set caps; A1 population mean or legacy-default cache-only fallback; refresh=cache budget refusal; independent filters; constants A2-cal v1 #1442; Default; repeats=3
All values and their side effects are listed in Entry and join cost model.
Joins
Which patterns become a join
Before any ordering, lowering folds patterns that share an endpoint into a
single path. For example, (a)-[:R]->(b), (b)-[:S]->(c) becomes one path
(a)-[:R]->(b)-[:S]->(c). Patterns stay separate when:
- a pattern is
OPTIONALor binds a named path; - both patterns contain a relationship and come from different
MATCHclauses; - the shared variable is an interior node of either path (branches and stars stay separate);
- two patterns share both endpoints but use disjoint relationship types. They stay a two-key join instead of becoming one closed walk (LSQB Q2’s shape; see Closed paths and endpoint joins).
Separate MATCH clauses lower together like comma patterns. The exceptions are
an OPTIONAL clause, a clause with its own WHERE or dynamic pattern
properties, and a clause with two or more relationship patterns. Patterns that
remain separate form a join chain.
Reference join planner (fallback)
The reference join planner orders joins when join costing declines or is
switched off.
-
Seed. The pattern with the smallest base estimate starts the chain; ties go to the first pattern. The base estimate is the smallest node estimate in the pattern. A pattern that touches a variable bound by an enclosing seed counts as 1.
-
Order. Up to 8 patterns use a Selinger-style dynamic program that minimises the sum of intermediate result sizes. Above 8 patterns, a greedy order is used.
- A connected join step is estimated as
max(prefix, base), a foreign-key assumption. It does not use fan-out. - A disconnected step is estimated as
prefix × base. - Connected patterns always come before disconnected ones. The planner never adds a cartesian product that the query did not already force.
- Finally, patterns that are anchored on two or more bound nodes, or that close a cycle on a bound node, are moved forward.
- A connected join step is estimated as
-
Strategy per step. A step becomes a seeded nested loop (
Apply) when it is connected and either:- anchored as above; or
- a point probe applies (one hop, or every interior node bound) and the
nested-loop cost
prefix × seekbeats a scan of the right side.
The right side must have at least 1,000 estimated rows, except when the whole step is evaluated per outer row. Every other step is a hash join (
Join kind=hash); the right side is the build side. -
Cartesian products. A keyless join fails with a resource error once it would emit more than
PATINADB_CARTESIAN_CAProws (default 10,000). -
Semi-join pruning. In supported shapes, the hash table of a join is passed as a membership filter into a later traversal. That traversal then skips vertices that cannot join (up to 8 keys).
-
Partial
COUNT. An ungroupedcount(*)over a keyed hash-join chain is rewritten into per-side grouped counts. These are joined on the keys and summed as products. InEXPLAINthis shows as aJoinof twoAggregate group_by=…inputs, as in Example C.
Hash and cartesian joins emit combined rows on demand (#1468), preserving
left scan order and right bucket/scan order, including duplicates. Under an
active query memory context, new row backing and recursive scalar copies are
admitted before allocation against the existing query/transaction budgets.
A memory refusal terminates output with ResourceExhausted. The cartesian
cardinality gate still checks the complete product before emitting any row.
Probe key containers, recursive scalar copies and edge-formatting scratch also
reserve before allocation (#1469). Their credit stays with the current probe
and releases on advancing, exhaustion, error or early iterator drop, rather
than accumulating in delivery. Missing bindings retain their skip semantics.
Keyed build tables also admit their hash allocation, fresh composite keys and
bucket storage (#1470), including old/new capacity overlap during growth.
Shared membership filters retain the same table owner; the last owner releases
its credit. Existing input row payloads are moved or shared, not re-accounted
as new table payload. Join input collectors reserve slot capacity before growing
(#1471), including old/new allocation overlap. Cartesian iterators keep both
input permits until drop; keyed build releases its collector after handoff.
Collection can fail its memory budget before the product cardinality is known.
No new switch is introduced. Shared seed/import selection and APPLY/OPTIONAL
merges now use the same fallible copier (#1473). Unique owned entries move;
shared entries require an admitted recursive copy. CALL import visibility and
first-binding precedence remain unchanged. Refusals propagate through reader,
writer, predicate and COUNT/EXISTS callers. A governed APPLY iterator checks its
original query memory context on every advance, including within the current seed.
Copied payload credit stays with query
delivery, including discarded temporary copies. Original row producers and other
copy sites remain separate limitations, so this is not a complete join memory bound.
The keyed emitter probes its immutable bucket per output row; its throughput
impact has not been measured.
OPTIONAL MATCH never joins the chain. It becomes a LeftJoin that seeds each
optional pattern from every left row.
Under a query memory context, its input, clause extensions, results and memoized
candidate row-slot buffers reserve capacity before growth/copy (#1472). Sort
input and correlated/rooted MATCH candidate and accumulator containers use the
same owner. Concurrently live containers count together; completed output keeps
its credit through iterator/result lifetime. A refusal propagates through the
existing resource-error path. Identity/SKIP filtering, clause-level WHERE and
NULL extension, duplicate order and count/deadline guards are unchanged. The
existing limits therefore may reject work previously admitted with uncounted
buffers. Optional memo keys/table storage and recursive row/model producers
remain separate limitations. No new knob or measured throughput claim.
Cost-based join ordering (PATINADB_COST_MODEL=join)
join adds degree-aware join costing on top of entry:
-
Search.
- Up to 10 relations: a Pareto (cost, rows) subset dynamic program. It gives up and falls through when a frontier would need more than 8 states.
- Up to 32 relations: an interval dynamic program over the greedy order.
- Above 32: greedy.
These limits are unverified policy. The search is left-deep and adds no cartesian products.
-
Pricing. For each step, a hash join (build + probe + row construction) is compared with a seeded nested loop (seeks plus expansion per outer row), in the same calibrated nanoseconds as the entry model. No new operator is introduced.
-
Guard. The reference plan is priced in the same model. The search result is adopted only if it is predicted to be faster by more than 1.1× (when both prices come from exact statistics) or 2× (otherwise). The reference plan is also kept when:
- every pattern is edge-free;
- a pattern closes a cycle, meaning a node variable repeats inside one pattern. A cycle spread over several comma patterns does not count;
- an input cannot be modelled: shortest paths, variable-length or multi-type relationships;
- the reference price is not finite, or planning scratch memory is refused;
- the reference executes as a partial
COUNTand the search result would not. The audit then sayskept reference: it executes as a partial COUNT ….
On the LSQB, SF1 and Velr suites that were checked (#1442), the guard keeps the
reference plan everywhere (RFC 0042, Increments A3). Treat join as an
experiment that reports its reasoning, not as a tuning knob.
Statistics
| Statistic | Source | Freshness | Used by |
|---|---|---|---|
| Label count | key-only count of the primary label index | cached; served until PATINADB_STATS_STALENESS_BUDGET (default 64) label generations old | entry estimates, join bases |
| Column statistics (present count, NDV, min, max, 16-bucket equi-depth histogram) | one run-length pass over the primary label’s value index | same budget | equality and range estimates |
| Legacy fan-out (mean edges per anchor) | one pass over the edge table, capped at PATINADB_FANOUT_RECOMP_MAX_EDGES (default 100,000,000; partial above) | per edge-type generation | reverse-probe top-k; cache-only fallback for the cost model |
| Degree catalog (A1) | explicit procedure or background sample | relative, see below | cost model, factorized and auto admission |
Notes:
- Generations. A generation advances once per dirty label or relationship type at each storage synchronisation, not once per written row.
- Where statistics live. Statistics are kept in RAM only. The cache holds at most 4,096 entries or 4 MiB, is cleared completely when it overflows, and is lost on restart. Each cluster node computes its own statistics; they are never replicated.
- Estimates vs guarantees. Every number here is an estimate. Statistics are never used to prove that a row is absent or to skip a check that results depend on.
CALL patinadb.stats(label?)shows exact, freshly computed values, including secondary-label holders. It is not a view of the planner’s cached (possibly stale, primary-label-only) entries, so its numbers can differ from the estimates inEXPLAIN.
The degree catalog
CALL patinadb.stats.degrees(edgeBudget?) builds the RFC 0042 A1 degree
catalog and returns one row per (relationship type, direction, anchor label,
other label) group. Both labels include secondary labels. Here is an excerpt
from the example dataset:
edge_type | direction | anchor_label | other_label | edges | anchors | population | p50 | p99 | max | heavy_hitters | provenance | sample_edges | sample_anchors
…
HAS_CREATOR | in | Person | Message | 30000 | 1000 | 1000 | 15 | 110 | 110 | [16 entries] | exact | 30000 | 1000
…
KNOWS | in | Person | Person | 6500 | 1000 | 1000 | 7 | 9 | 9 | [16 entries] | exact | 6500 | 1000
KNOWS | out | Person | Person | 6500 | 1000 | 1000 | 7 | 7 | 155 | [16 entries] | exact | 6500 | 1000
How to read a row:
anchorscounts anchors with at least one edge, whilepopulationincludes zero-degree anchors.p50andp99are upper bounds of log₂ buckets.heavy_hitterslists up to 16 high-degree candidates with their exact observed degrees. The cost model uses an exact heavy-hitter degree only for a literal UUID seed; for anything else it uses the mean.- Exact vs sampled. Without an argument, the budget is
PATINADB_FANOUT_RECOMP_MAX_EDGES. If both directions of the graph fit the budget, the pass isexact. Otherwise it samples 256 evenly spaced UUID ranges, assuming uniformly distributed UUIDs.sampledcounts are then extrapolated,maxis only the observed maximum, and groups that the sample missed are absent rather than proven empty. - Population. The population counts come from a full vertex scan, which the edge budget does not bound.
- Failure. An exhausted budget, more than 4,096 groups, or a deadline fails the call and keeps the previous catalog.
- Freshness. A group counts as stale for the entry and join models as
soon as any of its three generations (relationship type, anchor label, other
label) has moved. Factorized admission uses a looser rule: it refreshes when
the generations have moved by more than
max(64, population / 100). - Storage. The catalog is stored only when the entry cache is enabled (see above). Planner paths never block on building it; they request the background sample described in The entry cost model.
Specialised operators and switches
| Switch | Values | Default | Prerequisites | What it can change | Decision points |
|---|---|---|---|---|---|
PATINADB_COST_MODEL | exactly off, entry, join (case-sensitive) | join | entry cache for degree statistics | entry choice; join order (join); parallel admission; enables the auto modes below | entry.cost, entry.cost.hops, entry.cost.consumer, join.order, join.strategy, plan.cache, parallel.admission |
PATINADB_FACTORIZE | Boolean (0/false/off/no are off) | on | a query memory scope | grouped counts over two independent branches (B2); DISTINCT and existence, top-k within a factor, cross-branch counts (B3–B5) | agg.factorized, factor.topk, factor.exists, factor.flatten |
PATINADB_WCOJ | exactly off, on, auto (trimmed, case-insensitive) | auto | auto needs a cost model; intersections and cyclic joins need a query memory scope | closing probes (C0), sorted intersections (C1); anti-intersection and cyclic joins (C2) only with on | expand.into, expand.intersect, entry.intersect, join.wcoj |
PATINADB_CSR | off, on, auto, plus 1/true/yes for on | auto | auto needs a cost model; the entry cache | deduplicating variable-length BFS (D1); with on also shortest paths and fixed hops (D2) | csr.admission, traversal.source |
PATINADB_QUERY_THREADS | integer, clamped to 1–32 | 1 (serial) | idle pool workers | morsel-parallel scans, traversals, mergeable aggregates, top-k and Apply | parallel.admission |
All five are read once per process; restart after changing them.
--check-config validates their values and prints the effective mode of the
four RFC 0042 switches, marking defaults. Note that PATINADB_WCOJ rejects
Boolean aliases: at runtime true and 1 mean off.
Defaults (since 2026-10-17, #1442). Unset or empty means the default in
the table. An explicitly set value keeps the meaning it had before the
defaults changed: off selects the reference path, and so does any value
outside a switch’s grammar (--check-config rejects those). To run the
reference planner and executor exactly as before, set all four to off:
PATINADB_COST_MODEL=off PATINADB_FACTORIZE=off PATINADB_WCOJ=off PATINADB_CSR=off
The default was chosen from the server comparison in
Measured effects: Velr 0.544, LSQB 0.878, SF1 0.995 of
all-off. PATINADB_QUERY_THREADS stays 1.
What auto means. Under auto, an operator engages only where it has its
own cost admission, and only when PATINADB_COST_MODEL is entry or join.
Without a cost model, auto behaves exactly like off. Operators without a
cost admission (anti-intersection, the cyclic join, CSR shortest paths and CSR
fixed hops) need an explicit on.
Figure 3. Switch map. “Uncosted” means the operator engages wherever its structural
checks pass; “inert” means an auto mode without a cost model.
Prerequisites that decline silently. Factorized execution, sorted intersections and the cyclic join run only under a query memory scope:
- On a server, Bolt reads get one from the memory governor.
- Embedded code gets one from
Dataset::query_table_read_with_memory.
Without a scope, PROFILE reports agg.factorized aggregate: chosen=flat over factorized; query memory scope required and the reference
operator runs.
Factorized execution (PATINADB_FACTORIZE)
Factorized operators work on a fixed path that splits at an interior pivot into two independent branches. Instead of building every combination of the two branches, they read each branch once per pivot.
- Grouped aggregates (B2).
- Aggregates:
count(*),count(x)andcount(DISTINCT …), with group keys on the pivot or on the branch end nodes. - Exact-integer
sum,minandmaxon a general path, but only with the cost model off. - Branches may share a relationship type only if they reach the pivot through it in the same way. Relationship uniqueness is then checked per pivot at run time.
- Aggregates:
- B3–B5. DISTINCT over the pivot plus one branch, top-k within a factor,
and cross-branch
=/<>counts by inclusion–exclusion. A degenerate factor flattens back to the ordinary operator, before any output. The details are in Factor consumers. - Admission.
- With the cost model off, every proven shape engages (forced; meant for tests and measurement).
- With the cost model on, only the per-pivot count path is cost-admitted.
It is admitted when
700 · F > 1.5 · (1000 + 300 · W + 30 · F)ns, where F is the estimated flat rows and W the walked elements per pivot, taken from the degree catalog. The weights were fitted on one host and are indicative (#1442). Missing or refresh-due degree evidence keeps the flat aggregate. SUM/MIN/MAX and join shapes are not costed and stay flat. - B3–B5 have no cost admission. They engage whenever the switch is on and their structural checks pass.
The same Velr-A1-shaped query (“pairs of authors whose comments reply to the
same post”), first with PATINADB_FACTORIZE=on alone and then with the cost
model and auto modes on as well:
agg.factorized pivot=4 (per-pivot key multisets; runtime witness proof for shared relationship types; runtime admission)
…
agg.factorized aggregate: chosen=factorized over flat; selected; cached pivot count=Some(0); cached first-hop means=[Some(2.0), Some(2.0)]; PATINADB_COST_MODEL records its estimate separately; Cached; repeats=1
agg.factorized pivot=4 (per-pivot key multisets; runtime witness proof for shared relationship types; runtime admission)
…
agg.factorized aggregate: chosen=flat (est rows=4) (est cost=2800) over factorized (est rows=4) (est cost=2320); cost model per pivot: flat rows=4.00, walk elements=4.00, flat=2800ns, factorized=2320ns, margin=1.5; Cached; repeats=1
Both runs return the same five rows. On this graph each post has two comments,
so F = 4 flat rows per pivot against W = 4 walked elements. That fails the
margin, and the cost model keeps the flat aggregate. The grouped count
MATCH (p:Person)-[:KNOWS]->(q:Person)<-[:HAS_CREATOR]-(m:Message) RETURN q.lastName AS name, count(*) AS n ORDER BY name LIMIT 3 pivots at q. It has
F = 195 against W = 36.5 and is admitted:
agg.factorized pivot=2 (per-pivot key multisets; runtime admission)
…
agg.factorized aggregate: chosen=factorized (est rows=195) (est cost=17800) over flat (est rows=195) (est cost=136500); cost model per pivot: flat rows=195.00, walk elements=36.50, flat=136500ns, factorized=17800ns, margin=1.5; Cached; repeats=2
Worst-case optimal joins (PATINADB_WCOJ)
- C0, closing probe (
expand.into). When the last hop of a pattern returns to an already bound node, the executor can probe that one edge instead of scanning the whole adjacency and filtering. The closing relationship must be unnamed, single-typed and without properties. Underautothe probe is chosen when the cached list length or the catalog degree makes a full scan dearer than one seek; unknown degrees keep the scan. - C1, sorted intersection (
expand.intersect). ForcinN(a) ∩ N(b)shapes (triangles, mutual friends), the executor merges two UUID-sorted neighbour lists instead of expanding and filtering. Underauto, each candidate samples at most 256 keys per side. The intersection is used only when the binary plan’s intermediate result exceeds the output and the binary plan costs more than 1.5× the intersection. Sampling stops for a hop after 32 candidates whose expected gains do not pay for it, and resumes on every 64th. The anti-intersection forNOT (a)--(c)runs only withon. - C2, cyclic join (
join.wcoj). Connected cyclic regions with at least three fixed, typed relationships (up to 16 vertex variables and 16 relationships) run as a leapfrog join over the adjacency. Named or property-filtered relationships join it as binary probes.OPTIONAL, variable-length paths, derived inputs and scans with ordering promises keep the reference execution. Runs only withon; there is no cost admission yet. - Triangle entry (
entry.intersect). Withautoand a cost model, a homogeneous undirected triangle may be entered at the node that gives C1 its complete prefix.
Limitations: Experimental cyclic joins require explicit activation.
CSR adjacency cache (PATINADB_CSR)
The CSR is a derived, read-only, in-memory copy of one relationship type’s adjacency in one direction. It is built in the background from the stored trees and is never written to disk or replicated.
- When it is used. Until a CSR is built, and for any anchor written since the build, the existing reader is used. A CSR more than a quarter dirty is rebuilt.
- Memory. Its share is one ninth of the decoded entry-cache levels’
allowance; the four existing levels keep two ninths each. With
on, that share is reserved at start-up. Withauto, it is reserved only once the CSR cache exists. auto. Only deduplicating variable-length traversals (BFS), wherecount(DISTINCT …)or similar makes path multiplicity irrelevant, are cost-admitted. The expected expansions must save more than twice the dense-id lookups plus a 1/4096 share of the build scan; this policy is unverified. Shortest paths keep the reference reader.on. Also enables shortest paths and single-type fixed hops (D2).
EXPLAIN neither builds nor reports a CSR. Only PROFILE shows
traversal.source:
csr.admission deduplicated BFS: chosen=csr candidate; degree/visits versus amortized build and lookup; uncertified caches still decline; Structural; repeats=1
traversal.source "KNOWS" reverse=false: chosen=csr over l2/redb; clean anchor at compatible snapshot; Exact; repeats=225
traversal.source "KNOWS" reverse=true: chosen=csr over l2/redb; clean anchor at compatible snapshot; Exact; repeats=225
In the same auto run, the C1 intersection’s sorted domains also consulted the
CSR and requested its build (not built, then background build in progress
in Example D). That run had no
csr.admission decision for the triangle query. That excerpt was captured before
the CSR auto containment: in the current source, auto admits only the
deduplicating BFS, and intersection domains stay on the reference reader without
requesting a build. They read the CSR only with PATINADB_CSR=on.
Parallel execution (PATINADB_QUERY_THREADS)
With a thread cap above 1, eligible read-only scans, fixed traversals,
mergeable aggregates (not sum/avg), bounded top-k, sorted DISTINCT and
seeded Apply can run as morsels on the query pool. Results and their order
keep the serial contract. Admission depends on the cost model:
- Cost model off. A scan or traversal goes parallel only above
PATINADB_PAR_AGG_MIN_ROWSestimated rows (default 100,000, unverified). - Cost model on (A4). A plan goes parallel only when its estimated serial
work exceeds 4 × (400 µs dispatch + 265 ns per morsel + 1.4 ns per row), and
the parallel version is also cheaper. Some cases need more:
- aggregates grouped by an entity add about 1 µs of serial work per group;
- row-producing pipes still also need the row floor;
- a serial specialisation (path count, covering input) keeps priority unless the parallel plan is four times cheaper.
- Always.
- Workers must be idle (
parallel.loadin the access profile). - Deferred-prefix pruning always keeps priority.
- Edge morsels split a first hop only when its actual average fan-out reaches
PATINADB_PAR_FANOUT_MIN_EDGES(default 50,000).
- Workers must be idle (
A two-hop grouped count (MATCH (a:Person)-[:KNOWS]-(b:Person)-[:KNOWS]-(c:Person) RETURN c.lastName AS name, count(*) AS n) with PATINADB_QUERY_THREADS=4.
With the cost model off, the row floor compares the entry estimate (1,000)
with 100,000 and stays serial:
[2] MatchScan required=[(a:Person) -(b:Person) -(c:Person)] rows=377064 est=1000 q-error=377.064
…
parallel.admission traversal rows: chosen=serial over parallel (est rows=1000); max label estimate >= floor 100000; entry=1000; Cached; repeats=2
With PATINADB_COST_MODEL=join, the calibrated work estimate is compared with
the overhead and the pool admits four threads:
[2] MatchScan required=[(a:Person) -(b:Person) -(c:Person)] rows=377064 est=169000 q-error=2.231
…
parallel.admission calibrated work: chosen=parallel (est rows=169000) (est cost=2830130.3734979997) over serial (est rows=169000) (est cost=8780502.616999999); serial_ns > 4 * overhead_ns=635004.719248; effective_threads=4; morsels=4; A2-cal v1, unverified margin; Cached; repeats=2
…
parallel.admission pool load: chosen=parallel over serial; idle=8/8; requested cap=4; admitted cap=4; Exact; repeats=1
…
parallel.load: admitted, idle 8/8, threads 4
More detail is in Day-2 operations and the
PATINADB_QUERY_* rows of Configuration.
Fast paths
These run automatically, without a switch:
- Limit rules. Rules for
ORDER BY … LIMITare tried in this priority order, and the walk is recorded aslimit.rule:edge_sorted_topk(needs an edge-sorted index);spatial_knn;reverse_probe, which is cost-compared against the fan-out top-k;bounded_topk, an O(k) heap;window.
- Path counts.
agg.disjoint_path_countandagg.grouped_disjoint_path_countcount fixed paths that split into branches with disjoint relationship types, without building cross-product rows. - Optional counts.
agg.independent_optional_counthandlescount(*)over up to 8 independent optional one-hop branches. - Covering input.
agg.covering(PATINADB_READ_COVERING, default on) applies to broad integer aggregates of at least 4,096 estimated rows. - Whole-query counts. Label, filtered and grouped count scans.
The full list is in Fast paths that make queries cheap.
Reading Decisions: in EXPLAIN and PROFILE
EXPLAIN returns a plan string. In order, it contains:
- the operator tree, read bottom-up;
Storage:and property-layout lines;- an optional
Physical:footer with planned rules and cost lines; Decisions:;- optional
Advice:andWarning [...]lines.
PROFILE runs the query and also returns elapsed_ms. Its plan string adds:
Planned access (before execution):and the same planned tree;Executed operators (query thread):with actual row counts;Decisions (executed):, the decisions taken during this execution;- the executed aggregate access, the resolved property storage and the storage request counts.
Each decision line has this form:
<point> <subject>: chosen=<alternative> [(est rows=…)] [(est ns|cost=…)] over <alternative> …; <reason>; <provenance>; repeats=<n>
- point: the decision family. Examples are
entry.node,entry.access,entry.cost,join.order,limit.rule,agg.factorized,parallel.admissionandtraversal.source. - subject: what was decided. This is usually the pattern, with labels and
directions, or a step such as
step 1. - chosen / over: the chosen alternative first, then the rejected ones. Priority walks stop at their winner, so later alternatives may be missing.
- estimates:
est nsis used forentry.costand for decisions whose reason starts withcalibrated ns; other estimates are in the selector’s own units (est cost). They are estimates, not timings. - reason: the rule or the margin that decided.
- provenance: where the inputs came from.
Exact: a fresh, exact statistic.Sampled { size }: sampled evidence.Cached: a value reused from a cache or catalog.Histogram: column histogram interpolation.Stale: an outdated statistic.Default: no statistic; a fallback value was used.Structural: a rule with no numeric estimate.
- repeats: how often the same point, subject and choice recurred.
- Seeded and nested executions repeat decisions.
- The estimates shown are from the first occurrence.
- At most 128 distinct decisions are kept.
In Executed operators, [n] is the preorder index of the executed plan, and
rows= counts rows actually pulled from that operator. A LIMIT can leave its
input only partly consumed. rows=unobserved (bypassed/fused) marks an
operator that a fast path replaced or fused. A MatchScan (and, under
PATINADB_COST_MODEL=join, a join) also shows its estimate and its
q-error: the larger of estimate/actual and actual/estimate, with zero
counted as one. What the estimate means depends on the mode:
- Cost model off. The
MatchScanestimate is the entry node’s estimate, not the path’s output. A multi-hop scan therefore shows a large q-error even when the plan is right. - Cost model on. The estimate is the chosen entry’s predicted output rows
from
entry.cost, which is directly comparable torows=.
A large q-error shows where an estimate was wrong. It does not mean the plan was wrong. Worker-internal rows are not shown.
The examples below are real output from an in-process release build of this
source on the generated example dataset
(1,000 persons, 30,000 messages; #1442). Lines marked … are omitted. The
query texts are the SF1 Interactive and LSQB texts from
crates/patinadb/src/plan/exec/ldbc_pins/ (IC2, IC9) and
ldbc_pins/parallel_admission.rs (LSQB). The row counts are logical work on a
toy graph and are no performance measurement.
Example A: an IC2-style friends’ messages page
MATCH (:Person {id: $personId})-[:KNOWS]-(friend:Person)<-[:HAS_CREATOR]-(message:Message)
WHERE message.creationDate <= $maxDate
RETURN friend.id AS personId, friend.firstName AS personFirstName, friend.lastName AS personLastName,
message.id AS postOrCommentId, coalesce(message.content, message.imageFile) AS postOrCommentContent,
message.creationDate AS postOrCommentCreationDate
ORDER BY postOrCommentCreationDate DESC, toInteger(postOrCommentId) ASC
LIMIT 20
EXPLAIN with every switch off (personId = 1):
Limit skip=0 count=20
Sort keys=2
Project items=6 distinct=false return_star=false
MatchScan required=[[entry](_:Person) -(friend:Person) -(message:Message) → PropertyIndex[id] = est.1]
Storage: native (allocated database fits detected RAM at open)
Vertex property storage: owner-key directory; canonical values <= 256 serialized bytes inline, wider values in property/owner payload table; automatic physical routing, existing query access operators retained
Physical:
limit.bounded_topk
parallel.topk: serial: query thread cap is one
parallel.frontier: serial: query thread cap is one
Decisions:
entry.node _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"]: chosen=node[0] (est rows=1); minimum cardinality; stop at <=1; unlabelled nodes skipped; Default; repeats=3
entry.direction admission: chosen=fallback; shape, cardinality separation or sampling unavailable; retain pivot; Structural; repeats=3
entry.access None: chosen=property_value_index; static priority; no complete cost comparison; Structural; repeats=3
limit.rule edge_sorted_topk: chosen=declined; priority walk; stop at first applicable rule; Structural; repeats=2
limit.rule spatial_knn: chosen=declined; priority walk; stop at first applicable rule; Structural; repeats=2
limit.rule reverse_probe: chosen=declined; priority walk; stop at first applicable rule; Structural; repeats=2
limit.rule bounded_topk: chosen=applicable; priority walk; stop at first applicable rule; Structural; repeats=2
entry.node … chosen=node[0] (est rows=1): theidindex equality gives an estimate of 1. The walk stops there, so the other nodes are never estimated.entry.access … property_value_index: the entry is a point lookup on the value index (PropertyIndex[id] = est.1in the tree).limit.rule: no edge-sorted index covers this sort, so the walk ends atbounded_topk, a 20-row heap instead of a full sort.
With PATINADB_COST_MODEL=join and the degree catalog loaded, the same
EXPLAIN prices all three entries:
Decisions:
entry.cost.hops _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"] node[0]: chosen=estimate (est rows=376.5690376569038) (est cost=38508.96192405824); right[0] scanned=13.000 output=13.000 check_ns=8.933533 record_probes=0 regime=warm L2; right[1] scanned=390.000 output=376.569 check_ns=8.933533 record_probes=0 regime=warm L2; ; Exact; repeats=3
entry.cost.hops _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"] node[2]: chosen=estimate (est rows=376.56903765690384) (est cost=3045511.749845188); left[0] scanned=13000.000 output=13.000 check_ns=8.933533 record_probes=0 regime=warm L2; right[0] scanned=30000.000 output=28966.849 check_ns=8.933533 record_probes=0 regime=warm L2; ; Exact; repeats=3
entry.cost.hops _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"] node[4]: chosen=estimate (est rows=376.5690376569038) (est cost=40711683.6658395); left[0] scanned=28966.849 output=28966.849 check_ns=8.933533 record_probes=0 regime=warm L2; left[1] scanned=376569.038 output=376.569 check_ns=8.933533 record_probes=0 regime=warm L2; ; Exact; repeats=3
entry.cost _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"]: chosen=node[0] (est rows=376.5690376569038) (est ns=38508.96192405824) over node[2] (est rows=376.56903765690384) (est ns=3045511.749845188) over node[4] (est rows=376.5690376569038) (est ns=40711683.6658395); minimum calibrated primitive ns; dominant=expansion; warm redb default, L2 only within list and working-set caps; A1 population mean or legacy-default cache-only fallback; refresh=not needed; independent filters; constants A2-cal v1 #1442; Exact; repeats=3
entry.access None: chosen=property_value_index; static priority; no complete cost comparison; Structural; repeats=3
entry.cost.hopsshows, for each candidate, the scanned and accepted rows per hop, the label-check cost and the regime (warm L2). For node[0], 13KNOWSneighbours are expected (the mean undirected degree), then 390 messages, 376.6 of which pass the date filter.- The cost model picks the same entry as the reference. Every input is
Exact, so no margin applies.
PROFILE then shows how far the estimate was off:
Executed operators (query thread):
[0] Limit skip=0 count=20 rows=20
[1] Sort keys=2 rows=unobserved (bypassed/fused)
[2] Project items=6 distinct=false return_star=false rows=unobserved (bypassed/fused)
[3] MatchScan required=[(:Person) -(friend:Person) -(message:Message)] rows=5300 est=376.5690376569038 q-error=14.074
Person 1 is one of ten hubs in the dataset, with 155 outgoing KNOWS edges.
The catalog’s mean degree cannot see that, hence the q-error of 14. Only a
literal UUID seed would use the exact heavy-hitter degree. The plan is still the
best one available: a point entry followed by expansion.
Example B: IC9 and an edge-sorted index
IC9 collects friends and friends of friends, then pages their messages by
date. Without a covering index, PROFILE with every switch off shows the
seeded Apply expanding HAS_CREATOR once per friend:
Executed operators (query thread):
[0] Retain keep=[personId,personFirstName,personLastName,commentOrPostId,commentOrPostContent,commentOrPostCreationDate] rows=20
[1] Limit skip=0 count=20 rows=20
[2] Sort keys=2 rows=unobserved (bypassed/fused)
[3] Project items=7 distinct=false return_star=false rows=unobserved (bypassed/fused)
[4] Apply imports=[] scope=InheritOuter rows=17550
[5] Retain keep=[friend,message] rows=565
[6] Unwind binding=friend rows=unobserved (bypassed/fused)
[7] Aggregate group_by=0 aggs=1 rows=1
[8] Filter rows=2344
[9] MatchScan required=[(root:Person) -(friend:Person)] rows=2344 est=1 q-error=2344.000
[10] MatchScan required=[(friend:_) -(message:Message)] rows=unobserved (bypassed/fused)
…
traversal.source fixed ["HAS_CREATOR"] reverse=true: chosen=redb over csr over l2; CSR off or auto (fixed hops require on); Exact; repeats=565
…
Resolved vertex property storage (query thread; logical resolutions, not physical I/O; workers/caches/buffers may bypass):
inline=42100 canonical bytes=241541; external=0 canonical bytes=0
After CREATE EDGE SORTED INDEX FOR (m:Message)<-[:HAS_CREATOR]-() ON m.creationDate (run on a copy of the dataset), the limit.edge_sorted_topk
rule applies. The ordered frontier then merges the friends’ date-sorted edge
lists. The HAS_CREATOR traversal reader is entered 20 times instead of 565
(see repeats):
Physical:
limit.edge_sorted_topk
…
Executed operators (query thread):
[0] Retain keep=[personId,personFirstName,personLastName,commentOrPostId,commentOrPostContent,commentOrPostCreationDate] rows=20
[1] Limit skip=0 count=20 rows=20
[2] Sort keys=2 rows=unobserved (bypassed/fused)
[3] Project items=7 distinct=false return_star=false rows=unobserved (bypassed/fused)
[4] Apply imports=[] scope=InheritOuter rows=unobserved (bypassed/fused)
[5] Retain keep=[friend,message] rows=565
[6] Unwind binding=friend rows=unobserved (bypassed/fused)
[7] Aggregate group_by=0 aggs=1 rows=1
[8] Filter rows=2344
[9] MatchScan required=[(root:Person) -(friend:Person)] rows=2344 est=1 q-error=2344.000
[10] MatchScan required=[(friend:_) -(message:Message)] rows=unobserved (bypassed/fused)
…
traversal.source fixed ["HAS_CREATOR"] reverse=false: chosen=redb over csr over l2; CSR off or auto (fixed hops require on); Exact; repeats=20
…
Resolved vertex property storage (query thread; logical resolutions, not physical I/O; workers/caches/buffers may bypass):
inline=92 canonical bytes=506; external=0 canonical bytes=0
The Apply is now fused (unobserved), and 92 property resolutions replace
42,100. These are logical counts from one run each, not timings. The q-error of
2,344 on [9] compares the entry estimate (1, the id lookup) with the 2,344
rows that the KNOWS*1..2 expansion produced. With the cost model off, that is
expected. The cost model would not change it either, because it does not price
variable-length paths.
Example C: an LSQB-style count
LSQB Q2 with PATINADB_COST_MODEL=join:
MATCH (person1:Person)-[:KNOWS]-(person2:Person),
(person1)<-[:HAS_CREATOR]-(comment:Comment)-[:REPLY_OF]->(post:Post)-[:HAS_CREATOR]->(person2)
RETURN count(*) AS count
Planned access (before execution):
Project items=1 distinct=false return_star=false
Aggregate group_by=0 aggs=1
Join kind=hash keys=[person1,person2]
left:
Aggregate group_by=2 aggs=1
MatchScan required=[[entry](person1:Person) -(person2:Person) → LabelScan ⚠ (no matching index) est.1000]
right:
Aggregate group_by=2 aggs=1
MatchScan required=[(person1:Person) -(comment:Comment) -(post:Post) -[entry](person2:Person) → LabelScan ⚠ (no matching index) est.1000]
…
Executed operators (query thread):
[0] Project items=1 distinct=false return_star=false rows=1
[1] Aggregate group_by=0 aggs=1 rows=1
[2] Join kind=hash keys=[person1,person2] rows=269
[3] Aggregate group_by=2 aggs=1 rows=13000
[4] MatchScan required=[[entry](person1:Person) -(person2:Person) → LabelScan ⚠ (no matching index) est.1000] rows=13000 est=13000 q-error=1.000
[5] Aggregate group_by=2 aggs=1 rows=20000
[6] MatchScan required=[(person1:Person) -(comment:Comment) -(post:Post) -[entry](person2:Person) → LabelScan ⚠ (no matching index) est.1000] rows=20000 est=20000 q-error=1.000
Decisions (executed):
join.strategy step 1: chosen=Hash (est rows=31070) over NestedLoop (est rows=1000); connected and anchored/probe eligible; otherwise hash; Structural; repeats=1
join.order bases=[1000.0, 31070.0]: chosen=[0, 1] (est rows=31070) (est cost=32070); DP (cap 8), anchored priority; Default; repeats=1
…
join.order person1:["Person"] - ["KNOWS"]:Undirected - person2:["Person"] | person1:[] - ["HAS_CREATOR"]:Inbound - comment:["Comment"] - ["REPLY_OF"]:Outbound - post:["Post"] - ["HAS_CREATOR"]:Outbound - person2:[]; relations=2; guard: chosen=reference [0, 1] (est rows=260) (est ns=34999058.497517996) over subset DP [1, 0] (est rows=403.90999999999997) (est ns=33889480.401324295); calibrated ns; kept reference: predicted advantage below 1.1x; Exact; repeats=1
plan.cache join planning epoch: chosen=built; schema_epoch=0; retained decisions; data writes retain plans; new degree catalogs replan; Cached; repeats=1
How to read it:
- The join. The two patterns share both endpoints but use disjoint relationship types, so they stay separate join inputs.
- The rewrite. The partial
COUNTrewrite turned the hash join into a join of two grouped counts: 13,000 and 20,000 grouped rows meet in 269 key matches. - The reference decisions.
join.order(DP over 2 patterns) andjoin.strategy(hash) were made by the reference planner.bases=[1000.0, 31070.0]shows the secondary-label fallback for the second pattern. - The guard. The
join.order … guardline is the A3 search. It found a different order 3% cheaper, which is below the 1.1× margin for exact inputs, so the reference order was kept. - The cache entry.
plan.cache … chosen=builtrecords the schema epoch of these decisions. - Estimate quality. Both
MatchScanestimates have a q-error of 1.000. Under the cost model they are the predicted output rows from the degree catalog. With the switches off, the same scans show the label-count entry estimate of 1,000.
A keyed consumer looks like this. These lines are from LSQB Q7 under
PATINADB_COST_MODEL=entry:
entry.cost.consumer _:["Tag"] - ["HAS_TAG"]:Inbound - message:["Message"] - ["HAS_CREATOR"]:Outbound - creator:["Person"] node[0]: chosen=keyed runs (est cost=18684342.197546996); message:LIKES runs=30000.000 ns_per_run=305.783; message:REPLY_OF runs=30000.000 ns_per_run=317.028; ; Exact; repeats=1
…
entry.cost.consumer _:["Tag"] - ["HAS_TAG"]:Inbound - message:["Message"] - ["HAS_CREATOR"]:Outbound - creator:["Person"] node[2]: chosen=keyed runs (est cost=18684342.197546996); message:LIKES runs=30000.000 ns_per_run=305.783; message:REPLY_OF runs=30000.000 ns_per_run=317.028; ; Exact; repeats=1
…
entry.cost.consumer _:["Tag"] - ["HAS_TAG"]:Inbound - message:["Message"] - ["HAS_CREATOR"]:Outbound - creator:["Person"] node[4]: chosen=keyed runs (est cost=18684342.197546996); message:LIKES runs=30000.000 ns_per_run=305.783; message:REPLY_OF runs=30000.000 ns_per_run=317.028; ; Exact; repeats=1
…
entry.cost _:["Tag"] - ["HAS_TAG"]:Inbound - message:["Message"] - ["HAS_CREATOR"]:Outbound - creator:["Person"]: chosen=node[0] (est rows=30000) (est ns=29063112.205906995) over node[2] (est rows=30000) (est ns=38630507.417546995) over node[4] (est rows=30000) (est ns=29369677.606546998); minimum calibrated primitive ns; dominant=keyed consumer; warm redb default, L2 only within list and working-set caps; A1 population mean or legacy-default cache-only fallback; refresh=not needed; independent filters; keyed-consumer runs priced; constants A2-cal v1 #1442; Exact; repeats=1
Example D: a triangle count under WCOJ
MATCH (a:Person)-[:KNOWS]-(b:Person)-[:KNOWS]-(c:Person)-[:KNOWS]-(a)
RETURN count(*) AS triangles
With COST_MODEL=join WCOJ=auto CSR=auto FACTORIZE=on, under a query memory
scope:
Executed operators (query thread):
[0] Project items=1 distinct=false return_star=false rows=1
[1] Aggregate group_by=0 aggs=1 rows=1
[2] MatchScan required=[(a:Person) -(b:Person) -(c:Person) -(a:_)] rows=26760 est=338000 q-error=12.631
…
entry.cost a:["Person"] - ["KNOWS"]:Undirected - b:["Person"] - ["KNOWS"]:Undirected - c:["Person"] - ["KNOWS"]:Undirected - a:[]: chosen=node[2] (est rows=338000) (est ns=22099296.471) over node[0] (est rows=338000) (est ns=82355575.995) over node[4] (est rows=338000) (est ns=23492927.619); minimum calibrated primitive ns; dominant=row construction; warm redb default, L2 only within list and working-set caps; A1 population mean or legacy-default cache-only fallback; refresh=not needed; independent filters; constants A2-cal v1 #1442; Default; repeats=1
entry.intersect homogeneous triangle: chosen=node[0]; calibrated closing-domain candidate beats binary expansion; complete prefix required; Structural; repeats=1
…
expand.intersect bounded closing domains: chosen=intersect (est rows=13) (est cost=3120.355953) over binary (est rows=182) (est cost=6236.583665); left=Some(Exact(13.0)); right=Some(Exact(14.0)); closing=14 (homogeneous proxy); lower bounds must win over their open tails; Sampled { size: 256 }; repeats=11410
traversal.source "KNOWS" reverse=false: chosen=l2/redb over csr; not built; Exact; repeats=22820
traversal.source "KNOWS" reverse=true: chosen=l2/redb over csr; background build in progress; Exact; repeats=22820
expand.intersect two-hop closing domain: chosen=intersect over binary; auto cost admitted; sorted domains; Structural; repeats=11410
expand.into closing degree: chosen=scan; cached domain length or catalog degree versus one seek; unknown retains scan; Structural; repeats=36430
…
expand.into closing degree: chosen=probe; cached domain length or catalog degree versus one seek; unknown retains scan; Structural; repeats=6644
expand.into repeated binding: chosen=probe over scan; auto cost admitted; fixed bound target; Structural; repeats=6644
expand.intersect bounded closing domains: chosen=binary (est rows=2080) (est cost=7658.041013999999) over intersect (est rows=13) (est cost=10437.203915000002); left=Some(Exact(13.0)); right=Some(Exact(160.0)); closing=160 (homogeneous proxy); lower bounds must win over their open tails; Sampled { size: 256 }; repeats=1590
entry.costwould start atb(node[2]).entry.intersectthen overrides this toa, which gives the intersection its complete prefix.expand.intersect bounded closing domainsis the per-candidate sampling decision. With 13 and 14 neighbours the intersection wins. Against a hub with 160 neighbours, the binary plan wins. Both appear, with their ownrepeats.expand.into … probeis the C0 closing probe, admitted where the degree makes a scan dearer than one seek.
With WCOJ=on the cyclic join takes the whole pattern, and the MatchScan
becomes unobserved:
Executed operators (query thread):
[0] Project items=1 distinct=false return_star=false rows=1
[1] Aggregate group_by=0 aggs=1 rows=1
[2] MatchScan required=[(a:Person) -(b:Person) -(c:Person) -(a:_)] rows=unobserved (bypassed/fused)
…
join.wcoj variable order / (domain, conditional) estimates: [(Some("a"), Some(1000.0), Some(1000.0)), (Some("b"), Some(1000.0), Some(13.0)), (Some("c"), Some(1000.0), Some(0.16899999999999998))]: chosen=leapfrog over binary; completed base adjacency join; Cached; repeats=1
All three runs (off, auto, on) returned 26,760 triangles.
Practical guidance
Make the planner’s job easy
- Use parameters. Parameters share one cached plan and keep plan-build cost
off the hot path. Literals are normalized too, but not in queries with two or
more
MATCHclauses or with comments. Remember that every value of a parameter shares the join order built for the first one. - Label the anchor and its neighbours. Unlabelled nodes are skipped as entry candidates. Put the most selective label first when you create nodes, because only the primary label has exact counts and value indexes. A label used only as a secondary label is estimated as the whole graph.
- Filter on indexed properties of labelled nodes.
- Equality, range and
STARTS WITHon a property of a node’s primary label can seek the label-scoped value index (see the data model); the IC9 plan of Example B shows(message:Message) → PropertyIndex[creationDate] < est.30000although the dataset declares no index oncreationDate. - Declare a compound index for equality plus range or sort combinations.
- Declare an edge-sorted index for “traversal,
then
ORDER BY target.prop LIMIT k”.EXPLAINprints anAdvice:line with the exact DDL when such an index would apply.
- Equality, range and
- Paginate with keys, not deep
SKIP.WHERE n.p > $cursor ORDER BY n.p LIMIT kseeks to the cursor.SKIP nstill has to walk pastnqualifying rows. See Planning note for filtered pages. - Keep comma patterns connected. A pattern that shares no variable becomes a cartesian product, capped at 10,000 rows by default.
- Avoid literal-only differences in big multi-
MATCHqueries. Such queries are cached per exact text, so each literal variant builds its own plan.
Diagnosing a slow plan, step by step
EXPLAINfirst. Check the[entry]marker and the access path of everyMatchScan. Warning signs:LabelScan ⚠ (no matching index), orAllVertices ⚠;- estimates of
est.∞or of the whole graph (secondary-label fallback); Joinwithout keys (a cartesian product).
- Read
Decisions:. Find theentry.nodeorentry.costline for the slow pattern.DefaultorStaleprovenance means the planner had no usable statistics. Check thelimit.rulewalk and anyAdvice:line. PROFILE.- Compare
rows=withest=for everyMatchScanand join; the largest q-error is usually the culprit. - Check whether expensive operators are
unobserved(a fast path engaged), or carry largerepeats(seeded per outer row). - The
traversal.sourcelines show whether adjacency came froml2,redborcsr.
- Compare
- Fix the input before the knob. Add the label, the index or the parameter. Rewrite the query so the selective pattern is connected to the rest.
- Then try a switch on a test system, comparing
PROFILEoutput and timing with and without it. The RFC 0042 switches are on by default, so the comparison is usually againstoff.
| Symptom | What to try |
|---|---|
| Entry on a large label although a small one exists, q-error large at the entry | CALL patinadb.stats.degrees() for an exact catalog (the default join model otherwise waits for its background sample); compare with PATINADB_COST_MODEL=off |
| Comma-pattern join order looks wrong | read the join.order … guard line; compare with PATINADB_COST_MODEL=entry or off |
| Counts or grouped counts over two branches of a pivot build huge row counts | PATINADB_FACTORIZE is on by default; with the cost model only the count path is costed, so PATINADB_COST_MODEL=off engages every proven shape |
| Triangles, mutual friends, cycles over one relationship type | PATINADB_WCOJ=auto is the default; on for the cyclic join |
Deep variable-length reachability (count(DISTINCT f) over *1..k) | PATINADB_CSR=auto is the default; check csr.admission |
| A query is slower than before the 2026-10-17 default change | compare with each switch off alone, then all four off (the previous behaviour) |
| Large scans or aggregations on an idle multi-core host | PATINADB_QUERY_THREADS > 1, preferably with a cost model |
| Small point-anchored queries are slower on their first execution with a cost model | Expected; repeat executions reuse the cached entry choice (see Planning budget) |
Known limitations
- The reference join estimate ignores fan-out, and cached join orders are never re-validated (see The plan cache).
- The cost model assumes independent filters and uses mean degrees. Correlated paths and hubs reached through non-literal seeds can be misestimated, as in Example A.
- Statistics are primary-label only, except for the degree catalog.
- The RFC 0042 switches are on by default since 2026-10-17; the measured gain is suite-level, and single queries can be slower (see Measured effects). See Limitations, in particular the sections on cyclic joins and factorized operators.
Measured effects
These are the joint switch-matrix results recorded in RFC 0042
(docs/rfcs/0042-query-engine-modernisation.md, section “Joint switch
matrix”, #1442). The ratios are geometric means of warm medians, relative to
every switch off; below 1 is faster. They come from a shared host with
documented disturbances. Treat them as indicative. They are not a
qualification and not a promise for your workload.
| Suite | allauto / off (before fixes) | allauto / off (fixed head, 3 rounds) |
|---|---|---|
| Velr (11 + OLTP) | 0.533 | 0.592 |
| LSQB SF0.1 (9) | 1.072 | 0.865 |
| LDBC SF1 Interactive (28) | 1.129 | 1.011 |
allauto is COST_MODEL=join FACTORIZE=on WCOJ=auto CSR=auto. In the same
runs:
FACTORIZEcarried most of the Velr gain (A1 0.09).- WCOJ gave LSQB Q3 0.80–0.81.
- SF1 IC2 stayed 8–18% slower with the cost model, with an identical plan. The targeted IC2 line-2 timing was 9.86 ms off against 12.04 ms allauto: per-query cost-model work, not a plan change. The A2 entry-decision cache below removed it. With it, allauto against off gave Velr 0.544, LSQB 0.878 and SF1 0.995, IC2 0.82/0.81 and IC14 0.80/0.71 (3 rounds; RFC 0042 A6). IC10, IC11 line 3 and IC12 line 3 stayed 3–10% slower (IC11 line 3 at 1.10).
- The maintainer adopted
allautoas the default on 2026-10-17 on this result (#1442).
Per case, the A6 comparison that the default was adopted on looks like this. Darker bars changed by at least 3% in the same direction in every round; lighter bars did not.
Figure 4a. Velr per case. A1, A4, A6 and A8 are the cases that RFC 0042 attributes to FACTORIZE (indicative, #1442).
Figure 4b. LSQB per case: Q3, Q4, Q5 and Q8 are faster; no case is consistently slower (indicative, #1442).
Figure 4c. SF1 per case: IC2 and IC14 are faster; IC8 line 3, IC10, IC11 and IC12 line 3 are consistently 3–10% slower (indicative, #1442).
Figure 4d. Leave-one-out on the earlier, pre-fix binary: removing FACTORIZE costs Velr most. The three combined-switch regressions that RFC 0042 records for this run (LSQB Q4 and Q7, SF1 IC8) were fixed afterwards; compare the table above (indicative, #1442).
Planning budget
With a cost model on, the entry model runs every time a pattern is executed, because the entry choice is not part of the cached plan. Three mechanisms keep that cheap without changing any decision (RFC 0042 A6, #1442):
- Catalog reuse. The degree catalog is decoded once per transaction and published catalog, and each label and relationship-type generation is read once.
- Exact pruning. Candidates are priced in ascending order of a lower bound: rows × non-empty sides × one seek. A candidate whose bound already exceeds the best estimate is skipped. EXPLAIN and PROFILE still price and show every candidate.
- A2 entry-decision cache (introduced in RFC 0042 A6). A completed A2 choice is reused by later executions of the same pattern
with the same parameter values.
- The cache holds up to 64 entries per process, within the governor-visible
planner.entriescap ofmin(Q/16, 4 MiB)estimated bytes (#1467).Qis the auxiliary cache quantum after native/CSR adjustments.PATINADB_CACHE_LIMIT=0disables reuse; there is no separate A2 switch. - On a fill, pattern and parameter ownership is estimated and reserved before copying. Oversize keys, values exceeding the 128-level sizing traversal, and function/expression AST literals without a layout model skip this optional cache. The query still uses the ordinary entry planner. Hits retain exact key comparison and snapshot validation without repeating the sizing walk. The byte model includes live construction and evicted-but-pinned readers; it is not an exact allocator or RSS bound.
- Cache contention causes a miss or skipped fill, so planning uses the ordinary cost model. Snapshot metadata is validated outside the global cache mutex (#1466); every existing dependency check still applies.
- An entry is reused only while the schema, the statistics, the published degree catalog and every label/relationship-type generation the choice read are unchanged.
- Transactions that wrote, snapshot views, EXPLAIN/PROFILE and runs that requested a statistics refresh do not use it.
- A valid hit skips entry costing; validating its metadata still performs reads (RFC 0042 A6). Native READY and generation readers now decode borrowed bytes (#1474), avoiding their owned value copy while keeping all these reads and dependency checks. Overlay reads retain their existing fallback, and buffered own writes can still copy during lookup. No new contention or query-throughput measurement is claimed.
- The cache holds up to 64 entries per process, within the governor-visible
A budget gate that skipped the cost model for cheap-looking queries was measured and not adopted. It judged each inner pattern alone, so a query with an expensive pattern comprehension lost the model’s better entry. See RFC 0042 A6.
Appendix: the example dataset
The outputs above were produced on a graph generated by these statements,
executed in order on an empty embedded database. The EXPLAIN and PROFILE
runs used PATINADB_CACHE_LIMIT=1GiB, except the one shown without a cache
limit. Example D and the factorized, CSR and parallel excerpts also ran under a
2 GiB query memory scope. Each switch combination was a separate process. Every
process except the edge-sorted-index run (switches off) ran
CALL patinadb.stats.degrees() first.
CREATE INDEX ON :Person(id);
CREATE INDEX ON :Tag(id);
CREATE INDEX ON :Country(id);
CREATE INDEX ON :City(id);
UNWIND range(1, 5) AS i CREATE (:Place:Country {id: i, name: 'country' + toString(i)});
UNWIND range(1, 25) AS i MATCH (co:Country {id: (i % 5) + 1}) CREATE (:Place:City {id: 100 + i, name: 'city' + toString(i)})-[:IS_PART_OF]->(co);
UNWIND range(1, 1000) AS i MATCH (c:City {id: 100 + (i % 25) + 1}) CREATE (:Person {id: i, firstName: ['Chau', 'Ali', 'Jun', 'Eva'][i % 4], lastName: 'L' + toString(i % 17), creationDate: 1300000000000 + i * 1000000})-[:IS_LOCATED_IN]->(c);
MATCH (a:Person) UNWIND [5, 10, 15, 29, 61] AS d MATCH (b:Person {id: ((a.id + d - 1) % 1000) + 1}) CREATE (a)-[:KNOWS {creationDate: 1300000000000 + a.id * d}]->(b);
MATCH (a:Person) WHERE a.id <= 10 UNWIND range(1, 150) AS d MATCH (b:Person {id: ((a.id + d * 3 + 100) % 1000) + 1}) CREATE (a)-[:KNOWS {creationDate: 1300000000000 + d}]->(b);
UNWIND range(1, 40) AS i CREATE (:Tag {id: i, name: 'tag' + toString(i)});
MATCH (p:Person) UNWIND [0, 17] AS k MATCH (t:Tag {id: ((p.id + k) % 40) + 1}) CREATE (p)-[:HAS_INTEREST]->(t);
MATCH (p:Person) UNWIND range(1, 10) AS k CREATE (:Message:Post {id: p.id * 100 + k, creationDate: 1300000000000 + (p.id * 37 + k * 1009) * 100000, content: 'post ' + toString(k)})-[:HAS_CREATOR]->(p);
MATCH (m:Post) WITH m MATCH (t:Tag {id: (m.id % 40) + 1}) CREATE (m)-[:HAS_TAG]->(t);
MATCH (m:Post) UNWIND [3, 11] AS d MATCH (p:Person {id: ((m.id * d) % 1000) + 1}) CREATE (c:Message:Comment {id: m.id * 10 + d, creationDate: m.creationDate + d * 1000, content: 'comment'})-[:HAS_CREATOR]->(p), (c)-[:REPLY_OF]->(m);
MATCH (c:Comment) WITH c MATCH (t:Tag {id: (c.id % 40) + 1}) CREATE (c)-[:HAS_TAG]->(t);
MATCH (m:Message) WHERE m.id % 3 = 0 WITH m MATCH (p:Person {id: ((m.id * 13) % 1000) + 1}) CREATE (p)-[:LIKES]->(m);
KNOWS offsets 5, 10 and 15 form triangles. Persons 1–10 each get 150 extra
outgoing KNOWS edges (hubs). Message is the primary label of every post and
comment.