Skip to main content

Troubleshooting LoraDB Errors

When a query fails, returns no rows, or the server refuses to start, find the symptom in the lookup table below and jump to the fix. Each section names the cause, shows the failure mode, and gives the shortest way out.

Frequent fixes​

Why does my LoraDB query return no rows when I know the data is there?

Cypher labels and relationship types are case-sensitive. (:Person) and (:person) match different nodes; [:KNOWS] and [:knows] are different relationship types. Check the case of every label and type in the query against what was used at CREATE time. If labels match, also confirm the direction of relationships — (a)-[:R]->(b) does not match (b)-[:R]->(a).

Why is LoraDB throwing 'Unknown function' on a function I know exists?

LoraDB functions are namespaced. string.lower(x) works; lower(x) without the namespace does not. See Functions → Overview for the full list of namespaces (string, math, list, map, temporal, spatial, vectors, utility) and the compatibility aliases.

How do I fix 'DeleteNodeWithRelationships' when deleting a node?

Plain DELETE refuses to remove a node that still has relationships attached, to prevent dangling edges. Use DETACH DELETE n to remove the node and every relationship touching it in one step, or DELETE every relationship first and then DELETE the node.

Why does my LoraDB MATCH produce an N times M row explosion?

Independent MATCH patterns produce a Cartesian product unless joined by a shared variable. MATCH (a:Person), (b:Movie) is one pattern; MATCH (a:Person)-[:WATCHED]->(b:Movie) is another. If you need both pieces independently, project them through WITH and re-MATCH, or wire them together via a shared variable.

Why does my SET clause overwrite existing properties instead of merging?

SET n = {key: value} replaces every property on n with the given map. To add or update specific properties without disturbing the rest, use SET n.key = value or SET n += {key: value} for merge semantics.

Quick lookup​

SymptomJump to
Parse error, missing paren/directionParse errors
Unknown variable, Unknown function, WrongAritySemantic errors
DeleteNodeWithRelationshipsExecutor errors
Query returns empty for no reasonQueries return empty results
N × M row explosionMATCH returns a cross-product
SET destroyed propertiesSET wiped my properties
DELETE complains about edgesDELETE fails
Parameters seem ignoredParameters
Server won't startServer
Admin snapshot endpoint returns 404Snapshots → /admin/snapshot/* returns 404
A .tmp file is left beside the snapshotSnapshots → leftover .tmp file
Snapshot load fails with "bad magic" or checksum mismatchSnapshots → load fails with bad magic / checksum
Snapshot load reports unsupported versionSnapshots → unsupported format version
Result JSON shape is wrongResult format
Install or package build failsInstall and setup
Build failsBuild

Install and setup​

I just want to try LoraDB​

Use play.loradb.com first. It runs in the browser and avoids local toolchain setup. Move to a binding or the HTTP server once you need runtime-supplied parameters; use a binding when you also want host-language types or direct persistence APIs.

yarn uses the wrong version​

The repository pins Yarn through Corepack. From the repository root:

corepack enable
yarn install --immutable

If yarn install --immutable complains about the lockfile after a pull, do not edit yarn.lock by hand. Re-run with the pinned Yarn and check whether the branch intentionally changed dependencies.

Node package scripts fail immediately​

Use Node.js 20+. The docs site, playground, WASM package, and workspace tooling assume the Node 20 runtime even when a single package may appear to build on older versions.

Go binding cannot find liblora_ffi​

Build the Rust FFI library before running Go tests:

cargo build --release -p lora-ffi
cd crates/bindings/lora-go
go test -race ./...

Consumer projects outside the repository need CGO_CFLAGS and CGO_LDFLAGS pointing at the release archive or local checkout. See Go → Installation.

Build​

error: linker 'cc' not found​

Install a C toolchain. On macOS:

xcode-select --install

cargo: command not found or wrong Rust version​

Install Rust with rustup, then let the repository's rust-toolchain.toml select the pinned stable toolchain:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup show

The workspace currently requires Rust 1.87 or newer. If cargo build reports an older compiler, run rustup update stable and retry from the repository root.

Slow release builds​

Release builds use lto = "fat" and codegen-units = 1. For faster iteration, use debug builds:

cargo build # debug — fast
cargo build --release # release — slow, optimised

Server​

Address already in use​

Another process holds the server port (default 4747):

lsof -i :4747
kill <PID>

Or start on a different port:

lora-server --port 5000
# or
LORA_SERVER_PORT=5000 lora-server

See HTTP Server → run for all options.

HTTP 400 on every request​

Check the content-type header — the server expects application/json:

curl -s http://127.0.0.1:4747/query \
-H 'content-type: application/json' \
-d '{"query": "MATCH (n) RETURN count(*)"}'

Result JSON looks nothing like what I expected​

Different format values return different shapes. The engine default is graph, which returns deduplicated nodes+edges — if you were expecting rows, pass "format": "rows" (or "rowArrays"). See Result formats for every shape and when to pick which.

Queries​

Parse errors​

Common mistakes:

  • Missing parentheses: MATCH n → MATCH (n).
  • Missing direction on CREATE: (a)-[:T]-(b) is valid in MATCH, not in CREATE. Use -[:T]-> or <-[:T]-.
  • Missing type on CREATE: (a)-[]->(b) must have a type, e.g. -[:FOLLOWS]->.
  • BETWEEN is not supported — use x >= a AND x <= b. See Limitations.
  • Unclosed string literal — double the quote to escape: 'it''s fine'. See Scalars → String.

Semantic errors​

MessageCause
Unknown variable xx wasn't introduced by an earlier clause, or it was dropped by a WITH that didn't project it.
unsupported feature: unknown procedureGeneral-purpose procedures such as db.labels() are not implemented. The supported CALL surface is limited to vector and full-text index query procedures; see Indexes.
Unknown function 'foo'Not in the built-in list. See Functions.
WrongArityFunction exists but was called with the wrong number of arguments.
Aggregate in WHEREAggregates aren't allowed in WHERE. Use WITH … WHERE.

Executor errors​

MessageCause
DeleteNodeWithRelationshipsUse DETACH DELETE instead of plain DELETE.
MissingRelationshipTypeCREATE (a)-[]->(b) — a relationship must have a type.
ReadOnlyCreateShould not occur via normal paths; file a bug if you see this.
LIMIT expects a non-negative integer, got nullSKIP / LIMIT got null (often a parameter bound to null), a negative number or a fraction. See below.
expected parameter: $nameThe query uses $name but the params map has no name entry. See Unbound parameter.

Queries return empty results​

  1. Data was created on a different non-persistent handle. Plain in-memory databases start empty on each process run. Use a container-backed open (createDatabase("app", { databaseDir: "./data" }), Database.create("app", {"database_dir": "./data"}), lora.New("app", lora.Options{DatabaseDir: "./data"}), etc.), an explicit WAL open (openWalDatabase({ walDir: "./wal" }), Database.open_wal("./wal"), lora.OpenWal(...), LoraRuby::Database.open_wal("./wal")), or load a snapshot if you expect data to survive restarts. See Limitations → Storage.
  2. Label typo or case mismatch — :user ≠ :User, and :Usr is not an error. Labels and types are case-sensitive, and a name no entity carries simply matches nothing. See Nodes and Schema-free.
  3. Property type mismatch — {id: 1} matches integer 1, not the string "1". See Data Types.
  4. A parameter is null — a parameter bound to null never equals anything, so it filters everything out. (A parameter that is missing from the map is an error, not null.) See Parameters.
  5. = null — never matches. Use IS NULL / IS NOT NULL.
  6. Regex anchoring — =~ 'foo' matches only the full string "foo". Use .* or CONTAINS for substring. See WHERE → regex.

MATCH returns a cross-product​

MATCH (a:User), (b:User) RETURN a, b    // N * N rows

Use a relationship pattern to connect them:

MATCH (a:User)-[:FOLLOWS]->(b:User) RETURN a, b

Or scope both sides before the write:

MATCH (a:User {id: $from}), (b:User {id: $to})
CREATE (a)-[:FOLLOWS]->(b)

SET wiped my properties​

SET n = {…} replaces the property map. To update individual keys:

SET n.prop = value         // single key
SET n += {newProp: value}  // merge keys

DELETE fails with "still has relationships"​

MATCH (n:User {id: 1}) DETACH DELETE n

DETACH DELETE removes the edges in one step.

WITH dropped my variable​

A variable must be explicitly projected through WITH:

MATCH (a)-[r:KNOWS]->(b)
WITH a                     // r and b are now out of scope
RETURN a, r                // error: Unknown variable r

Either pass them through — WITH a, r, b — or don't bind them in the first place.

Aggregation gave one row when I expected per-group​

Every non-aggregated column in the same RETURN becomes part of the implicit group key. See Aggregation → Grouping.

Ordering puts nulls in an unexpected place​

null sorts last ASC / first DESC. Override with coalesce:

MATCH (p:Person)
RETURN p.name, p.rank
ORDER BY coalesce(p.rank, 2147483647) ASC

See Ordering → nulls in ordering.

Shortest path returns nothing​

  1. No path exists between the endpoints.
  2. The relationship type filter excludes the only path.
  3. Direction is too strict — try [:R*] or [:R*]- (undirected) on MATCH.

Wrap in OPTIONAL MATCH if you still want a row:

MATCH (a:User {id: $from}), (b:User {id: $to})
OPTIONAL MATCH p = shortestPath((a)-[:FOLLOWS*]->(b))
RETURN a, b, path.length(p) AS hops

Snapshots​

/admin/snapshot/* returns 404​

Symptom: POST /admin/snapshot/save or /admin/snapshot/load returns 404 Not Found.

Likely cause: The server was not started with --snapshot-path <PATH> (or the LORA_SERVER_SNAPSHOT_PATH env var). The admin routes are opt-in — they are not mounted at all when that flag is unset.

Fix: Restart with the flag, or use a per-binding save_snapshot call instead.

lora-server \
--host 127.0.0.1 --port 4747 \
--snapshot-path /var/lib/lora/db.bin

See HTTP server → Snapshots, WAL, and restore and the HTTP API reference.

Leftover .tmp file beside the snapshot​

Symptom: A file named <path>.tmp sits next to the target snapshot file.

Likely cause: A save was interrupted — SIGKILL, a power loss, or ENOSPC on the target disk. The save writes to <path>.tmp, fsyncs, and renames over the target in one atomic step. If the process died between the write and the rename, the tmp remains.

Fix: If the target <path> still exists, it is valid and loads cleanly — the last successful save. Delete the <path>.tmp and investigate whatever killed the process (disk space? OOM? operator error?). If the target does not exist, the tmp is your most recent attempt but has not been atomically committed — rename it and try loading; if checksum validation fails, restore from an earlier backup.

Snapshot load fails with "bad magic" or checksum mismatch​

Symptom: a bad-magic error, checksum-mismatch error, or snapshot load failed: bad magic style message on load.

Likely cause:

  • Bad magic — the file is not a LoraDB snapshot. The first 8 bytes should be LORACOL1 for the current columnar format.
  • Checksum mismatch — the file is corrupt (truncated, bit-flipped, copied incompletely, or an unrelated file matching the magic by accident).

Fix:

# Confirm it looks like a snapshot at all.
head -c 8 path/to/snapshot.bin
# => LORACOL1

If the magic is wrong, check you pointed at the right path. If the magic is right but the checksum fails, restore from a known-good copy — a corrupt snapshot never loads partially on purpose, to prevent silently accepting half a graph. See Snapshots → File format for the on-disk layout.

Snapshot load reports unsupported format version​

Symptom: SnapshotError::UnsupportedVersion on load.

Likely cause:

  • The file was written by a newer LoraDB than the reader — the reader is older than the writer.
  • The file was written by an obsolete LoraDB whose format has since been retired.

Fix:

  • If the reader is older: upgrade the reader to a release that understands the file's format.
  • If the file is from a retired format: use the last LoraDB release that accepted that version, export via Cypher (MATCH (n) RETURN n and MATCH (a)-[r]->(b) RETURN id(a), id(b), type(r), properties(r)), and re-import on the new release.

Version / compatibility policy (internal): Change management → Snapshot format compatibility.

WAL and checkpoints​

/admin/wal/* or /admin/checkpoint returns 404​

Symptom: POST /admin/wal/status, POST /admin/wal/truncate, or POST /admin/checkpoint returns 404 Not Found.

Likely cause: The server was not started with --wal-dir <DIR> (or the LORA_SERVER_WAL_DIR env var). WAL admin routes are mounted only when a WAL directory is attached.

Fix: Restart with a WAL directory:

lora-server \
--host 127.0.0.1 --port 4747 \
--wal-dir /var/lib/lora/wal

See WAL and checkpoints and HTTP API → Admin endpoints.

/admin/checkpoint returns 400​

Symptom: POST /admin/checkpoint returns 400 Bad Request with a message about no checkpoint path.

Likely cause: The server has --wal-dir but no --snapshot-path, and the request body did not provide a path. WAL-only deployments can checkpoint, but the target snapshot path must come from the request body.

Fix: Either pass path in the request:

curl -sX POST http://127.0.0.1:4747/admin/checkpoint \
-H 'content-type: application/json' \
-d '{"path":"/var/lib/lora/checkpoint.bin"}'

or start the server with --snapshot-path so body-less checkpoints have a default target.

WAL/container root is already open​

Symptom: Opening a WAL-backed or container-backed database fails with a LORA_LOCKED error saying the database directory is locked by another process or live handle.

Likely cause: Another live process or database handle already owns that WAL directory or .loradb archive. LoraDB takes a lock so two appenders cannot write to the same log at once.

Fix: Use one WAL/container root per live database, or close the first handle before reopening the same directory in the same process: db.dispose() in Node, db.close() / await db.close() in Python, db.Close() in Go, or db.close in Ruby.

WAL is poisoned or bgFailure is set​

Symptom: Queries fail with WAL poisoned, WAL flush failed, or /admin/wal/status returns a non-null bgFailure.

Likely cause: A WAL append or fsync failed. In group sync mode, the background flusher latches the first fsync failure so later writes fail loudly instead of pretending they are durable.

Fix: Stop accepting writes, fix the underlying disk or permission problem, then restart from the last consistent snapshot + WAL. Inspect /admin/wal/status before restart if you need the latched error text for logs.

Recovery warns about an older snapshot​

Symptom: Startup prints a warning that the snapshot LSN is older than the newest checkpoint marker in the WAL.

Likely cause: The WAL contains evidence of a newer checkpoint than the snapshot passed to --restore-from or Database::recover(...).

Fix: If you have the newer checkpoint snapshot, restore from that file instead. If not, the current recovery is still safe: LoraDB replays every committed WAL record above the snapshot's own walLsn, which may take longer but preserves correctness.

Parameters​

Why are my queries returning nothing?​

A parameter bound to null never equals anything, so it usually filters everything out. Check the values you pass, not only the keys: a missing key fails the query with expected parameter: $name, but an explicit null runs it.

The HTTP API rejected my parameters​

POST /query, /explain, and /profile accept a params object. If you get LORA_INVALID_PARAMS, check that params is a JSON object keyed by parameter name, not an array or scalar:

{ "query": "RETURN $v AS v", "params": { "v": 42 } }

For typed values such as dates, points, and vectors, pass the JSON value and cast it in the query. See Parameters.

Integer precision lost in JS​

JS number loses precision above Number.MAX_SAFE_INTEGER (2^53). Use bigint parameters or string-encoded ids for large values. See Node → gotchas.

Performance​

Query is slow on a big graph​

  • Explicit RANGE, TEXT, and POINT indexes help common property, string, and point predicates when the query is scoped to the matching label or relationship type. Scope to a label (MATCH (n:L {id: 1})) and add an index for hot predicates. Vector similarity remains exhaustive.
  • Unbounded variable-length traversals explode fast. Cap with a max depth: [:R*1..6].
  • ORDER BY on a huge unbounded result requires a full sort. Pair it with LIMIT (a literal or a parameter): the sort then keeps only SKIP + LIMIT rows, though it still reads every match.
  • See Limitations → Storage for the full list of storage gaps.

Queries block each other​

Auto-commit reads can overlap on Arc snapshots. Write commits and explicit read-write transactions still serialize. See Limitations → Concurrency.

Debugging query pipelines​

A Cypher query is a pipeline — each clause feeds its rows into the next. When a query returns surprising results the bug is almost always between stages, not within a single clause. The cure is to step through the pipeline and inspect what each stage emits.

The golden rule​

If a query doesn't return what you expect, read it clause by clause and ask: what does this stage produce, and which variables are in scope after it?

Variable scope across WITH​

A variable leaves scope the moment it isn't projected through WITH. Future clauses can't see it.

Symptom: Unknown variable r / Unknown variable b.

Likely cause: A WITH between MATCH and RETURN dropped the variable.

Fix: Project every variable you need downstream.

Example:

// Broken
MATCH (a)-[r:KNOWS]->(b)
WITH a                    // r and b are now out of scope
RETURN a, r, b            // error

;// Fixed
MATCH (a)-[r:KNOWS]->(b)
WITH a, r, b
RETURN a, r, b

See WITH — losing variables.

WITH * vs explicit projection​

WITH * passes every in-scope variable forward — convenient but easy to misuse. The instant you add a computed column, you need to enumerate the existing variables too, otherwise they silently drop.

Symptom: A variable that existed a moment ago is suddenly unknown.

Likely cause: Someone wrote WITH x AS renamed expecting the other variables to survive.

Fix: Either use WITH *, x AS renamed or list every needed variable explicitly.

Example:

// Broken — drops u
MATCH (u:User)-[:WROTE]->(p:Post)
WITH count(p) AS posts
RETURN u.name, posts          // error: u is not in scope

;// Fixed
MATCH (u:User)-[:WROTE]->(p:Post)
WITH u, count(p) AS posts
RETURN u.name, posts

The fix is not WITH * — aggregates plus WITH * together cause different trouble, because the aggregate needs an explicit grouping key column.

Variable loss between stages​

Symptom: Second MATCH in a multi-stage query returns zero rows.

Likely cause: A WITH stage narrowed the rows to a subset, and subsequent MATCH clauses only run for the surviving rows.

Fix: Push the second MATCH earlier, or remove the narrowing.

Example:

// "Only see friends of the top-3 oldest users" — works
MATCH (u:User)
WITH u ORDER BY u.age DESC LIMIT 3
MATCH (u)-[:FOLLOWS]->(other)
RETURN u.name, other.name

;// Likely a bug — the LIMIT 3 applies too soon
MATCH (u:User)
WITH u LIMIT 3
MATCH (u)-[:FOLLOWS]->(other:User)
WHERE other.active
RETURN u, other

The second MATCH sees at most three users; if none of their follows are active, the whole query is empty. Push the filter up into the first WITH, or don't LIMIT yet.

Debugging inside the pipeline​

Print-debug a pipeline by swapping the final RETURN for one that exposes intermediate state:

// Inspect what WITH is emitting
MATCH (u:User)
WITH u.country AS country, count(*) AS n
RETURN country, n
ORDER BY n DESC

Then paste the rows into a spreadsheet — spotting duplicate keys, a missing country, or an unexpected cardinality often takes five seconds once you can see the rows.

CASE expression pitfalls​

CASE is powerful but its rules interact with three-valued logic and type-mixing in ways that surprise new users.

Missing ELSE returns null​

Symptom: A derived column has null values you didn't expect.

Likely cause: The CASE has no ELSE branch and one of the input rows doesn't satisfy any WHEN.

Fix: Add an explicit ELSE, or accept null as the implicit default.

Example:

// Broken — users with score < 50 become null
MATCH (u:User)
RETURN u.name,
     CASE WHEN u.score >= 50 THEN 'ok' END AS tier

;// Fixed
MATCH (u:User)
RETURN u.name,
     CASE WHEN u.score >= 50 THEN 'ok' ELSE 'low' END AS tier

Null in the predicate​

CASE WHEN expr treats a null expr as not matching — because three-valued logic propagates.

Symptom: Rows with null properties land in the ELSE branch, even though the condition wasn't explicitly false.

Likely cause: u.score >= 50 is null (not false) when u.score is null; the branch doesn't fire.

Fix: Guard with coalesce or an explicit IS NULL branch placed before the numeric comparison.

MATCH (u:User)
RETURN u.name,
     CASE
       WHEN u.score IS NULL  THEN 'unknown'
       WHEN u.score >= 50    THEN 'ok'
       ELSE                       'low'
     END AS tier

Inconsistent branch types​

Symptom: A downstream ORDER BY or comparison misbehaves on the CASE column.

Likely cause: Different branches return different types — e.g. an Int in one branch and a String in another.

Fix: Make every branch return the same type. If you genuinely need heterogeneous output, convert with toString.

Example:

// Mixed types — results downstream-unpredictable
CASE WHEN n.score >= 50 THEN n.score ELSE 'unknown' END

// Fixed
CASE WHEN n.score >= 50 THEN toString(n.score) ELSE 'unknown' END

Simple vs generic form confusion​

Simple form (CASE x WHEN v THEN …) compares x against values using equality. It can't express ranges or boolean predicates per branch — that's the generic form (CASE WHEN pred THEN …).

// Doesn't work — comparison is hidden inside the simple form
CASE p.age WHEN >= 18 THEN 'adult' ELSE 'minor' END

// Use the generic form
CASE WHEN p.age >= 18 THEN 'adult' ELSE 'minor' END

WITH clause pitfalls​

Aggregate without an explicit group key​

Symptom: The aggregate returns a single row when you expected one row per group.

Likely cause: There's no non-aggregated column in the WITH or RETURN, so all rows collapse to a single group.

Fix: Add the grouping column.

Example:

// "Orders per region" — one row total
MATCH (o:Order)
RETURN count(*)

;// Fixed — one row per region
MATCH (o:Order)
RETURN o.region, count(*)
ORDER BY count(*) DESC

Implicit group key by accident​

The opposite surprise: an aggregate query that returns many rows because a non-aggregated column you didn't realise was there formed part of the key.

Symptom: A count(*) query returns many rows instead of one.

Likely cause: You projected something extra (e.g. the Node itself) alongside the aggregate, and each node became its own group.

Fix: Drop the extra column.

// Broken: returns one row per user
MATCH (u:User)
RETURN u, count(*)

;// Fixed: single total
MATCH (u:User)
RETURN count(*)

Aggregates in WHERE​

Symptom: Aggregate in WHERE analysis error.

Likely cause: Aggregates aren't allowed in WHERE. Cypher has no HAVING keyword.

Fix: Pipe through WITH and filter after.

// Broken
MATCH (u:User)-[:WROTE]->(p:Post)
WHERE count(p) > 5
RETURN u

;// Fixed (HAVING-style)
MATCH (u:User)-[:WROTE]->(p:Post)
WITH u, count(p) AS posts
WHERE posts > 5
RETURN u, posts

See WITH — HAVING-style filtering.

Ordering a WITH drops the order downstream?​

Symptom: You sort in a WITH stage and the final result comes back unsorted.

Likely cause: ORDER BY attached to WITH only guarantees ordering for that stage's output and for any ORDER BY-sensitive aggregate that immediately consumes it (such as collect). A subsequent MATCH then re-emits rows in no particular order.

Fix: Either collect in the sorted stage, or re-apply ORDER BY on the final RETURN.

// Broken — final order is unspecified
MATCH (u:User)
WITH u ORDER BY u.created DESC
MATCH (u)-[:WROTE]->(p)
RETURN u.name, count(p)

;// Fixed
MATCH (u:User)
WITH u ORDER BY u.created DESC
MATCH (u)-[:WROTE]->(p)
RETURN u.name, count(p)
ORDER BY u.created DESC

Aggregation pitfalls​

count(*) vs count(expr)​

Symptom: OPTIONAL MATCH aggregation yields 1 for entities that should be 0.

Likely cause: count(*) counts rows. An OPTIONAL MATCH that missed still produces a row with null bindings — count(*) counts it.

Fix: Use count(expr) on a variable from the optional side. count(expr) skips null.

Example:

// Broken — users with no posts get 1
MATCH (u:User)
OPTIONAL MATCH (u)-[:WROTE]->(p:Post)
RETURN u.name, count(*) AS posts

;// Fixed
MATCH (u:User)
OPTIONAL MATCH (u)-[:WROTE]->(p:Post)
RETURN u.name, count(p) AS posts

Missing DISTINCT in collect​

Symptom: collect returns duplicate values.

Likely cause: A many-to-many join upstream produced the same child multiple times, once per ancestor.

Fix: collect(DISTINCT …).

Example:

// Broken — same city listed many times if the person visited it often
MATCH (p:Person)-[:VISITED]->(c:City)
RETURN p.name, collect(c.name) AS cities

;// Fixed
MATCH (p:Person)-[:VISITED]->(c:City)
RETURN p.name, collect(DISTINCT c.name) AS cities

Aggregating after filtering vs after projection​

Symptom: Aggregate seems to include rows the WHERE should have excluded.

Likely cause: The WHERE runs against post-aggregate output, not the rows that fed the aggregate — because the query put it in the wrong stage.

Fix: If you want the filter before the aggregate, place it in a pre-aggregate WHERE. If you want it after (HAVING-style), pipe through WITH.

// Pre-aggregate filter (input rows only)
MATCH (o:Order)
WHERE o.status = 'paid'
RETURN o.region, sum(o.amount) AS revenue

;// Post-aggregate filter (computed totals only)
MATCH (o:Order)
WITH o.region AS region, sum(o.amount) AS revenue
WHERE revenue > 1000
RETURN region, revenue

stdev/percentile* don't support DISTINCT​

Symptom: stdev(DISTINCT …) / percentileCont(DISTINCT …) returns the same value as without DISTINCT — duplicates are still counted.

Likely cause: These aggregates ignore DISTINCT silently (see Limitations).

Fix: collect(DISTINCT …), UNWIND, then aggregate.

// Broken
MATCH (r:Review) RETURN stdev(DISTINCT r.stars)

;// Fixed
MATCH (r:Review)
WITH collect(DISTINCT r.stars) AS xs
UNWIND xs AS x
RETURN stdev(x)

Empty results and filtering issues​

Unbound parameter​

Symptom: The query fails with expected parameter: $id (code LORA_INVALID_PARAMS).

Cause: The query uses $id but the params map has no id entry.

Fix: Bind it, as null if it has no value. A parameter bound to null runs, but never equals anything:

MATCH (u:User) WHERE u.id = $id RETURN u
// {}           -> error: expected parameter: $id
// {id: null}   -> zero rows

LIMIT expects a non-negative integer, got null​

Symptom: A paginated query fails with LIMIT expects a non-negative integer, got null (or the same message for SKIP).

Likely cause: The $limit / $offset parameter was not bound, or the host passed null, a negative number or a fraction. SKIP and LIMIT take a non-negative integer (an integral float such as 10.0 is accepted). As in Neo4j, LIMIT null is an error, not "no limit"; older LoraDB releases treated it as unlimited.

Fix: Bind an integer on every call. To make the limit optional, build the query without the LIMIT clause when the caller wants every row.

MATCH (u:User)
RETURN u
ORDER BY u.id
SKIP $offset
LIMIT $limit
// Both $offset and $limit must be bound to non-negative integers

= null never matches​

Symptom: A predicate intended to match missing properties returns zero rows.

Likely cause: prop = null is always null. Use IS NULL / IS NOT NULL.

Fix:

// Broken
MATCH (n) WHERE n.optional = null RETURN n

;// Fixed
MATCH (n) WHERE n.optional IS NULL RETURN n

Regex anchored by default​

=~ 'foo' matches only the full string foo, not any string containing foo. Use =~ '.*foo.*' or CONTAINS 'foo' for substring matching.

Case-sensitive when you meant insensitive​

All string operators (=, STARTS WITH, ENDS WITH, CONTAINS) are case-sensitive. Normalise both sides with string.lower.

MATCH (u:User)
WHERE string.lower(u.email) = string.lower($candidate)
RETURN u

Duplicate results​

Pattern reached twice​

Symptom: The same node appears in results multiple times.

Likely cause: Two different paths reach the same node; the pattern matches each path independently.

Fix: Use DISTINCT on the RETURN, or restructure with EXISTS { } when you only need existence.

// May duplicate \`c\` if a is connected to many b
MATCH (a:Person)-[:FOLLOWS]->(b)-[:FOLLOWS]->(c)
RETURN a, c

;// One row per distinct (a, c) pair
MATCH (a:Person)-[:FOLLOWS]->(b)-[:FOLLOWS]->(c)
RETURN DISTINCT a, c

Undirected match doubles symmetric pairs​

Symptom: Every pair appears twice in the results.

Likely cause: (a)-[:R]-(b) matches both directions. A pattern that's symmetric in a / b matches each pair twice.

Fix: Filter with id(a) < id(b) (or <>).

MATCH (a:Person)-[:KNOWS]-(b:Person)
WHERE id(a) < id(b)
RETURN a.name, b.name

Debugging workflow (step-by-step)​

When a query misbehaves, follow this loop. Every step is cheap.

1. Simplify the MATCH​

Remove everything except the patterns. Check you get any rows at all.

MATCH (u:User) RETURN count(*)

Zero? Your label is wrong or the graph is empty. See Queries return empty results.

2. Remove WHERE, inspect rows​

Drop the WHERE and look at what the pattern actually binds.

MATCH (u:User)-[:FOLLOWS]->(f)
RETURN u.handle, f.handle
LIMIT 20

Spot duplicates, unexpected relationships, or nulls here.

3. Reintroduce predicates one at a time​

Add each WHERE clause back one at a time. Count rows at each step — the step that drops too many rows is the bug.

MATCH (u:User)-[:FOLLOWS]->(f)
WHERE u.active                  // step 1
RETURN count(*);

MATCH (u:User)-[:FOLLOWS]->(f)
WHERE u.active
AND f.country = u.country     // step 2
RETURN count(*)

4. Inspect intermediate WITH stages​

If your query has multiple stages, replace the final RETURN with one that exposes the WITH stage output. Do this per stage.

// Instead of the full query:
MATCH (u:User)-[:WROTE]->(p:Post)
WITH u, count(p) AS posts
WHERE posts > 5
RETURN u.handle, posts

;// Inspect stage 1:
MATCH (u:User)-[:WROTE]->(p:Post)
RETURN u.handle, count(p) AS posts
ORDER BY posts DESC
LIMIT 20

Does stage 1 emit what you think? If not, the bug is before the WITH.

5. Check parameter bindings​

Confirm every $param the query uses has the value you expect. A missing one fails the query; a null one silently filters.

6. Re-read the problem​

If you've been through the loop twice and the result is still wrong, the bug may be in the data model, not the query. See the Modelling checklist.

See also​