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.

Quick lookup

SymptomJump to
Parse error, missing paren/directionParse errors
Unknown label, Unknown variable, Unknown functionSemantic 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 "bad CRC"Snapshots → load fails with bad magic / CRC
Snapshot load reports unsupported versionSnapshots → unsupported format version
Result JSON shape is wrongResult format
Build failsBuild

Build

error: linker 'cc' not found

Install a C toolchain. On macOS:

xcode-select --install

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 nMATCH (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 label :FooNo node with that label exists yet; populate the graph first or use CREATE.
Unknown variable xx wasn't introduced by an earlier clause, or it was dropped by a WITH that didn't project it.
Unsupported feature: CALLCALL / procedures aren't implemented — see Limitations.
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; filed bug if you see this.

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 archive-backed open (createDatabase("app", { databaseDir: "./data" }), Database.create("app", {"database_dir": "./data"}), lora.New("app", lora.Options{DatabaseDir: "./data"}), etc.) or load a snapshot if you expect data to survive restarts. See Limitations → Storage.
  2. Label case mismatch:user:User. Labels and types are case-sensitive. See Nodes.
  3. Property type mismatch{id: 1} matches integer 1, not the string "1". See Data Types.
  4. A parameter is unbound — missing parameters resolve to null, which usually filters everything out. 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, 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 CRC validation fails, restore from an earlier backup.

Snapshot load fails with "bad magic" or "bad CRC"

Symptom: SnapshotError::BadMagic or SnapshotError::BadCrc on load.

Likely cause:

  • Bad magic — the file is not a LoraDB snapshot. The first 8 bytes should be LORASNAP.
  • Bad CRC — the file is corrupt (truncated, bit-flipped, 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
# => LORASNAP

If the magic is wrong, check you pointed at the right path. If the magic is right but CRC fails, restore from a known-good copy — a corrupt snapshot never loads partially on purpose, to prevent silently accepting half a graph. See the Snapshots operator doc (internal) 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 (the reader's SNAPSHOT_MIN_SUPPORTED_FORMAT_VERSION has been raised above the file's version).

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/archive root is already open

Symptom: Opening a WAL-backed or archive-backed database fails with an error that the WAL/archive root is already open by another 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/archive 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?

Missing parameters resolve to null, which usually filters everything out. Verify every $name in your query has a corresponding entry in the params map passed to execute_with_params.

The HTTP API ignored my parameters

POST /query does not currently accept a params body field — see Limitations. Bind parameters via the Rust / Node / Python APIs for now.

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

  • No property indexes — MATCH ({id: 1}) is O(n). Scope to a label (MATCH (n:L {id: 1})) to narrow the search.
  • 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 with LIMIT.
  • See Limitations → Storage for the full list of storage gaps.

Queries block each other

LoraDB serialises queries on a single mutex. There is no concurrent read execution. 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 …) fails with an analysis error.

Likely cause: These aggregates don't support DISTINCT directly (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

Silent filter from an unbound parameter

Symptom: Query returns zero rows in production but works in the local REPL.

Likely cause: A $param isn't bound. Unbound parameters resolve to null, which silently filters out every row.

Fix: Audit parameter bindings on the host side before executing.

MATCH (u:User) WHERE u.id = $id RETURN u
-- If $id is not bound, this returns zero rows without raising

= 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 toLower.

MATCH (u:User)
WHERE toLower(u.email) = toLower($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 is in the call. Unbound parameters become null and silently filter.

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