Cypher Reference¶
KGLite’s independently implemented Cypher dialect runs in-process (no server). It provides a tested openCypher-compatible subset plus explicitly classified KGLite and GQL-style extensions; it is not a complete openCypher or Neo4j implementation. For a quick overview, see the Cypher guide.
Start with: Getting Started · Cypher guide · dialect contract · Python API · Fluent API · 0.13 → 0.14 migration
Label model: Each node has one immutable primary type plus optional secondary labels.
CREATE (n:A:B),SET n:B,REMOVE n:B,MATCH (n:A:B)(AND), andMATCH (n:A|B)(OR — reading patterns only; mixing|with a:chain is a parse error, and|is refused in CREATE/MERGE/SET/REMOVE) all work;labels(n)returns the primary type first.SET n.type = 'NewType'only writes a property—it does not retype the node. To change the primary type, migrate/recreate the node under the new schema.
Feature coverage¶
If you’re evaluating an embedded, Cypher-queryable graph, here’s the surface at a glance — most of what you’d reach for is here, in-process:
Area |
Supported |
|---|---|
Reading |
|
Writing |
|
Subqueries |
Per-row |
Schema DDL |
|
Constraint DDL |
|
Path finding |
variable-length |
Predicates |
|
Expressions |
list comprehension |
Parameters |
values |
Aggregation |
|
Procedures ( |
centralities (pagerank, betweenness, closeness, degree), community (louvain, leiden, label propagation), components, k-core, clustering, |
Vector + text |
|
Spatial |
|
Temporal |
|
Value types |
int, float, string, bool, date, timestamp (date + time), duration, point, list, map, node, relationship, path |
Transactions |
multi-statement with snapshot isolation + rollback ( |
Change data capture |
opt-in change stream with stateless cursors — |
Storage |
identical Cypher across in-memory, mmap, and on-disk modes (largest graph run: Wikidata, 124M nodes / 861M edges) |
Per-write UNIQUE / NOT NULL / NODE KEY / IS :: TYPE constraints are
supported and enforced on every write path, including the bulk loader — declare
them with constraint DDL or define_schema.
Relationships carry NOT NULL and IS :: TYPE too. A graph that
declares no constraint pays one HashMap::is_empty check per write, so the
in-memory write path is untouched by the feature existing.
Node identity — use id as your primary key¶
id is the indexed identity property, and it accepts strings as well as
integers:
graph.cypher("CREATE (:Memory {id: 'a3f9-uuid', text: 'hello'})")
graph.cypher("MATCH (n:Memory {id: 'a3f9-uuid'}) RETURN n.text") # indexed
graph.cypher("MATCH (n:Memory) WHERE n.id IN $keys RETURN n", params={"keys": [...]}) # multi-probe
Put your application key in id — not a custom property. An anchored lookup
on id uses the storage mode’s identity index; an arbitrary property (mid, key, …) is
not indexed, so MATCH (n {mid: $k}) is a full label scan (linear in node
count). Two semantics to keep in mind:
Uniqueness is opt-in: with no constraint declared,
CREATEdoes not reject a duplicateid— twoCREATE (:T {id: 'k'})make two nodes. Either declare the node type’s primary key (define_schema({'nodes': {'T': {'primary_key': 'id'}}}), after which the secondCREATEis rejected), or useMERGE, notCREATE—MERGE (:T {id: $k})is idempotent either way. Constraint DDL is deliberately not the route here:REQUIRE t.id IS UNIQUEis refused, becauseidis a structural field rather than a stored property and the unique secondary index would never see the write — see Cypher constraint DDL.Matching is type-exact:
'42'≠42. Keep id types consistent across writes and reads.Property typos are caught where the type’s shape is known. A
CREATEorMERGEnode pattern naming a property the type has never carried is rejected with aValid properties: …/ “did you mean?” hint — a deliberate typo-guard, active on an open schema. It applies only where KGLite knows the shape: a type with no recorded properties yet, an unlabelled pattern, and relationship properties are all skipped, andSET n.newprop = …stores a new property by design (that is how a type grows a column).lock_schema()extends the refusal to reads of a property no node of the type has (WHERE,RETURN,WITH,ORDER BY) and to unknown labels; see Diagnostics. The bulk loaders (add_nodes/add_connections) deliberately bypass the lock; it gates the Cypher write path.
Basic Query¶
result = graph.cypher("""
MATCH (p:Person)-[:KNOWS]->(f:Person)
WHERE p.age > 30 AND f.city = 'Oslo'
RETURN p.name AS person, f.name AS friend, p.age AS age
ORDER BY p.age DESC
LIMIT 10
""")
# Read queries → ResultView (iterate, index, or convert)
for row in result:
print(f"{row['person']} knows {row['friend']}")
# Pass to_df=True for a DataFrame
df = graph.cypher("MATCH (n:Person) RETURN n.name, n.age ORDER BY n.age", to_df=True)
Every result column needs a distinct name. A RETURN or WITH that names
one column twice — RETURN 1 AS x, 2 AS x, RETURN n.a AS x, n.b AS x, or an
unaliased RETURN n.a, n.a — is rejected with “Multiple result columns with
the same name are not supported”, matching Neo4j. A row is one name-keyed map,
so two items sharing a name were never two columns: the collision used to be
silent and lost both values. Rename one with AS. Names are
case-sensitive, so AS x and AS X are two columns.
Current clause spellings¶
KGLite accepts the Cypher 25 spellings FILTER, OFFSET, NODETACH DELETE,
FINISH, and INSERT:
# FILTER is a standalone row filter; OFFSET is a SKIP synonym.
graph.cypher("MATCH (p:Person) FILTER p.active RETURN p.name OFFSET 10 LIMIT 5")
# FINISH ends a read or write pipeline and deliberately returns no rows.
graph.cypher("MATCH (p:Person) SET p.seen = true FINISH")
# NODETACH DELETE is explicit plain DELETE: connected nodes are still refused.
graph.cypher("MATCH (p:Person {id: $id}) NODETACH DELETE p", params={"id": 7})
FILTER predicate has the row behavior of WITH * WHERE predicate without
changing the current columns. FINISH must be terminal; completed mutations
remain visible and its ResultView is empty.
WHERE Clause¶
# Comparisons: =, <>, <, >, <=, >=
graph.cypher("MATCH (n:Product) WHERE n.price >= 500 RETURN n.title, n.price")
# Boolean operators: AND, OR, NOT
graph.cypher("MATCH (n:Person) WHERE n.age > 25 AND NOT n.city = 'Oslo' RETURN n.name")
# Null checks
graph.cypher("MATCH (n:Person) WHERE n.email IS NOT NULL RETURN n.name")
# String predicates: CONTAINS, STARTS WITH, ENDS WITH
graph.cypher("MATCH (n:Person) WHERE n.name CONTAINS 'ali' RETURN n.name")
# IN lists
graph.cypher("MATCH (n:Person) WHERE n.city IN ['Oslo', 'Bergen'] RETURN n.name")
# Regex matching with =~ — the pattern must match the WHOLE value
graph.cypher("MATCH (n:Person) WHERE n.name =~ '(?i)ali.*' RETURN n.name")
graph.cypher("MATCH (n:Person) WHERE n.email =~ '.*@example\\.com' RETURN n.name")
# `=~` is not a substring search: 'inactive' =~ 'active' is false.
# To search, say so with `.*` — or use CONTAINS / text_match_regex().
graph.cypher("MATCH (n:Person) WHERE n.name =~ '.*ali.*' RETURN n.name")
Generated filters: prefer IN [...] over long OR chains¶
Expression nesting is capped at 512 levels, and every OR term adds one
level — so a filter builder that emits one term per selected value
(n.sku = 'a' OR n.sku = 'b' OR ...) stops parsing at ~512 selections:
Expression nesting exceeds 512 levels; simplify the query. ...
A list membership test is one level no matter how long the list is, so the
same filter expressed with IN has no practical ceiling — and it is faster,
because it can be pushed into the MATCH and use an index:
# Fragile above ~512 values, and slower below it
graph.cypher("MATCH (n:Product) WHERE " + " OR ".join(f"n.sku = '{s}'" for s in skus) + " RETURN n")
# Prefer this — one AST level for any number of values, and parameterised
graph.cypher("MATCH (n:Product) WHERE n.sku IN $skus RETURN n", params={"skus": skus})
The planner already rewrites OR chains of equalities on a single property
into IN for you, but only after the query parses — so it cannot rescue a
chain that is already too long, and it does not fire for chains spanning
different properties. Generating IN directly is the robust habit.
A stored '["Oslo"]' equals 'Oslo'¶
A string property whose value is a single-element JSON string list compares
equal to that element’s plain string, in both directions and on every route
that decides equality: =, <>, IN [...], inline pattern properties
(MATCH (n {tag: 'Oslo'})), the WHERE-clause scan predicates, and the
index-backed pushdown. So n.tag = 'Oslo', n.tag = '["Oslo"]' and
n.tag IN ['Oslo'] all match a row storing either spelling, and =/<>
always partition the rows between them. The rule is one-element-only —
'["Oslo","Bergen"]' is an ordinary string and matches only itself — and it
applies to strings, never to real list values.
Relationship Properties¶
Relationships can have properties. Access them with r.property syntax:
# Create relationships with properties
graph.cypher("""
MATCH (p:Person {name: 'Alice'}), (m:Movie {title: 'Inception'})
CREATE (p)-[:RATED {score: 5, comment: 'Excellent'}]->(m)
""")
# Access, filter, aggregate, sort by relationship properties
graph.cypher("MATCH (p)-[r:RATED]->(m) RETURN p.name, r.score, r.comment, type(r)")
graph.cypher("MATCH (p)-[r:RATED]->(m) WHERE r.score >= 4 RETURN p.name, m.title")
graph.cypher("MATCH (p)-[r:RATED]->(m) RETURN avg(r.score) AS avg_rating")
graph.cypher("MATCH ()-[r:RATED]->(m) RETURN m.title, r.score ORDER BY r.score DESC")
SET / REMOVE work on a relationship variable, so you can upsert edge
properties — including via MERGE:
graph.cypher("MATCH (p)-[r:RATED]->(m) WHERE r.score < 3 SET r.flagged = true")
graph.cypher("MATCH (p)-[r:RATED]->(m) REMOVE r.comment")
# idempotent edge upsert:
graph.cypher("""
MATCH (p:Person {id: $a}), (m:Movie {id: $b})
MERGE (p)-[r:RATED]->(m) ON CREATE SET r.score = $s
""", params={"a": "p1", "b": "m1", "s": 5})
Aggregation¶
graph.cypher("MATCH (n:Person) RETURN n.city, count(*) AS population ORDER BY population DESC")
graph.cypher("MATCH (n:Person) RETURN avg(n.age) AS avg_age, min(n.age), max(n.age)")
# DISTINCT
graph.cypher("MATCH (n:Person) RETURN DISTINCT n.city")
graph.cypher("MATCH (n:Person) RETURN count(DISTINCT n.city) AS unique_cities")
Aggregate |
Description |
|---|---|
|
Row or value count |
|
Numeric sum; all-integer inputs produce an exact Int64 result or an overflow error if the final result is out of range |
|
Arithmetic mean |
|
Minimum / maximum |
|
Gather values into a list |
|
Sample standard deviation (n-1) |
|
Sample variance (n-1) |
|
Median value |
|
Continuous percentile (linear interpolation), |
|
Discrete percentile (nearest rank), |
graph.cypher("MATCH (n:Person) RETURN median(n.age), percentile_cont(n.age, 0.9)")
graph.cypher("MATCH (n:Person) RETURN variance(n.age), std(n.age)")
ORDER BY after an aggregate¶
An aggregating RETURN emits one row per group, so a sort key must have a
single value on that row. Two things do:
Projected columns — an alias, or an unaliased item’s expression form:
RETURN c.name AS company, count(p) ORDER BY count(p) DESC, company.Anything read by a grouping key — every non-aggregate item is a grouping key, so any property of a variable it names is sortable even when it is not itself projected:
# t is named by the grouping key t.title, so t.priority is sortable.
graph.cypher("""
MATCH (t:Task)
OPTIONAL MATCH (t)-[:X]->(c)
RETURN t.title AS title, count(c) AS n
ORDER BY t.priority DESC
""")
The value used is the group’s first row. When several distinct nodes collapse
into one group (two Tasks sharing a title), that representative is which one
the group met first — project the sort key explicitly if you need it pinned.
Ordering by anything else is rejected rather than silently ignored:
# ERROR: c collapses into count(c), so c.label has no value per group.
"... RETURN t.title AS title, count(c) AS n ORDER BY c.label DESC"
# ERROR: aggregate not projected. Project it, then order by the alias.
"... RETURN t.title AS title, count(*) AS n ORDER BY max(t.priority) DESC"
# ERROR: the aggregate is projected as `n` — order by `n`.
"... RETURN t.title AS title, count(*) AS n ORDER BY count(*) DESC"
A RETURN without aggregates keeps every binding on its rows, so ordering
by a non-projected expression is unrestricted there. WITH narrows scope to
what it projects, so a sort key after WITH must be one of its columns.
Sort order¶
ORDER BY accepts ASC (default) / DESC per key, plus an explicit
NULLS FIRST / NULLS LAST. Without one, NULLs go last ascending and first
descending — the Neo4j 5 default.
A sort key can hold values of more than one type: a CASE returning a number
on some rows and a string on others, coalesce over differently-typed
properties, a property read across two node types. Those are ordered by type
first, following Neo4j 5’s ranking. Ascending:
rank |
|
|---|---|
lowest |
map |
node |
|
relationship |
|
list |
|
path |
|
date / datetime |
|
duration |
|
point |
|
string |
|
boolean |
|
highest |
number |
NULL sorts after all of them ascending, subject to the NULLS placement
above. Within a rank:
Numbers compare numerically across integers and floats — never “all integers, then all floats” — and exactly, including integers past 2⁵³ that no float can represent.
NaNsorts above every other number.Dates and datetimes share one rank and compare chronologically, a date counting as midnight on that date. The same rule governs
=,<>andIN, sodatetime('2024-03-15T00:00:00') = date('2024-03-15')is true — a deliberate divergence from openCypher, which makes every comparison between a date and a datetime null.DISTINCTand grouping keys remain structural, so the two stay separate keys there.Lists rank element by element, then by length — ascending,
[1],[1,1,9],[1,2],[2]. This is the sort rank; the<operator has no rule for two lists (see below).Maps rank entry by entry in key order, then by size, with the same sort-only caveat.
graph.cypher("UNWIND [3, 'b', 1, 'a'] AS v RETURN v ORDER BY v")
# → 'a', 'b', 1, 3 (strings rank below numbers)
min() and max() use this same order — min is the first value ascending,
max the last — so they never depend on which row arrived first. NULLs are
excluded from both. This is one deliberate deviation from Neo4j, which
applies a different, aggregate-specific rule to min/max on mixed input
(there, numbers rank below strings, which rank below lists). KGLite uses one
order everywhere, so min(x) always equals x ORDER BY x ASC LIMIT 1.
The same total order governs the fluent API’s sort= fields, where a node
missing the sort property is ordered as NULL.
Sorting is total; comparing is not. This ranking exists so ORDER BY,
min and max can place every pair. The <, <=, > and >= operators
answer null when no ordering rule relates the two values’ types — openCypher’s
rule — so 'a' < 1, true < 1, [1] < 2, {a: 1} < 1 and a list against
another list are all null, in both directions, even though ORDER BY places
those same values confidently. A null keeps no row in either direction:
WHERE n.v < 5 and WHERE NOT (n.v < 5) both drop a string-valued n.v.
Cross-type = is still false and <> still true. The one number that
declines every ordering comparison without being cross-type is NaN, which
answers false (never null) for all four operators — including against
itself — while still sorting above every other number.
HAVING¶
Post-aggregation filter — use after RETURN or WITH with aggregates:
graph.cypher("MATCH (n:Person) RETURN n.city, count(*) AS pop HAVING pop > 1000")
Also supported on WITH:
graph.cypher("""
MATCH (n:Person)
WITH n.city AS city, count(*) AS pop HAVING pop > 100
RETURN city, pop
""")
Window Functions¶
Window functions compute values across partitions of the result set without collapsing rows.
Function |
Description |
|---|---|
|
Sequential number within partition |
|
Rank with gaps for ties |
|
Rank without gaps for ties |
OVER clause: OVER (PARTITION BY expr [, ...] ORDER BY expr [ASC|DESC] [, ...])
PARTITION BY is optional (whole result set = one partition). ORDER BY is required.
# Global ranking
graph.cypher("MATCH (n:Person) RETURN n.name, row_number() OVER (ORDER BY n.score DESC) AS rn")
# Rank within department
graph.cypher("""
MATCH (n:Person)
RETURN n.name, n.dept,
rank() OVER (PARTITION BY n.dept ORDER BY n.score DESC) AS dept_rank
""")
WITH Clause¶
graph.cypher("""
MATCH (p:Person)-[:KNOWS]->(f:Person)
WITH p, count(f) AS friend_count
WHERE friend_count > 3
RETURN p.name, friend_count
ORDER BY friend_count DESC
""")
OPTIONAL MATCH¶
Left outer join — keeps rows even when no match:
graph.cypher("""
MATCH (p:Person)
OPTIONAL MATCH (p)-[:KNOWS]->(f:Person)
RETURN p.name, count(f) AS friends
""")
WHERE belongs to the OPTIONAL MATCH it follows¶
A WHERE written directly after an OPTIONAL MATCH is part of that clause’s
pattern — its predicate is applied while looking for matches, not after.
A row whose candidates all fail the predicate is therefore null-extended, not
deleted:
graph.cypher("""
MATCH (p:Person)
OPTIONAL MATCH (p)-[:KNOWS]->(f:Person) WHERE f.age > 35
RETURN p.name, f.name
""")
# every Person appears; f.name is NULL for those with no friend over 35
This holds whichever variables the predicate mentions — including one bound
before the OPTIONAL MATCH (WHERE p.age > 35 decides whether the optional
part matches, it does not drop the person). To filter rows instead, put the
predicate where it filters: after a plain MATCH, or in a following
WITH ... WHERE, which deletes the null-extended rows:
graph.cypher("""
MATCH (p:Person)
OPTIONAL MATCH (p)-[:KNOWS]->(f:Person)
WITH p, f WHERE f.age > 35
RETURN p.name, f.name
""")
# only Persons with a friend over 35
Built-in Functions¶
Function |
Description |
|---|---|
|
Convert to uppercase |
|
Convert to lowercase |
|
Convert to string; |
|
Convert to integer; a string must spell an integer (see below) |
|
Convert to float |
|
Element count of a list, or character count of a string (not UTF-8 bytes) |
|
Relationship type |
|
The node’s |
|
Node labels as a list, primary type first |
|
Node’s total edge count (in + out; a self-loop counts twice) — e.g. |
|
Node’s incoming / outgoing edge count |
|
Sorted property names of a node or relationship, or entry names of a map — |
|
Full property map of a node or relationship (as JSON map) |
|
Source node of a bound relationship; supports dotted access: |
|
Target node of a bound relationship; supports dotted access: |
|
Parse a date / ISO-8601 datetime string ( |
|
Days between two dates ( |
|
First non-null argument |
|
Generate a checked inclusive integer list; default step = 1. Cardinality, the |
|
First / last element of a list (returns |
|
Path hop count; on a string or list, same as |
|
Nodes in a path |
|
Relationships in a path |
|
Split string into list; an empty |
|
Replace all occurrences |
|
Extract substring |
|
First/last n characters |
|
Remove leading/trailing whitespace |
|
Left/right trim |
|
Reverse a string |
|
Create a geographic point |
|
Geodesic distance (m); geometry-aware |
|
Does a’s geometry contain b? |
|
Do geometries intersect? |
|
Centroid of geometry → Point |
|
Geodesic area (m²) |
|
Geodesic perimeter/length (m) |
|
Extract latitude from point |
|
Extract longitude from point |
|
Temporal point-in-time filter (nodes or edges) |
|
Temporal range overlap filter |
|
Lexical (BM25) relevance of the node’s indexed text against a query string. Needs |
|
Semantic similarity. A list |
|
With explicit metric ( |
|
Semantic similarity against a pre-computed embedding vector (pass a list of floats directly, no |
|
L2 norm of embedding vector (hierarchy depth in Poincaré space: 0=root, ~1=leaf) |
|
Fuse ranked-lane scores into one — the mean of the signals that are present, or a weighted mean with a trailing list. A lane that could not score the row ( |
|
Dot product of two list-valued vectors |
|
Cosine similarity of two list-valued vectors |
|
Euclidean (L2) length of a list-valued vector |
|
Sum of timeseries values (date-string range) |
|
Average of timeseries values |
|
Minimum timeseries value |
|
Maximum timeseries value |
|
Count of non-NaN timeseries values |
|
Exact timeseries key lookup |
|
First / last non-NaN value |
|
Value change between two time points |
|
Extract series as |
Divergence —
toIntegeron a string does not truncate. A string argument must spell an integer:toInteger('3')is3, whiletoInteger('3.7')isnull(Neo4j parses the float and truncates to3). So aretoInteger('abc')andtoInteger(' 3 ')— surrounding whitespace is not trimmed. A numeric argument does truncate as expected (toInteger(3.7)→3), so the portable spelling for a decimal string istoInteger(toFloat(s)).toFloat('3.7')is3.7, unaffected.
Hybrid retrieval (RAG) over a knowledge graph¶
vector_score / text_score compose with ordinary WHERE predicates and
traversal, so semantic retrieval and graph constraints run in one query —
no separate vector store + join. The graph filter and the similarity ranking
combine: filter first, rank the survivors by similarity, take the top k.
// Top-3 'politics' articles most similar to a query embedding —
// the category filter is applied *before* ranking, so a highly-similar
// 'sports' article is correctly excluded.
MATCH (a:Article)
WHERE a.category = 'politics'
RETURN a.title, vector_score(a, 'summary_emb', $query_vec) AS score
ORDER BY score DESC
LIMIT 3
// RAG with a graph hop: retrieve passages, then pull their source document
// and author in the same query (`text_score` auto-embeds the query string;
// requires set_embedder()).
MATCH (p:Passage)-[:OF_DOC]->(d:Document)-[:WRITTEN_BY]->(author:Person)
WHERE d.published_year >= 2020
RETURN p.text, d.title, author.name,
text_score(p, 'text', 'how does photosynthesis work?') AS score
ORDER BY score DESC
LIMIT 5
The embedding store key is {text_column}_emb (set via
set_embeddings(node_type, text_column, {id: vector})), so embeddings set on
the summary column are scored as vector_score(a, 'summary_emb', …).
vector_scoretakes the store name,text_scoretakes the raw column.vector_scorenames the store directly —'summary_emb'.text_scorenames the source column —'summary'(it resolves tosummary_emb). That’s why the example above usestext_score(p, 'text', …)(raw columntext), not'text_emb'. The Python API (embedding_info,vector_search,search_text) likewise uses the raw column name throughout; only Cypher’svector_scoreis in store-name terms.
Both scoring functions accept your own query vector.
text_scoreisvector_scoreafter a plan-time rewrite, so the two differ only in how they name the column and in how they read the query argument. Give either one a list of numbers — a literal[0.1, 0.2, …]or a$parambound to a list — and it scores that list directly as the query vector, needing only the embedding store. That makes semantic scoring reachable from every binding: any language that can send a list parameter can query by vector.// identical results; both need only the embedding store RETURN vector_score(n, 'summary_emb', $q) AS s // store name RETURN text_score(n, 'summary', $q) AS s // raw columnThe query argument’s type selects how it is scored. In
text_scorea list is scored as a vector and a string is scored as text — sotext_score(n, 'summary', [1.0, 2.0])scores that two-element vector, whiletext_score(n, 'summary', '[1.0, 2.0]')embeds the 10-character string and requiresset_embedder().vector_scorereads a list as a vector and also parses a JSON-array string as one (a legacy form kept for compatibility), so pass a list to have both spellings agree. A$paramused as the query argument must be bound to a string or a list; plan-time validation reports the type of anything else.
Retrieval policy.
vector_scoreandtext_scoreaccept an optional final map:{exact: true}forces an exact scan without using or refreshing HNSW. Put it fourth when omitting the metric, or fifth after an explicit metric:vector_score(n, 'summary_emb', $q, 'cosine', {exact: true}). A parameter-bound map works too.exactmust be boolean, defaults to false, and unknown options are errors. An omitted metric uses each actual node type’s store metric, defaulting to cosine if the store has none.Index-accelerated top-k. With default policy and an HNSW index,
RETURN vector_score(n, prop, q) AS s ORDER BY s DESC LIMIT k(or thetext_scoreform) can narrow candidates approximately, then score those candidates exactly. ASC, explicit NULLS LAST, row-dependent selectors, mixed/duplicate or unembedded bindings, incompatible metrics, unavailable indexes and filtered candidate underfill use exact execution. Filters alone do not guarantee an exact scan: request{exact: true}when that matters. Missing per-node embeddings score null and retain ordinary ORDER BY null placement. Invalid dimensions, metrics or options raise in filters as well as projected scores.
Lexical search — text_bm25¶
text_bm25(n, 'property', 'query text') ranks a row by Okapi BM25 against
a lexical index — word overlap, weighted by how rare each word is in the corpus
and normalised for document length. No embedder, no vectors: it is the
keyword-search half of hybrid retrieval, and it finds the exact term
(a product code, a name, a rare noun) that an embedding blurs away.
The index is opt-in and explicit, like build_vector_index:
graph.build_text_index("Article", "body") # Python; every binding has it
// Rank the corpus, best first.
MATCH (a:Article)
RETURN a.title, text_bm25(a, 'body', 'photosynthesis in low light') AS score
ORDER BY score DESC LIMIT 10
// It is an ordinary scalar: filter first, then rank the survivors.
MATCH (a:Article)-[:WRITTEN_BY]->(p:Person {name: 'Vera'})
WHERE a.year >= 2020
RETURN a.title, text_bm25(a, 'body', 'low light') AS score
ORDER BY score DESC LIMIT 5
Filter on the graph (a year, an author, a hop), never on the score. The
first form above — a bare MATCH over the whole indexed type, with the score
projected and ordered — is the only shape the postings top-k operator can
claim; a WHERE makes the rows a subset of the corpus and sends the query
back to scoring every document. Measured over 50,000 documents with a term in
0.1% of them, WHERE text_bm25(…) > 0 … ORDER BY score DESC LIMIT 10 cost
53× the same query without the WHERE (6.1 ms vs 114 µs). The filter also
buys nothing: a document sharing no word with the query scores 0.0 and is
already last.
Semantics:
Case |
Result |
|---|---|
The row’s document shares no word with the query |
|
The index holds no document for that row (created after the build and not yet caught up, or its property is absent / not a string) |
|
The query argument is |
|
No text index on the node’s |
error naming |
0.0 and null are deliberately different answers: 0.0 says the document was
searched and did not match, null says it was not searched. Collapsing them
would make an index that is quietly behind the graph look like a corpus with no
matches.
Tokenization is the same on both sides — lowercase, split on anything that is
not a letter or a digit — with no stemming and no stopword list: BM25’s IDF
term already discounts a word that appears in nearly every document, without a
language-specific list to maintain or be wrong about. A repeated query word is
weighted once, so 'rust rust' ranks exactly like 'rust'.
Ties break by node id, so a query over unchanged data returns the same order every time.
Staleness is visible, never silent. The index does not follow writes; it
records them and folds them in when a query next reads it, as long as the
outstanding delta is within that index’s auto_refresh_limit — which is why a
node created after the build scores without anyone rebuilding. Past the limit
(or on a read-only graph, which a query may not write to) the query serves what
the index has, scores the rest null, and returns a warning naming the delta
and the rebuild call. SHOW INDEXES reports stale and delta either way.
Folding a document in is not a constant-cost operation — it inserts into the
posting list of every term that document uses, and those lists grow with the
corpus — so auto_refresh_limit bounds a document count, not a duration.
Past roughly 1500 documents, folding costs more than rebuilding the index
outright and the catch-up rebuilds instead: a refresh costs the cheaper of the
two and never more than one rebuild, whatever the limit is set to.
Fusing the lexical and semantic lanes — score_fuse¶
score_fuse(s1, s2, …) combines the scores of several ranked lanes into one
number, so a keyword lane and a semantic lane can rank the same query in a
single pass:
MATCH (a:Article)
RETURN a.title,
score_fuse(text_bm25(a, 'body', $q), vector_score(a, 'body_emb', $qv)) AS score
ORDER BY score DESC LIMIT 10
Each lane finds what the other misses — BM25 finds the exact term (a product code, a surname, a rare noun) that an embedding blurs away, and the embedding finds the paraphrase that shares no word with the query.
By default the lanes weigh equally. A trailing list weights them, in argument order:
// Lexical evidence counts for 70% of the score, semantic for 30%.
RETURN score_fuse(text_bm25(a, 'body', $q), vector_score(a, 'body_emb', $qv), [0.7, 0.3]) AS score
Any number of lanes fuse, and the weights are relative — [3, 1] and
[0.75, 0.25] produce the same ranking. Weights must be finite and >= 0,
one per score; a wrong-length list, a negative weight and a non-numeric score
are all errors rather than a quietly different ranking.
An absent lane leaves the average; it does not score zero. A lane reports
null (or NaN, or an infinity) for a row it could not see — a document
the text index has not caught up with, a node with no stored embedding — and
that row keeps the score of the lanes that did run, its weight leaving the
denominator with it. Zero would mean “this lane looked and found nothing”,
which ranks a document one lane simply could not see below a document both
lanes actively disliked. score_fuse is null only when every lane is
absent, and — since it reads no row state — a call whose arguments are all
constants is folded once for the whole query rather than evaluated per row.
Where is
rrf()? Reciprocal Rank Fusion works on each lane’s rank across the whole result set, and a per-row scalar sees only one row’s scores — a function that claimed to do RRF row-by-row would be computing something else. Rank the lanes first with a window function, then fuse the reciprocals:MATCH (a:Article) WITH a, rank() OVER (ORDER BY text_bm25(a, 'body', $q) DESC) AS lex_rank, rank() OVER (ORDER BY vector_score(a, 'body_emb', $qv) DESC) AS vec_rank RETURN a.title, score_fuse(1.0 / (60 + lex_rank), 1.0 / (60 + vec_rank)) AS score ORDER BY score DESC LIMIT 10That is the whole of RRF, in the primitives that already exist. Reach for it when the lanes’ scores are on incomparable scales (BM25 is unbounded, cosine is not), since ranks discard the magnitudes; fuse the scores directly when the magnitudes carry information you want.
Vector math over list properties — dot / cosine / norm¶
vector_score and embedding_norm read the embedding store
(set_embeddings(...)). dot(a, b), cosine(a, b) and norm(a) read
ordinary list-valued data instead — a stored list property, a list literal,
a $param bound to a list, a collect(). Use them when the vectors live in
the graph as data rather than in a registered store, or to compare two nodes’
vectors against each other.
// Rank documents against a query vector held in a parameter.
MATCH (d:Doc)
RETURN d.title, cosine(d.vec, $q) AS score
ORDER BY score DESC LIMIT 10
// Compare two nodes' own stored vectors — no store, no embedder.
MATCH (a:Doc {id: 1}), (b:Doc {id: 2})
RETURN dot(a.vec, b.vec) AS dot, cosine(a.vec, b.vec) AS cos, norm(a.vec) AS len
Semantics:
Case |
Result |
|---|---|
Any argument is |
|
The two vectors have different lengths |
error naming both lengths |
An element is not a number (a string, a |
error naming the vector and the position |
An argument is not a list |
error — reported even when the other argument is |
|
|
|
|
A length mismatch is an error rather than null because a 384-dimension vector
meeting a 768-dimension one is a data bug, and a null would sit unremarked in
a column of otherwise plausible scores — the same reason Neo4j’s
vector.similarity.* family only compares equal dimensions. A null element
is likewise not silently treated as 0.0 (Neo4j’s GDS does substitute zero),
because a zeroed component changes the answer without changing its shape.
cosine returns null rather than 0.0 for a zero-length vector; that
differs from vector_score, which answers 0.0 there because a top-k ranking
needs a total order over every candidate.
A property whose value is a bracketed string ('[3.0, 4.0]') is read as a
list here, exactly as head() / last() / UNWIND read it, so a graph that
stored its vectors as text answers too. (size() / length() are the
exception — they measure a string as a string; see the dialect note under
String Functions.)
Spatial Functions¶
Built-in spatial functions for geographic queries. All node-aware functions auto-resolve geometry and location via spatial types.
Function |
Returns |
Description |
|---|---|---|
|
Point |
Create a geographic point |
|
Float (m) |
Geodesic distance (WGS84); geometry-aware (0 if inside/touching) |
|
Float (m) |
Geodesic distance (4-arg shorthand) |
|
Boolean |
Does a’s geometry contain b? (point-in-polygon or geometry containment) |
|
Boolean |
Do geometries intersect? |
|
Point |
Centroid of geometry (node or WKT string) |
|
Float (m²) |
Geodesic area of polygon (node or WKT string) |
|
Float (m) |
Geodesic perimeter/length (node or WKT string) |
|
Float |
Extract latitude component |
|
Float |
Extract longitude component |
All functions accept both nodes (auto-resolved via spatial config) and raw values (WKT strings, Points).
Coordinate order:
point(lat, lon)uses latitude-first (geographic convention). WKT strings use longitude-first per OGC standard:POLYGON((lon lat, lon lat, ...)). These conventions differ — be careful when mixing them.
# Node-aware spatial — with spatial config declared via column_types
graph.cypher("""
MATCH (c:City), (a:Area)
WHERE contains(a, c)
RETURN c.name, a.name
""")
graph.cypher("""
MATCH (a:Field), (b:Field)
WHERE intersects(a, b) AND a <> b
RETURN a.name, b.name
""")
graph.cypher("""
MATCH (n:Field)
RETURN n.name, area(n) AS area_m2, centroid(n) AS center
""")
# Geometry-aware distance
graph.cypher("""
MATCH (a:Field), (b:Field) WHERE a <> b
RETURN a.name, b.name, distance(a.geometry, b.geometry) AS dist
""") # 0 if polygons touch, centroid distance otherwise
graph.cypher("""
MATCH (n:Field)
WHERE distance(point(60.5, 3.5), n.geometry) < 10000.0
RETURN n.name
""") # 0 if point inside polygon, closest boundary otherwise
# Distance filtering — cities within 100 km of Oslo
graph.cypher("""
MATCH (n:City)
WHERE distance(n, point(59.91, 10.75)) < 100000.0
RETURN n.name
ORDER BY distance(n, point(59.91, 10.75))
""")
# Aggregation with spatial
graph.cypher("""
MATCH (a:Field), (b:Field) WHERE a <> b
RETURN avg(distance(a, b)) AS avg_dist, std(distance(a, b)) AS std_dist
""")
Geometry primitives¶
Constructive operations on WKT geometries. All accept WKT strings, node variables (auto-resolved via spatial config), or Point values; all return WKT strings (or boolean / float as noted).
Function |
Returns |
Description |
|---|---|---|
|
WKT (MultiPolygon) |
Planar buffer at the geometry’s centroid latitude (geo crate native; degrades far from the centroid) |
|
WKT (Polygon) |
Convex hull over a list of geometries; also accepts variadic args |
|
WKT (MultiPolygon) |
Polygonal union; rectangles auto-converted |
|
WKT (MultiPolygon) |
Polygonal intersection (empty MultiPolygon when disjoint) |
|
WKT (MultiPolygon) |
|
|
Boolean |
OGC-style validity check |
|
Float (m) |
Geodesic length: LineString length, polygon perimeter (sum of rings), 0 for points |
# Buffer a point by 5 km
graph.cypher("RETURN geom_buffer('POINT(10.7 59.9)', 5000) AS area")
# Hull of all city centroids
graph.cypher("""
MATCH (c:City)
WITH collect(c.geometry) AS shapes
RETURN geom_convex_hull(shapes) AS catchment
""")
# Union of overlapping licence areas
graph.cypher("""
MATCH (a:Licence), (b:Licence) WHERE a.id < b.id AND intersects(a, b)
RETURN geom_union(a.geometry, b.geometry) AS merged
""")
# LineString length (perimeter is polygon-only)
graph.cypher("RETURN geom_length('LINESTRING(10.7 59.9, 5.3 60.4)') AS m") # ≈ 305000
k-nearest-neighbour¶
CALL kg_knn({lat: 60.4, lon: 5.3, target_type: 'City', k: 5})
YIELD node, distance_m
RETURN node.title, round(distance_m / 1000.0, 1) AS km
Looks up the k nodes of target_type closest to (lat, lon) (geodesic). Uses the node’s location config for point comparisons; falls back to geometry centroid when location isn’t configured. Nodes without spatial config are skipped silently.
Temporal Functions¶
Date-range filtering on nodes and relationships with explicit field names.
Function |
Description |
|---|---|
|
Parse a date string to a DateTime (date-only) value |
|
Parse an ISO-8601 stamp to a Timestamp (date + time, second precision). Accepts |
|
Current local datetime (no-arg form) |
|
Local wall-clock datetime; 1-arg form parses/normalises a string (NULL on bad input). Unlike |
|
Local wall-clock time-of-day as |
|
Extract component from a DateTime property (chained accessor — works in |
|
Other temporal field accessors |
|
Build a Duration value (see Duration semantics below) |
|
Difference between two date or timestamp values; whole days and remaining seconds are returned as a Duration |
|
Checked calendar shift; returns NULL when the requested date is outside the representable range |
|
Start of |
|
Add a duration to a date |
|
Scale all duration components with checked arithmetic |
|
Days between two dates (legacy; same as |
|
Add/subtract N days (Int64 form, kept for backward compat) |
|
Returns a Duration (was Int64 days pre-0.9.0) |
|
True if entity is active at a point in time |
|
True if entity’s range overlaps the given interval |
NULL semantics: NULL from = valid since beginning. NULL to = still valid. Both NULL = always valid.
Magnitude/error policy: a well-typed date/calendar shift that lands outside
the representable chrono range returns NULL. Invalid types, a zero range step,
range allocation/budget overflow, and Duration construction or arithmetic that
would overflow a stored component raise CypherExecutionError; values are
never narrowed, wrapped, or silently truncated. Fractional/non-finite Duration
constructor components are rejected.
The same policy covers plain integer arithmetic. + - * / % and unary -
on two integers raise CypherExecutionError when the result leaves the signed
64-bit range (9223372036854775807 + 1, -9223372036854775808 / -1), and
integer division or modulo by zero raises rather than answering NULL.
Float division by zero stays NULL: the IEEE answer is ±Infinity / NaN, which
no wire format kglite ships over can carry, so promoting it would move the
silence one layer out rather than remove it.
datetime() and localdatetime() return timestamp values. An offset-bearing
datetime(str) is normalized to naive UTC; localdatetime(str) keeps the local
wall-clock reading and drops the zone. localtime() and time() return
HH:MM:SS strings because KGLite has no time-only value type. Each single-string
form returns NULL on unparseable input.
# Nodes active at a point in time
graph.cypher("""
MATCH (e:Employee)
WHERE valid_at(e, '2020-06-15', 'hire_date', 'end_date')
RETURN e.name
""")
# Relationships active at a point in time
graph.cypher("""
MATCH (e:Employee)-[r:WORKS_AT]->(c:Company)
WHERE valid_at(r, '2020-06-15', 'start_date', 'end_date')
RETURN e.name, c.name
""")
# Overlap: entities active during a range
graph.cypher("""
MATCH (r:Regulation)
WHERE valid_during(r, '2020-01-01', '2022-12-31', 'effective_from', 'effective_to')
RETURN r.name
""")
# Combine with other predicates
graph.cypher("""
MATCH (e:Employee)-[r:WORKS_AT]->(c:Company {name: 'Acme'})
WHERE valid_at(r, '2019-01-01', 'start_date', 'end_date')
RETURN e.name ORDER BY e.name
""")
# Works with date() function too
graph.cypher("MATCH (e:Estimate) WHERE valid_at(e, date('2020-06-15'), 'date_from', 'date_to') RETURN count(*)")
Duration semantics¶
A Duration value carries three independent components:
Component |
Source |
Units |
|---|---|---|
|
|
calendar months |
|
|
clock days |
|
|
clock seconds |
Components stay separate by design. Calendar arithmetic
(+ duration({months: 1})) is fundamentally different from clock
arithmetic (+ duration({days: 30})) because months have variable
length. duration({months: 1, days: 5}).months returns 1, not 35.
This matches Neo4j and openCypher; it diverges from Postgres
interval (which collapses everything into a single combined value).
Users coming from Postgres will need to know.
duration.between(d1, d2)¶
Computes the difference between two date or timestamp values. months is
always 0; whole days are stored in days, and any remaining sub-day difference
is stored in seconds.
RETURN duration.between(date('2024-08-12'), date('2026-05-02')).days
// → 628
RETURN duration.between(date('2024-08-12'), date('2026-05-02')).months
// → 0 (NOT 20 — `between` only fills in `days`)
Composite accessors¶
d.years = d.months / 12, d.minutes = d.seconds / 60,
d.hours = d.seconds / 3600. These are integer-truncated convenience
views on the underlying components — derived, not stored.
WITH duration({months: 26, days: 100}) AS d
RETURN d.months AS m, d.years AS y, d.days AS days
// → m=26, y=2 (26/12 truncated), days=100
DateTime ± Duration¶
Calendar months in the duration are approximated as 30 days for
DateTime arithmetic (the Value::DateTime precision limitation
again). For exact month-aware addition, use a literal date.
RETURN date('2024-01-15') + duration({days: 30}) // → 2024-02-14
RETURN date('2024-01-15') + duration({months: 1}) // → 2024-02-14 (1*30 days), NOT 2024-02-15
Math Functions¶
Function |
Description |
|---|---|
|
Absolute value |
|
Round up to integer |
|
Round down to integer |
|
Round to nearest integer |
|
Round to |
|
Square root |
|
Sign: -1, 0, or 1 |
|
Natural logarithm (x must be > 0) |
|
Base-10 logarithm (x must be > 0) |
|
e^x |
|
x^y |
|
π constant |
|
Random float [0, 1) |
|
Random RFC 4122 v4 UUID string |
Divergence — undefined arithmetic returns null. KGLite’s Float64 value can represent NaN and infinities (time-series channels use NaN as a missing sentinel), but these math expressions do not produce a non-finite result. They return
nullrather than Neo4j’sNaN/Infinity:sqrt(-1),log(0),log(-1)andlog10(-1)arenull, and so is every float division or modulo by zero (1.0 / 0.0,-1.0 / 0.0,0.0 / 0.0,1.0 % 0.0). Null then propagates through the rest of the expression and through comparisons, exactly like any other null. Integer division by zero is the one case that raises instead (RETURN 1 / 0→ “Integer division by zero”), matching Neo4j. Guard withcoalesce(...)or aWHERE x > 0filter where a number is required.
Trigonometric Functions¶
All take a numeric argument and return a Float64. Angles are in
radians (use radians(x) / degrees(x) to convert). NULL in → NULL
out; a non-numeric argument also yields NULL.
Function |
Description |
|---|---|
|
Trig functions (radians) |
|
Inverse trig functions |
|
Quadrant-aware arctangent of |
|
Cotangent ( |
|
Half-versed-sine |
|
Radians → degrees |
|
Degrees → radians |
// Bearing between two points (radians)
RETURN atan2(0.5, 0.5) // → 0.7853981633974483 (π/4)
RETURN degrees(pi()) // → 180.0
String Functions¶
Function |
Description |
|---|---|
|
Split string into list; an empty |
|
Replace all occurrences of |
|
Extract substring (0-indexed) |
|
First |
|
Last |
|
Remove leading/trailing whitespace |
|
Left/right trim |
|
Reverse a string |
Auto-coercion: String functions accept non-string values (DateTime, numbers, booleans) and auto-convert them to strings. For example,
substring(date('2020-06-15'), 0, 4)returns"2020".
Characters, not bytes. Every string function here is indexed by character, and so are
size()/length()—size('Tromsø')is6, sosubstring('Tromsø', size('Tromsø') - 1)is'ø'.That holds for every string, including one delimited by brackets:
size('[1,2,3]')is7andsize('[redacted]')is10, matching Neo4j’ssize(STRING). Only the argument’s type decides — pass a real list and you get its element count. KGLite’s other list-coercions on strings (UNWIND, indexing,head/last/reverse,IN) are unchanged and still read'[1,2,3]'as a three-element list.
Divergence —
splitwith an empty delimiter. openCypher does not define this case. KGLite splits into characters:split('abc', '')is['a', 'b', 'c']. An empty original stays['']for any delimiter, sosplit('', '')andsplit('', ',')both return[''].
graph.cypher("RETURN split('a,b,c', ',') AS parts") # ["a", "b", "c"]
graph.cypher("RETURN split('abc', '') AS chars") # ["a", "b", "c"]
graph.cypher("RETURN replace('hello world', 'world', 'cypher') AS s") # "hello cypher"
graph.cypher("RETURN substring('hello', 1, 3) AS s") # "ell"
graph.cypher("RETURN left('hello', 2) AS l, right('hello', 2) AS r") # "he", "lo"
Text Predicates¶
Lexical similarity and fuzzy-match primitives — useful for deduplication, alias matching, and free-text indexing without dropping to Python.
Function |
Returns |
Description |
|---|---|---|
|
|
Levenshtein edit distance (UTF-8 aware) |
|
|
Lowercase, drop punctuation, collapse whitespace |
|
|
Token-set Jaccard similarity (default separator: whitespace) |
|
|
Character n-grams |
|
|
True if |
|
|
True if |
# Edit distance — Levenshtein
graph.cypher("RETURN text_edit_distance('kitten', 'sitting') AS d") # 3
# Normalize before comparing
graph.cypher("RETURN text_normalize(' Hello, World! ') AS s") # "hello world"
# Jaccard similarity
graph.cypher("RETURN text_jaccard('a b c', 'b c d') AS j") # 0.5
# Fuzzy dedup pipeline
graph.cypher("""
MATCH (a:Person), (b:Person) WHERE a.id < b.id
WITH a, b, text_edit_distance(
text_normalize(a.title), text_normalize(b.title)
) AS d
WHERE d <= 2 RETURN a.title, b.title, d
""")
# Multi-prefix / multi-substring filters
graph.cypher("MATCH (n) WHERE text_starts_with_any(n.title, ['Mr.', 'Dr.', 'Prof.']) RETURN n")
graph.cypher("MATCH (n) WHERE text_contains_any(n.body, 'urgent', 'critical') RETURN n")
Arithmetic & String Concatenation¶
graph.cypher("MATCH (n:Product) RETURN n.title, n.price * 1.25 AS price_with_tax")
# String concatenation with ||
graph.cypher("MATCH (n:Person) RETURN n.first || ' ' || n.last AS fullname")
# || auto-converts non-strings; null propagates
graph.cypher("RETURN 'block-' || 35 AS label") # → "block-35"
Integer arithmetic is checked. + - * / % and unary - on two integers
raise CypherExecutionError when the result leaves the signed 64-bit range,
and integer division or modulo by zero raises rather than returning NULL.
Integer division truncates toward zero (-7 / 2 → -3) and only promotes to
float when an operand is a float. Float division by zero stays NULL — see
Magnitude/error policy.
CASE Expressions¶
# Generic form
graph.cypher("""
MATCH (n:Person)
RETURN n.name,
CASE WHEN n.age >= 18 THEN 'adult' ELSE 'minor' END AS category
""")
# Simple form
graph.cypher("""
MATCH (n:Person)
RETURN n.name,
CASE n.city WHEN 'Oslo' THEN 'capital' WHEN 'Bergen' THEN 'west coast' ELSE 'other' END AS region
""")
List Properties¶
Node properties can be native lists, not just scalars. When a pandas
column passed to add_nodes holds Python lists, it is ingested as a real
list property (auto-detected, or forced with column_types={'col': 'list'})
— stored structurally rather than stringified. List properties behave like
list literals in every list operation:
# aliases is a list property, e.g. ['Bob', 'Bobby']
graph.cypher("MATCH (n:Person) WHERE 'Bobby' IN n.aliases RETURN n.name") # membership
graph.cypher("MATCH (n:Person) UNWIND n.aliases AS a RETURN n.name, a") # explode
graph.cypher("MATCH (n:Person) RETURN size(n.aliases) AS n_aliases") # length
IN over a list property is true membership — 'Bob' IN ['Bobby'] is
false (no substring matching).
List Comprehensions¶
[x IN list WHERE predicate | expression] syntax:
# Map: double each number
graph.cypher("UNWIND [1] AS _ RETURN [x IN [1, 2, 3, 4, 5] | x * 2] AS doubled")
# [2, 4, 6, 8, 10]
# Filter only
graph.cypher("UNWIND [1] AS _ RETURN [x IN [1, 2, 3, 4, 5] WHERE x > 3] AS filtered")
# [4, 5]
# Filter + map
graph.cypher("UNWIND [1] AS _ RETURN [x IN [1, 2, 3, 4, 5] WHERE x > 3 | x * 2] AS result")
# [8, 10]
# With collect() — transform aggregated values
graph.cypher("""
MATCH (p:Person)
WITH collect(p.name) AS names
RETURN [x IN names | toUpper(x)] AS upper_names
""")
Note: List comprehensions require at least one row in the pipeline. Use
UNWIND [1] AS _or a precedingMATCH/WITHto provide the row context.
List Quantifier Predicates¶
any(x IN list WHERE pred), all(...), none(...), single(...) — test list elements against a predicate:
Function |
Returns |
|---|---|
|
At least one element satisfies the predicate |
|
Every element satisfies the predicate |
|
No element satisfies the predicate |
|
Exactly one element satisfies the predicate |
# any: at least one friend over 30
graph.cypher("""
MATCH (p:Person)-[:KNOWS]->(f:Person)
WITH p, collect(f.age) AS ages
WHERE any(a IN ages WHERE a > 30)
RETURN p.name
""")
# all: every item costs less than 100
graph.cypher("""
MATCH (o:Order)-[:CONTAINS]->(i:Item)
WITH o, collect(i.price) AS prices
WHERE all(p IN prices WHERE p < 100)
RETURN o.id
""")
# none / single
graph.cypher("RETURN none(x IN [1, 2, 3] WHERE x < 0) AS all_positive") # true
graph.cypher("RETURN single(x IN [1, 2, 3] WHERE x = 2) AS has_one_two") # true
Works in WHERE, RETURN, and WITH clauses.
Reduce (List Fold)¶
reduce(acc = init, x IN list | body) — fold a list with an accumulator. The body is evaluated once per element with acc and x bound; the final accumulator value is returned.
# Sum
graph.cypher("RETURN reduce(s = 0, x IN [1, 2, 3, 4, 5] | s + x) AS total") # 15
# Concat
graph.cypher('RETURN reduce(s = "", x IN ["a", "b", "c"] | s + x) AS r') # "abc"
# Max via CASE
graph.cypher("""
RETURN reduce(m = 0, x IN [5, 3, 8, 1, 7] |
CASE WHEN x > m THEN x ELSE m END
) AS max_val
""") # 8
# Pair with collect()
graph.cypher("""
MATCH (n:Person) WITH collect(n.age) AS ages
RETURN reduce(s = 0, x IN ages | s + x) AS total
""")
JSON Parsing¶
parse_json(s) (alias from_json(s)) parses a JSON string into a structured
value — an object becomes a map, an array a list, with scalars typed as
int / float / bool / string. Invalid JSON or a non-string argument returns
null (never an error). This lets you predicate over data that is stored as
a JSON string rather than as graph structure.
The code graph keeps Function.parameters, Class.fields, and
Function.signature as JSON (property columns hold scalars only), so
parse_json is how you query inside them:
# Functions that take a parameter typed `Dataset`. Each parsed element is a
# map with keys name / type_annotation / default / kind.
graph.cypher("""
MATCH (f:Function)
WHERE any(p IN parse_json(f.parameters) WHERE p.type_annotation = 'Dataset')
RETURN f.qualified_name
""")
# Index into the parsed structure with bracket subscript (works on lists and
# maps). To reach a map field after a list index, chain brackets — or bind the
# parsed value with WITH and use dot access (arr[0].name).
graph.cypher("RETURN parse_json('[{\"name\":\"x\"}]')[0]['name'] AS first") # "x"
graph.cypher("RETURN parse_json('{\"a\":1}')['a'] AS a") # 1
Combine with any / all / list comprehensions to filter or project the
parsed elements.
List Slicing¶
expr[start..end] syntax — slice lists with optional start/end bounds and negative indices:
# Slice collected values
graph.cypher("""
MATCH (p:Person)
WITH collect(p.name) AS names
RETURN names[0..3] AS first_three
""")
# Open-ended slices
graph.cypher("RETURN [1,2,3,4,5][2..] AS from_idx_2") # [3, 4, 5]
graph.cypher("RETURN [1,2,3,4,5][..3] AS first_three") # [1, 2, 3]
# Negative indices (from end)
graph.cypher("RETURN [1,2,3,4,5][-2..] AS last_two") # [4, 5]
Map Projections¶
n {.prop1, .prop2, alias: expr} syntax — select specific properties from a node:
# Select only name and age (returns a dict per row)
graph.cypher("MATCH (p:Person) RETURN p {.name, .age} AS info")
# [{'info': {'name': 'Alice', 'age': 30}}, {'info': {'name': 'Bob', 'age': 25}}]
# Mix shorthand properties with computed values
graph.cypher("""
MATCH (p:Person)-[:WORKS_AT]->(c:Company)
RETURN p {.name, .age, company: c.name} AS info
""")
# System properties (id, type) work too
graph.cypher("MATCH (p:Person) RETURN p {.name, .type, .id} AS info LIMIT 1")
# [{'info': {'name': 'Alice', 'type': 'Person', 'id': 1}}]
Map Literals¶
{key: expr, key2: expr} syntax — construct map objects in RETURN, WITH, or anywhere an expression is valid:
# Build a map from node properties
graph.cypher("""
MATCH (p:Person)
RETURN {name: p.name, age: p.age} AS info
""")
# Computed values in map literals
graph.cypher("""
MATCH (p:Person)
RETURN {name: p.name, next_age: p.age + 1} AS info
""")
# Map literals in WITH
graph.cypher("WITH {x: 1, y: 2} AS point RETURN point")
Parameters¶
graph.cypher(
"MATCH (n:Person) WHERE n.age > $min_age RETURN n.name, n.age",
params={'min_age': 25}
)
# Parameters in inline pattern properties
graph.cypher(
"MATCH (n:Person {name: $name}) RETURN n.age",
params={'name': 'Alice'}
)
# Parameters with DataFrame output
df = graph.cypher(
"MATCH (n:Person) WHERE n.age > $min_age RETURN n.name, n.age ORDER BY n.age",
params={'min_age': 20}, to_df=True
)
A $name the query references and params does not supply is an error —
Missing parameter: $name — wherever it is written, the inline property map
included. It is never read as “matches nothing”.
Dynamic labels and relationship types¶
A parameter can also supply a label or relationship type, in both the bare
and the Neo4j 5 parenthesised spelling — $label and $(label) are the same
reference:
graph.cypher("MATCH (n:$label) RETURN n.name", params={'label': 'Person'})
graph.cypher("MATCH (n:$(label)) RETURN n.name", params={'label': 'Person'})
graph.cypher("MATCH (a)-[:$type]->(b) RETURN b.name", params={'type': 'KNOWS'})
graph.cypher("CREATE (n:$label {id: 1})", params={'label': 'Person'})
graph.cypher("MATCH (n:Person {id: 1}) SET n:$label", params={'label': 'Employee'})
graph.cypher("MATCH (n:Person {id: 1}) REMOVE n:$label", params={'label': 'Employee'})
graph.cypher("MATCH (n) WHERE n:$label RETURN n.name", params={'label': 'Robot'})
It works anywhere a name is written: MATCH, MERGE, CREATE, SET,
REMOVE, the WHERE n:Label predicate, secondary labels ((n:Person:$label)),
type alternations (-[:KNOWS|$type]->), and inside EXISTS { … } /
COUNT { … } / CALL { … }.
Why it matters: there is now no position left that a caller has to escape. Building a query from user input used to mean splicing the label into the query text, and every such caller owned an injection surface. As a parameter the value is a name by construction — it is bound into an already-parsed query, so no spelling of it (backticks, braces, a closing parenthesis, another label) can change the query’s shape. A value that does not name an existing label simply matches nothing, exactly as the literal spelling would:
# Matches nothing. Not a syntax error, and not a different query.
graph.cypher("MATCH (n:$label) RETURN n",
params={'label': "Person) RETURN n MATCH (m:Person"})
The parameter is bound before the query is planned, so a dynamic label plans
and performs exactly like the literal one — EXPLAIN of both is identical, and
index selection is unaffected.
Two limits, both deliberate:
$(...)takes a parameter name, not a general expression;$(row.label)is a syntax error.The value must be a string. Neo4j’s list forms (one parameter expanding into several labels, or into a type alternation) are rejected with an error rather than expanded. A missing parameter is an error too — a label has no sensible default, and matching nothing would hide the caller’s bug.
UNWIND¶
Expand a list into rows:
graph.cypher("UNWIND [1, 2, 3] AS x RETURN x, x * 2 AS doubled")
UNWIND over a parameter is also the bulk-write route — UNWIND $rows AS row CREATE (…) writes thousands of nodes in one statement, paying the per-statement
parse, plan, checkpoint and version-bump cost once instead of once per row.
LOAD CSV¶
Read a delimited file and pipe its rows through the rest of the query, in the spelling other Cypher databases use, so a ported load script runs unedited.
LOAD CSV [WITH HEADERS] FROM <source> AS <variable> [FIELDTERMINATOR <sep>]
graph.cypher("""
LOAD CSV WITH HEADERS FROM 'file:///data/people.csv' AS row
CREATE (:Person {id: toInteger(row.id), name: row.name})
""")
Row binding. WITH HEADERS binds each record as a map keyed by the
header row (row.name); without it, each record binds as a zero-indexed
list (row[0]) and the first line is data, not a header. Fields are always
strings — CSV carries no types, and inferring them would corrupt
leading-zero identifiers, so convert explicitly with toInteger(row.n) /
toFloat(row.x). An empty field is null, and a short row nulls its
missing columns rather than failing the load.
Position. LOAD CSV must be the first clause. It is a row source, not
a transform, and the executor drives everything after it (see below).
Sources. file:// URLs and plain local filesystem paths. http(s):// is
rejected with a message, never a syntax error — the engine ships no HTTP
client (network dependencies were removed in 0.14.x). Fetch the file first, or
download and parse it in your own code and pass the rows in as a parameter.
FROM $path works, so the location can be a parameter.
Reading local files is a capability the caller is granted, not a given:
Caller |
Default |
Override |
|---|---|---|
Python API, Rust library, CLI (in-process) |
Allowed — the caller already has the host process’s filesystem access |
— |
|
Denied |
|
|
Denied |
none |
Without this gate, any client that could open a Bolt connection could run
LOAD CSV FROM 'file:///etc/passwd'. Server-mode graph databases generally
gate CSV import the same way — an allowed import directory, off by default.
Memory: what streams and what does not¶
LOAD CSV reads the file in 1000-row batches and runs the clauses that
follow once per batch, so peak memory does not scale with file size. Measured
on a row-local pipeline, a 5 MB and a 109 MB input both cost ~20 MB of resident
memory.
Batching is only equivalent to a single whole-file pass when every following
clause is row-local — MATCH, WHERE/FILTER, UNWIND, CREATE/INSERT,
MERGE, SET, the delete/remove forms, FOREACH, ordinary procedures, and
non-aggregating WITH/RETURN (plus terminal FINISH). That covers the ingest
shape the clause exists for, and it streams at any file size.
A clause that reasons over the whole result — any aggregate, ORDER BY,
SKIP/OFFSET, LIMIT, DISTINCT, a set operation, cluster(), or a CALL
subquery — cannot be batched without changing the answer (RETURN count(*)
would report one count per batch). Those queries read the file into a single
pass instead, capped at 1,000,000 rows: past that the query fails naming the
clause that forced it, rather than exhausting memory. Restructure to a row-local
pipeline, or aggregate outside the query.
Not supported: CALL { ... } IN TRANSACTIONS (and the older
USING PERIODIC COMMIT). Batching here is automatic and internal, so there is
no commit-interval to declare; a whole LOAD CSV statement commits once.
UNION / INTERSECT / EXCEPT¶
Set operators combine two queries with matching column shapes.
Operator |
Semantics |
Duplicate handling |
|---|---|---|
|
Rows from either side |
Deduped |
|
Rows from either side |
Duplicates kept |
|
Rows present in both sides |
Always deduped |
|
Rows in left but not in right |
Always deduped |
# UNION — combine
graph.cypher("""
MATCH (n:Person) WHERE n.city = 'Oslo' RETURN n.name AS name
UNION
MATCH (n:Person) WHERE n.age > 30 RETURN n.name AS name
""")
# INTERSECT — keep names that appear on both sides
graph.cypher("""
MATCH (n:Person) WHERE n.city = 'Oslo' RETURN n.name AS name
INTERSECT
MATCH (n:Person) WHERE n.age > 30 RETURN n.name AS name
""")
# EXCEPT — Oslo residents minus everyone over 30
graph.cypher("""
MATCH (n:Person) WHERE n.city = 'Oslo' RETURN n.name AS name
EXCEPT
MATCH (n:Person) WHERE n.age > 30 RETURN n.name AS name
""")
Set operators dedupe by the projected column values; column names must match between sides (positional fallback when they don’t).
Variable Binding in MATCH Patterns¶
Variables from WITH or UNWIND can be used as values in inline pattern properties:
# Scalar variable in pattern property
graph.cypher("""
WITH 'Oslo' AS city
MATCH (p:Person {city: city})
RETURN p.name
""")
# UNWIND + pattern variable — batch lookups
graph.cypher("""
UNWIND ['Alice', 'Bob'] AS name
MATCH (p:Person {name: name})
RETURN p.name, p.age
ORDER BY p.age
""")
Variable-Length Paths¶
# 1 to 3 hops
graph.cypher("MATCH (a:Person)-[:KNOWS*1..3]->(b:Person) WHERE a.name = 'Alice' RETURN b.name")
# Exact 2 hops
graph.cypher("MATCH (a:Person)-[:KNOWS*2]->(b:Person) RETURN a.name, b.name")
Open-ended forms (*, *N..) default the upper bound to 10 hops
as a runaway-query guard — an intentional divergence from openCypher’s
unbounded * (recorded as pattern.var_length_default_cap in the dialect
manifest). An explicit lower bound above 10 (*11..) raises the ceiling to
that bound, and a range whose minimum exceeds its maximum (*5..2) is a
parse error. Spell out *1..N when you need more than 10 hops.
Trail semantics¶
A variable-length segment walks trails: no relationship may be used twice
within one MATCH clause, and that rule spans the whole clause — a sibling
edge in the same clause cannot re-bind a relationship the segment already
walked. This is openCypher’s rule, and it is what makes an unbounded-looking
pattern terminate on a cyclic graph.
Two consequences worth knowing:
A node can be its own endpoint. On a triangle
a-b-c-a,MATCH (a {id: 1})-[:KNOWS*1..3]-(b) RETURN DISTINCT b.idreturns1, 2, 3— the closed traila→b→c→auses three distinct relationships, soais legitimately reachable from itself. It is not returned at*1..2, because stepping back along the edge you arrived on would reuse that relationship.A trail is not a walk. Engines that answer these patterns with a distance-style BFS (and SQL recursive CTEs written the obvious way) compute a different relation, so a cross-engine comparison of a variable-length query can disagree by a few nodes without either side being broken.
What depth costs¶
Reachability shapes — count(DISTINCT …), RETURN DISTINCT, EXISTS { … }
over a segment whose minimum is 0 or 1 — run one breadth-first pass over the
reachable subgraph, so their cost flattens as soon as the frontier saturates
and EXISTS is depth-independent (it stops at the first witness). count(*),
and every shape whose minimum hop count is 2 or more, are per-path by the
trail rule above, and grow with branching to the power of the depth. The
guide’s How deep traversal behaves
section carries the measured curves.
When a pattern does explode, the 10,000,000-row backstop that applies when
no max_work_units is set is charged against the expansion as it runs, not only
against the finished result, so an open-ended traversal is refused while it
expands rather than after it has exhausted memory. The error names which
expansion overflowed — the MATCH itself, a comma-pattern join, an
OPTIONAL MATCH, an EXISTS { … } or a COUNT { … } subquery — and both
ways to raise the ceiling.
WHERE EXISTS¶
Check for subpattern existence. Brace { }, parenthesis (( )), and inline pattern syntax are all supported:
# Brace syntax
graph.cypher("MATCH (p:Person) WHERE EXISTS { (p)-[:KNOWS]->(:Person) } RETURN p.name")
# With optional MATCH keyword and WHERE clause inside
graph.cypher("""
MATCH (p:Person)
WHERE EXISTS { MATCH (p)-[:KNOWS]->(f:Person) WHERE f.age > 30 }
RETURN p.name
""")
# Parenthesis syntax (equivalent)
graph.cypher("MATCH (p:Person) WHERE EXISTS((p)-[:KNOWS]->(:Person)) RETURN p.name")
# Inline pattern predicate (shorthand for EXISTS)
graph.cypher("MATCH (p:Person) WHERE (p)-[:KNOWS]->(:Person) RETURN p.name")
# Negation
graph.cypher("""
MATCH (p:Person)
WHERE NOT EXISTS { (p)-[:PURCHASED]->(:Product) }
RETURN p.name
""")
Property existence: the Neo4j-legacy
exists(n.prop)form for property-existence is not supported in KGLite. Use the modernWHERE n.prop IS NOT NULL/WHERE n.prop IS NULLinstead — those are property-existence checks;EXISTS { ... }andEXISTS((...))are pattern-existence checks. Writingexists(n.prop)returns a parser error that points at theIS NOT NULLalternative.
shortestPath()¶
BFS shortest path between two nodes. Supports directed (->) and undirected (-) syntax:
# Directed — only follows edges in their defined direction
result = graph.cypher("""
MATCH p = shortestPath((a:Person {name: 'Alice'})-[:KNOWS*..10]->(b:Person {name: 'Dave'}))
RETURN length(p), nodes(p), relationships(p), a.name, b.name
""")
# Undirected — traverses edges in both directions (same as fluent API)
result = graph.cypher("""
MATCH p = shortestPath((a:Person {name: 'Alice'})-[:KNOWS*..10]-(b:Person {name: 'Dave'}))
RETURN length(p), nodes(p), relationships(p)
""")
# No path → empty list (not an error)
Path functions: length(p) returns the hop count, nodes(p) returns full
node values, and relationships(p) returns full relationship values in path
order. Use type(r) (or r.type in a projected Python value) when you need
only each relationship type.
Weighted shortest path¶
The fluent shortest_path() accepts an optional weight_property that flips the search from BFS (hop count) to Dijkstra (sum of edge weights). Edges missing the property fall back to weight 1.0; negative weights cause the path to be reported as missing.
# Cheapest path by edge.cost (a property on each edge)
result = graph.shortest_path(
"Stop", "A", "Stop", "Z",
weight_property="cost",
)
# {'path': [...], 'connections': [...], 'length': 3, 'weight': 4.7}
# Length-only variant returns float when weighted, int otherwise
graph.shortest_path_length("Stop", "A", "Stop", "Z", weight_property="cost") # → 4.7
graph.shortest_path_length("Stop", "A", "Stop", "Z") # → 3
Same Louvain plumbing — weight_property=None falls back to BFS.
Procedure calls and incoming rows¶
An ordinary CALL procedure(...) YIELD ... is a row operator. Parameter
expressions are evaluated against each incoming row, the procedure runs once
for that row, and each yielded row is inner-joined with the outer bindings.
Multiple procedure rows multiply the outer row; no procedure rows drop it. A
leading CALL receives one implicit seed row, while an empty stream later in a
query stays empty and does not invoke the procedure.
graph.cypher("""
UNWIND ['Person', 'Company'] AS kind
CALL db.property_stats({node_type: kind, property: 'name'})
YIELD value_count
RETURN kind, value_count ORDER BY kind
""")
Outer bindings remain available after CALL; a yielded name that would
overwrite one is rejected. cluster() is the explicit exception: it consumes
the complete cohort bound by the preceding MATCH because that row set is the
data being clustered.
CALL { ... } read subqueries¶
A CALL subquery runs its read pipeline once for every incoming row, including
when it imports no variables. A leading subquery receives one implicit seed
row; a later empty outer stream stays empty. Each row produced by the body is
inner-joined with its own outer row, so a body returning k rows emits k
joined rows and k = 0 drops that outer row. An aggregating body such as
RETURN count(*) still returns one row for an empty match and therefore keeps
the outer row with a zero.
# Modern named scope: p is imported and remains visible throughout the body.
graph.cypher("""
MATCH (p:Person)
CALL (p) {
MATCH (p)-[:KNOWS]->(f)
RETURN count(f) AS friend_count
}
RETURN p.name AS name, friend_count
""")
# Empty scope imports nothing, but the body still executes once per company.
graph.cypher("""
MATCH (c:Company)
CALL () { MATCH (p:Person) RETURN count(p) AS people }
RETURN c.name AS company, people
""")
Scope forms¶
Form |
Imported outer variables |
Scope inside the body |
|---|---|---|
|
The named variables |
Global: imports survive later |
|
Every variable currently in scope |
Global, as above |
|
None |
No outer names are visible; the body still runs per input row |
|
Bare names in the first |
Legacy import: each |
|
None |
Legacy no-import form; runs per input row like |
Modern scope lists accept bare, distinct variable names only: aliases and
expressions are rejected. Legacy importing WITH likewise accepts only bare
variables—not aliases, expressions, aggregation, or a WHERE in the importing
position. With modern scope, imported names stay visible after ordinary WITH
projection or aggregation; with legacy scope, ordinary Cypher WITH scoping
applies after the importing clause.
Unimported outer names are never auto-correlated. A name used in a pattern inside an empty/no-import body is a fresh pattern variable, not the outer binding.
Set operations inside a subquery¶
Read subqueries support UNION and UNION ALL. Every arm starts from the same
outer seed; a modern scope applies to all arms, while a legacy import must be
repeated at the start of each arm. All arms must return the same column names
in the same order. KGLite also supports INTERSECT and EXCEPT in a read
subquery as dialect extensions.
graph.cypher("""
MATCH (p:Person)
CALL (p) {
RETURN p.name AS value
UNION ALL
RETURN p.name + '!' AS value
}
RETURN value ORDER BY value
""")
Result scope and restrictions¶
Only columns named by each arm’s terminal RETURN leave the body. Internal
variables do not leak, and a returned name that collides with an outer binding
is rejected even when the outer stream is empty.
The body must be a read pipeline ending in RETURN. Writes, unit subqueries
(no terminal RETURN), and CALL { ... } IN TRANSACTIONS are not supported;
put writes in a top-level clause and use the documented LOAD CSV batching or
an explicit transaction when you need a write batch.
Schema Introspection (CALL db.*)¶
Neo4j-compatible schema procedures for discovering what’s in the graph
without leaving Cypher. Bolt clients (cypher-shell, Neo4j Browser, the
Python neo4j driver) call these to populate their type palettes and
sidebars; SHOW PROCEDURES feeds autocomplete.
Procedure |
YIELD columns |
Returns |
|---|---|---|
|
|
One row per node-type (“label”) in the graph, sorted alphabetically |
|
|
One row per connection-type (“relationship type”) in the graph, sorted alphabetically |
|
|
One row per index installed on the graph, sorted by |
|
|
One row per declared constraint, sorted by |
|
|
One row per declared property name (node + relationship), sorted alphabetically |
|
|
One row per node-type with its sorted list of property names — the in-language counterpart of Python |
|
|
One row: virtual nodes (one per label; |
Procedure names are case-insensitive on dispatch (Neo4j convention
preserves camelCase in docs: db.relationshipTypes, not
db.relationship_types). YIELD columns are case-sensitive. The other db.*
family is the change stream — see
Change data capture.
Standalone CALL. YIELD is optional when the CALL is the entire
statement: CALL db.labels() returns every declared column in declared
order — the form Neo4j clients and cypher-shell send. Combined with any
other clause, YIELD is required. Result columns always follow YIELD
order (alias-or-name), and a call that yields zero rows still reports its
declared columns.
SHOW PROCEDURES [YIELD …] lists every procedure (Neo4j’s default
columns: name, description, mode, worksOnSystem; signature
yieldable) from the same registry CALL list_procedures() reads.
SHOW FUNCTIONS [YIELD …] lists every scalar and aggregate function
(default columns name, category, description; signature and
aliases yieldable) from a registry whose every entry is test-verified to
dispatch — IDEs use both for autocomplete. Bolt server only:
CALL dbms.components(), CALL dbms.showCurrentUser(), and
SHOW DATABASES are answered by kglite-bolt-server (they report server
facts — identity, configured user, served database) and are not available
in-process; see the
Bolt server guide.
# Enumerate node types
for row in graph.cypher("CALL db.labels() YIELD label RETURN label"):
print(row["label"])
# Find relationship types matching a prefix
graph.cypher("""
CALL db.relationshipTypes() YIELD relationshipType
WHERE relationshipType STARTS WITH 'WORKS'
RETURN relationshipType
""")
# Inspect indexes
for idx in graph.cypher("""
CALL db.indexes() YIELD name, type, properties
RETURN name, type, properties ORDER BY name
"""):
print(f"{idx['name']:30} type={idx['type']:9} props={idx['properties']}")
# All property keys, and the per-type schema (no separate API needed)
graph.cypher("CALL db.propertyKeys() YIELD propertyKey RETURN propertyKey ORDER BY propertyKey")
for row in graph.cypher("CALL db.schema() YIELD nodeType, properties RETURN nodeType, properties"):
print(f"{row['nodeType']}: {row['properties']}")
db.indexes() column semantics¶
Column |
KGLite value |
|---|---|
|
|
|
|
|
Always |
|
|
|
|
|
|
KGLite extension. Neo4j collapses equality + range under a single
type = "PROPERTY"; KGLite distinguishes range indexes
(type = "RANGE") because the planner uses the distinction — an
equality index can’t serve a range query. Index advisors and tooling
that branch on type get the information they need without parsing
the name string. A BM25 text index reports Neo4j’s "FULLTEXT",
which is what it is for a client reading the column — KGLite’s is
single-label and single-property, so CREATE FULLTEXT INDEX still
refuses; build one with build_text_index(node_type, property). A
built HNSW index reports "VECTOR" and is named for the source
column it was built over, not the _emb store key, so a type carrying
both a text and a vector index over body lists two rows under the same
name and different types. Embedding stores with no index built over them
are not indexes and are reported by list_embeddings() instead.
Cross-reference with the Python API¶
db.indexes() is the procedure form of the Python
KnowledgeGraph.list_indexes() method — both pull from the same
introspection helper, so output stays in sync. Use db.indexes()
from a Bolt client or inside a Cypher pipeline; use list_indexes()
from Python code where you’d rather have a Python list of dicts than
a cypher() result.
Change data capture (CALL db.cdc.*)¶
An opt-in stream of the changes the graph publishes, read from Cypher — so
every binding (Python, the CLI shell, a Bolt client, the C ABI) gets it
without a method of its own. Capture is off until you ask for it; nothing
that happened before enable is in the log.
Procedure |
YIELD columns |
Does |
|---|---|---|
|
|
Start capturing (or reconfigure a running log in place). Both arguments are optional and every omitted one takes its default: |
|
|
Stop capturing and discard the log. Idempotent; |
|
|
How capture is configured and how much it is holding. The one read verb that answers while capture is off ( |
|
|
Cursor addressing the newest published change — start here to see only what happens next |
|
|
Cursor addressing the oldest change still retained — start here to read the whole retained log |
|
|
Changes published after the |
graph.cypher("CALL db.cdc.enable()")
# Consume from "now": remember the cursor, poll, remember again.
cursor = graph.cypher("CALL db.cdc.current()").to_dicts()[0]["id"]
graph.cypher("CREATE (:Person {id: 1, name: 'ann'})")
graph.cypher("MATCH (p:Person {id: 1}) SET p.name = 'anne'")
rows = graph.cypher(
"CALL db.cdc.query({from: $c}) YIELD id, operation, nodeType, nodeId, state",
params={"c": cursor},
).to_dicts()
for row in rows:
print(row["operation"], row["nodeType"], row["nodeId"], row["state"])
# create Person 1 {'after': {'labels': [], 'properties': {'name': 'ann'}, 'title': 'ann'}, 'before': None}
# update Person 1 {'after': {'labels': [], 'properties': {'name': 'anne'}, 'title': 'anne'}, 'before': None}
cursor = rows[-1]["id"] # the row's own id is the cursor for the next poll
Rows. operation is create, update or delete; elementType is
node or relationship. A node row carries (nodeType, nodeId) and null
relationship columns; a relationship row carries
(relationshipType, srcType, srcId, tgtType, tgtId) and null node columns —
logical identity, not an internal index, so a cursor’s events stay meaningful
after a compaction.
state is the pair {before, after} — Neo4j’s shape — and it is always
a map, so a consumer reads state["after"] without null-checking the container
first. Each half is the entity’s image on that side of the commit, or null
where the log holds none:
after—{title, labels, properties}for a node,{properties}for a relationship, andnullfor a delete, which left no entity behind.before— the same shape, underenrichment: 'full': what the commit found.nullfor a create (there was nothing before it), andnullin every row under the defaultenrichment: 'off', which captures no pre-image. A delete’sbeforeis the state it destroyed — the one event whose only informative half is this one.
Each half is null rather than an empty map on purpose: an empty map reads as “an entity with no properties”, which is a different fact.
Enrichment. enable({enrichment: 'off' | 'full'}) selects how much state
each event carries. 'off' — after-image only — is the default; 'full' adds
the before-image. The argument is named after Neo4j’s txLogEnrichment
database option and takes two of its three values: 'diff' is refused by
name, because a diff is computed from the full before-image, so it would
save ring bytes rather than capture work, at the cost of a second event
semantics for consumers to handle. A consumer that wants a diff computes one
from a 'full' event, where both sides are present.
Changing the mode on a running log keeps the epoch, exactly as a resize
does, so live cursors survive it. It applies to writes, not to the log: the
events already in the ring keep the shape they were captured with, so a
consumer that switches to 'full' starts seeing before from the next commit
rather than retroactively.
before is the state at the start of the commit, not before the most
recent write. Three writes to one entity in one transaction publish one event,
whose before is what the transaction opened on and whose after is what it
left — so the pair answers “what did this commit change”, which is the
question a mirror or an audit trail is asking. A label change is included on
the node’s side: before.labels is the set the commit replaced.
Selectors — filtering the stream¶
selectors is a list of filter maps, and an event is returned if any one
of them matches it — so one query serves “every delete, plus every change to
a Person”. Within a single map every key must hold, so
{nodeType: 'Person', operation: 'update'} is “updates of Person”.
CALL db.cdc.query({
from: $cursor,
selectors: [{operation: 'delete'}, {nodeType: 'Person', operation: 'update'}],
maxRows: 100
}) YIELD operation, nodeType, nodeId, state
Key |
Matches when |
Example |
|---|---|---|
|
the row’s |
|
|
the row’s |
|
|
a node row whose |
|
|
a relationship row whose type equals it |
|
|
a relationship row whose endpoint type equals it (directional) |
|
|
a node row whose |
|
|
a relationship row whose endpoint id equals it |
|
|
a node carrying all the listed secondary labels |
|
|
any listed property differs across the commit — needs |
|
The vocabulary is the columns’. operation takes create/update/delete
and elementType takes node/relationship — the same strings the rows
report, not Neo4j’s single-letter c/u/d. A selector that spelled a
concept differently from the column it filters would be a trap.
labels is a conjunction, over secondary labels only.
{labels: ['Archived', 'Cold']} selects nodes carrying both; use two selectors
for “either”. The primary type is not in this set — that is nodeType’s job —
which keeps the key aligned with what state.after.labels reports. A
relationship has no labels, so a labels constraint never matches one.
changesTo compares the two images, so it requires
enrichment: 'full'; on an 'off' log it is refused, naming the fix,
rather than silently matching every event that merely has the property. An
absent image and an absent key read alike (“not there”), which makes the rule
total across operations: a create matches a property it set, a delete one it
had, an update one whose value moved. One caveat on a log switched from
'off' to 'full' mid-stream: events captured before the switch have no
before-image, so every property they carry reads as newly set.
Validation is strict, one level in. An unknown key inside a selector map
({nodeTyp: 'Person'}) is refused rather than ignored — a silently-dropped
constraint would widen the query to everything while looking like a filter.
Wrong-typed values are refused by key name. selectors: [] is the absence of
filters and returns everything; selectors: [{}] is refused, because an empty
map is a filter that constrains nothing and is almost always a selector built
from an empty set of conditions.
Filtering happens before the copy-out, so an event you did not ask for is
never cloned out of the ring, and maxRows is applied after filtering — it
caps the rows you receive, not the window they were drawn from. (The key is
maxRows and not limit because LIMIT is a reserved clause word that cannot
be written as a bare map key.)
Polling with selectors: take the cursor first. Rows keep the id they
would have had unfiltered — a cursor addresses the log, not your filtered view
— which is what lets two consumers with different selectors exchange cursors.
The consequence is that a filtered poll can return zero rows while the log
has advanced, so a consumer that advanced its cursor from “the last row I
received” would re-scan the same events forever. Take db.cdc.current()
before the query and adopt it after:
cursor = graph.cypher("CALL db.cdc.current()").to_dicts()[0]["id"]
while True:
nxt = graph.cypher("CALL db.cdc.current()").to_dicts()[0]["id"]
rows = graph.cypher(
"CALL db.cdc.query({from: $c, selectors: [{nodeType: 'Person'}]}) YIELD nodeId, state",
params={"c": cursor},
).to_dicts()
handle(rows) # may legitimately be empty
cursor = nxt # advance regardless
Cursors are opaque and exclusive. Pass back the string you were given
rather than constructing one — the encoding may change. Exclusive means a
cursor names what you have already seen, so polling with the last row’s id
never re-delivers it, and current() immediately followed by query returns
nothing.
A cursor addresses one log, and the log says so. Each log carries an
epoch; a cursor from a different epoch is refused rather than resolved
against different data. A new epoch is minted by enable on a graph that had
capture off — including after disable, after a .kgl load (the log is
process-local runtime state and is deliberately never saved, so a loaded graph
starts with capture off), and on an independent copy. Re-enable on a running
log only reconfigures it and keeps the epoch, so live consumers survive a
capacity or enrichment change. The three refusals are distinct because the fix differs:
a malformed cursor means fix the call, a foreign epoch means re-acquire, and a
cursor older than retention means resync and accept the gap.
A save records where the running epoch got to, so the wrong-epoch refusal
in the next process can say more than “that epoch is gone”. Present a cursor
from the epoch the file was saved under and the message names where it ended —
and whether you were caught up at that point or behind by a countable number of
changes that were never delivered. It is a diagnostic only: the log itself is
never persisted, so nothing is resumable and the remedy is still to resync from
earliest() or current(). A wrong-epoch cursor the file knows nothing about
keeps the plain refusal, because nothing is known about it to report.
Retention is a bounded ring. The oldest events are evicted as it fills, and
earliest() advances to match. A consumer that falls further behind than the
capacity gets a typed refusal naming both remedies — resync from earliest(),
or raise the retention with CALL db.cdc.enable({capacity: <larger>}) — never
a silently truncated answer.
A change that was not committed never appears. Events are derived from the
same write-capture buffer the write-ahead log uses, at the same commit
boundaries. A statement that failed, and a transaction that was rolled back,
contribute no event at all — not a filtered-out one. A Transaction
publishes its whole batch at commit(), and nothing before it. A held
ResultView forces the next write to fork copy-on-write; the fork shares the
one log, so that write publishes exactly once.
Capture is not free while it is on. Every mutation buffers an op, and an
enabled graph gives up the checkpoint-free-mutation fast path, so the cost
tracks ops written rather than statements run. Measured (release profile): a
bare CREATE +33%, MERGE that creates +8%, SET by id +3-7%, a MERGE that
matches and writes nothing 0%, and bulk loads most of all — +52% for a
1000-row add_nodes, +82-88% for 1000 add_connections. The default ring
holds ~50-65 MB of resident memory once full at its 65 536 events — the
spread is the event payload, not measurement slack: identity-only changes sit
near the bottom, 4-property nodes near the top. A graph that never calls
db.cdc.enable() is untouched by any of this.
enrichment: 'full' adds one whole-entity read per changed entity per commit
— not per write, because the image is taken at each entity’s first touch, and
creates read nothing at all. Measured at +2-5% (median +4.5%) on 1000
autocommit SETs, which is the worst case for it: every write is its own
commit and so its own first touch. An update or delete event then holds two
images where it held one, so a ring of a given capacity retains more bytes —
and that cost tracks the changed entity’s property payload rather than a fixed
multiplier: +0.7% of ring memory when the changes carry nothing beyond
their identity, +32% (~82 MB) on 4-property nodes. The default capacity
does not change with the mode, and is the knob to lower if that matters.
Storage modes. In-memory and storage='mapped' serve the stream
identically, durable or not. storage='disk' refuses enable: a disk
graph commits by publishing an immutable generation, so the per-commit write
capture this stream is derived from does not describe its change boundary, and
serving it would report a stream that silently missed writes.
Divergences from Neo4j’s db.cdc.*¶
KGLite matches the names its model has a concept for and drops the rest — an absent column is better than an empty one, because a consumer writes code against it.
Neo4j |
KGLite |
|---|---|
|
Same meaning: the opaque cursor addressing a change |
|
Wider. Neo4j orders events within a transaction; KGLite’s is the log-wide sequence, monotonic across commits, and it is what the cursor carries |
|
Absent. KGLite publishes at commit boundaries but assigns no durable transaction identity, so any value would be invented |
|
Absent. Neo4j reports executing user, connection client and transaction start time; the engine holds none of that |
|
Flattened into columns, so |
|
Same shape. |
|
Same idea, KGLite’s vocabulary. A list of maps, ANY-matched. Keys name the columns ( |
— |
|
Arguments are passed as a map ({capacity: N}, {from: cursor});
positional arguments are a parse error.
Code-graph analysis¶
When the graph is a parsed codebase (built by e.g. codingest), the
data needed for the analyses other tools ship as bespoke commands is
already on the graph — most are one query. The metrics are captured at
parse time (branch_count, max_nesting, loc) and the relationships
are first-class (CALLS, REFERENCES_FN, USES_TYPE, EXTENDS,
IMPLEMENTS).
CALL dead_code(...) — unreferenced functions¶
CALL dead_code() YIELD node
RETURN node.qualified_name AS fn, node.file_path AS file
ORDER BY file
Reports Function nodes with no inbound use edge — nothing CALLS
them, references them as a value (REFERENCES_FN), HANDLES them
(route), or IMPLEMENTED_BY (procedure), and no DECORATES participation.
Bundling all of those is the point: a naive WHERE NOT (:Function)-[:CALLS]->(f)
falsely flags callbacks, route handlers and decorated entry points.
Implicit entry points are excluded automatically: test functions, dunder
methods (__x__), and main. Options:
Param |
Default |
Effect |
|---|---|---|
|
|
also report test functions |
|
|
also drop |
CALL rev_diff({from, to}) — what changed between two revisions¶
For a multi-rev code graph built with
a multi-rev codingest build (one graph holding N git
revisions — see the Python guide), rev_diff reports the code entities
added, removed, or changed between any two loaded revs:
CALL rev_diff({from: 'v1', to: 'v2'})
YIELD bucket, type, qualified_name, name, file, line
RETURN bucket, type, qualified_name, file, line
ORDER BY bucket, qualified_name
Each merged node carries revs: [str] (the revisions it appears in) and
rev_fp: [int] (a per-rev shape fingerprint, positionally aligned with revs).
rev_diff reads those straight off each node: present at from only → removed;
at to only → added; present at both with a divergent rev_fp → changed. It
is a pure set-and-fingerprint membership check — it reports that an entity
changed (and its current/newest value via the ordinary property columns), never
re-parsing source, matching the two-graph structural-diff contract.
Param |
Required |
Effect |
|---|---|---|
|
yes |
the baseline rev label (as passed to |
|
yes |
the comparison rev label |
|
no |
scope to one node type (string) or several (list) |
Yields bucket ("added"/"removed"/"changed"), type, qualified_name,
name, file, line. Errors clearly on a graph that isn’t multi-rev (no revs
property) or an unknown rev (listing the available revs). Nodes-only in v1; edge
add/remove is a documented deferral. To scope an ordinary query to a single rev,
use list membership directly: MATCH (n:Function) WHERE 'v2' IN n.revs RETURN n
— an unscoped query spans all revs (an over-count trap).
Recipe queries (no procedure needed — the data is already there)¶
-- Complexity hotspots (cyclomatic-style branch count is stored per fn)
MATCH (f:Function)
RETURN f.qualified_name, f.branch_count, f.max_nesting
ORDER BY f.branch_count DESC LIMIT 20
-- Blast radius: everything that (transitively) calls a target
MATCH (caller:Function)-[:CALLS*1..5]->(t:Function {name: 'parse'})
RETURN DISTINCT caller.qualified_name
-- God functions: large + high fan-in + high fan-out
MATCH (f:Function)
RETURN f.qualified_name, f.branch_count,
size([(f)-[:CALLS]->() | 1]) AS fan_out,
size([()-[:CALLS]->(f) | 1]) AS fan_in
ORDER BY fan_out + fan_in DESC LIMIT 20
-- Call-recursion cycles (strongly-connected components over CALLS)
CALL connected_components({node_type: 'Function', relationship: 'CALLS'})
YIELD node, component
RETURN component, collect(node.name) AS members
ORDER BY size(members) DESC
For test-impact analysis from a set of changed files, see
CALL affected_tests({files: [...]}).
Freshness provenance & staleness (auto_timestamp)¶
Opt a node or connection type into engine-managed freshness stamping:
g.define_schema({
"nodes": {"Task": {"auto_timestamp": True}},
"connections": {"LINKS": {"source": "N", "target": "N", "auto_timestamp": True}},
})
define_schemamerges per node/connection type. A type the call names takes the new declaration entire; a type it omits keeps its own, so declaring per module never withdraws another type’s constraints. Passreplace=Truefor whole-schema replacement — it warns, naming each constraint it stops enforcing. Constraints declared withCREATE CONSTRAINTare unaffected by either mode; drop them withDROP CONSTRAINT.
Every write to an opted-in type then stamps a reserved updated_at (a
Timestamp) — Cypher CREATE/INSERT/MERGE/SET and
add_nodes/add_connections.
Pass git_sha / modified_by to record who/where too:
g.cypher("MERGE (t:Task {id: $id}) SET t.status = 'done'",
params={"id": "T1"}, git_sha=current_sha, modified_by="coding-agent")
These are metadata, not data: queryable directly (n.updated_at,
n.git_sha, r.updated_at) but hidden from properties(n) / keys(n) /
RETURN n / describe(). The engine owns them (a user-supplied value is
overwritten) and only stamps opted-in types, so other writes stay deterministic.
Staleness is a pure query — the engine never touches the filesystem. Have
the writer stamp the linked file’s state (file_path, file_mtime /
content_hash) when it writes the node; drift is then a check:
-- Nodes whose linked file changed since we last described it
MATCH (n:Artifact) WHERE n.updated_at < n.file_mtime
RETURN n.id, n.file_path, n.updated_at, n.file_mtime
-- Linked-path-gone: the writer stamps a flag (it owns fs access)
MATCH (n:Artifact) WHERE n.file_missing = true RETURN n.id, n.file_path
-- "As of which commit?" — group runtime nodes by the sha they were written at
MATCH (n:Task) WHERE n.git_sha IS NOT NULL
RETURN n.git_sha, count(n) ORDER BY count(n) DESC
Store file_mtime as a Timestamp (or ISO string) so it compares with
updated_at. For low-stakes drift you want to defer, mark your own
stale = true property and sweep later (MATCH (n) WHERE n.stale = true ...).
Edge confidence¶
Most edges are extracted — parsed facts (a CALLS edge is a real call
site). A few are inferred — best-effort heuristics, notably the
cross-language coupling edges (a client request matched to a server route by
path). Inferred edges carry confidence = "inferred"; extracted edges leave
the property unset. So:
-- facts only (exclude heuristic edges)
MATCH (a)-[r:CALLS]->(b) WHERE r.confidence IS NULL RETURN a, b
-- just the heuristic cross-language couplings
MATCH (a)-[r]->(b) WHERE r.confidence = 'inferred' RETURN type(r), a.name, b.name
Inheritance-resolved CALLS edges stay extracted — they’re pinned via
the type graph, not guessed, so they’re facts, not heuristics.
Scoping graph algorithms to a subgraph¶
The centrality (pagerank, degree, betweenness, closeness) and community
(louvain, leiden, label_propagation) procedures accept two optional
parameters that restrict the algorithm to a property-filtered subgraph,
so test / benchmark / external nodes don’t pollute the result:
Param |
Meaning |
|---|---|
|
string or list of node labels to include (e.g. |
|
a predicate over the node variable |
# PageRank over non-test, non-external functions only — the library's real hubs
graph.cypher("""
CALL pagerank({node_type: 'Function', connection_types: 'CALLS',
where: 'n.is_test = false AND n.is_external = false'})
YIELD node, score
RETURN node.name, score ORDER BY score DESC LIMIT 15
""")
# Louvain over a subsystem, excluding benchmark code
graph.cypher("""
CALL louvain({node_type: 'File', where: 'n.is_benchmark = false'})
YIELD node, community
RETURN community, count(*) AS size ORDER BY size DESC
""")
Only edges with both endpoints in scope are traversed, so scores and
communities reflect the subgraph, not the whole graph filtered afterward. An
explicit scope also lifts the large-graph refusal guard (you’ve bounded the
run yourself). Scoping is an in-memory-only feature; on disk/mapped graphs
the procedures reject node_type / where (filter with a preceding MATCH
instead).
Dependency frontier — CALL ready_set(...)¶
Over a DAG on a chosen edge type, ready_set returns the nodes whose
dependencies are all satisfied — the “ready set” of a build / scheduling /
plan graph. A node’s dependencies are its outgoing-edge neighbours, so
(task)-[:DEPENDS_ON]->(dependency) reads naturally: a task is ready once
every dependency it points to is done. “Done” is a predicate over the node
variable n (same grammar as where); a node already done is excluded, and a
root with no dependencies is ready as soon as it isn’t done.
Param |
Meaning |
|---|---|
|
the dependency edge type (string or list) |
|
predicate over |
|
optional — limit which nodes are emitted (dependencies are followed regardless) |
YIELD node, dependency_count (how many dependencies the ready node had, all satisfied).
Scope to the type you care about. A node with no outgoing-
Eedge is a root — vacuously “all dependencies satisfied” — so an unscopedready_setover a sparse edge type also returns every unrelated node. To get e.g. “ready tasks”, passnode_type: 'Task'so only that type is emitted (dependencies are still followed across types).
# Which tasks can the agent pick up next?
graph.cypher("""
CALL ready_set({relationship: 'DEPENDS_ON', done: 'n.status = "done"'})
YIELD node, dependency_count
RETURN node.id AS id, dependency_count AS deps ORDER BY id
""")
CREATE / INSERT / SET / DELETE / REMOVE / MERGE¶
# CREATE — returns ResultView with .stats
result = graph.cypher("CREATE (n:Person {name: 'Alice', age: 30, city: 'Oslo'})")
print(result.stats['nodes_created']) # 1
# CREATE relationship between existing nodes
graph.cypher("""
MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
CREATE (a)-[:KNOWS]->(b)
""")
# Nodes and relationships in one statement. Endpoints need no variable name,
# and a variable introduced in one comma-separated part is a *reference* in
# every later part of the same CREATE (variable scope ends with the statement).
graph.cypher("CREATE (:Person {name: 'Carol'})-[:KNOWS]->(:Person {name: 'Dan'})")
graph.cypher("""
CREATE (a:Person {name: 'Eve'}), (b:Person {name: 'Frank'}),
(a)-[:KNOWS]->(b), (b)-[:KNOWS]->(a)
""")
# INSERT uses static labels/types. Multiple node labels use `&`.
graph.cypher("""
INSERT (a IS Person&Actor {id: 1}), (b:Person {id: 2}),
(a)-[r IS KNOWS {since: 2020}]->(b)
RETURN labels(a), type(r), r.since
""")
# SET — update properties
result = graph.cypher("MATCH (n:Person {name: 'Bob'}) SET n.age = 26, n.city = 'Stavanger'")
print(result.stats['properties_set']) # 2
# DELETE / NODETACH DELETE error if a node has relationships; DETACH removes all
graph.cypher("MATCH (n:Person {name: 'Alice'}) DETACH DELETE n")
# REMOVE — remove properties (id/type are immutable)
graph.cypher("MATCH (n:Person {name: 'Alice'}) REMOVE n.city")
# MERGE — match or create
graph.cypher("""
MERGE (n:Person {name: 'Alice'})
ON CREATE SET n.created = 'today'
ON MATCH SET n.updated = 'today'
""")
INSERT is deliberately stricter than CREATE. It accepts static node labels
introduced by : or IS, with & between multiple labels, and one static,
directed relationship type. It rejects dynamic labels/types, a dynamic property
map, path assignment, colon-separated multiple labels, relationship type
alternation, and undirected or variable-length relationships. Use CREATE
when one of those CREATE-specific forms is intentional.
Transactions¶
Group multiple mutations into an atomic unit. On success, all changes apply; on exception, nothing changes.
with graph.begin() as tx:
tx.cypher("CREATE (:Person {name: 'Alice', age: 30})")
tx.cypher("CREATE (:Person {name: 'Bob', age: 25})")
tx.cypher("""
MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
CREATE (a)-[:KNOWS]->(b)
""")
# Commits automatically when the block exits normally
# Rolls back if an exception occurs
# Manual control:
tx = graph.begin()
tx.cypher("CREATE (:Person {name: 'Charlie'})")
tx.commit() # or tx.rollback()
DataFrame Output¶
df = graph.cypher("""
MATCH (p:Person)-[:KNOWS]->(f:Person)
WITH p, count(f) AS friends
RETURN p.name, p.city, friends
ORDER BY friends DESC
""", to_df=True)
EXPLAIN¶
Prefix any Cypher query with EXPLAIN to see the query plan without executing it.
Returns a ResultView with columns [step, operation, estimated_rows]:
plan = graph.cypher("""
EXPLAIN
MATCH (p:Person)
OPTIONAL MATCH (p)-[:KNOWS]->(f:Person)
WITH p, count(f) AS friends
RETURN p.name, friends
""")
for row in plan:
print(row)
# {'step': 1, 'operation': 'Match :Person', 'estimated_rows': 500}
# {'step': 2, 'operation': 'FusedOptionalMatchAggregate', 'estimated_rows': 1}
# {'step': 3, 'operation': 'Projection (RETURN)', 'estimated_rows': None}
# {'step': 4, 'operation': 'OptimizerPass fuse_optional_match_count', 'estimated_rows': None}
Cardinality estimates use type_indices counts when available, None otherwise.
After the physical plan rows, EXPLAIN appends an OptimizerPass <name> row
for every optimizer pass that changed the plan. These rows make it possible to
verify a particular rewrite directly; disabling that pass removes its tag.
A ClosureProbe :Person (Student, Teacher) row follows the Match row of any
node pattern whose ontology closure the matcher can answer from the member
types’ indexes instead of a scan — a materialized supertype label in the
closed state, where every live descendant carries an index answering the
pattern’s equality properties, and the row names those members. No row means
no probe: the label is open, a member is unindexed, or nothing is
materialized, and the match falls back to a (still correct) scan. A label
written in WHERE position rather than in the pattern is an ordinary
post-candidate predicate and never probes. The row’s estimated_rows is
Null — the probe returns the value’s row count, which no static model
knows — and a value supplied as a parameter ({p: $v}) leaves the row off
because the plan renders before the parameter binds.
PROFILE¶
Prefix any Cypher query with PROFILE to execute AND collect per-clause statistics.
Returns a normal ResultView with results, plus a .profile property:
result = graph.cypher("""
PROFILE
MATCH (p:Person)
WHERE p.age > 30
RETURN p.name, p.age
""")
# result contains the normal query results
for row in result:
print(row)
# result.profile contains execution stats
for step in result.profile:
print(step)
# {'clause': 'Match :Person', 'rows_in': 0, 'rows_out': 500, 'elapsed_us': 120}
# {'clause': 'Where', 'rows_in': 500, 'rows_out': 200, 'elapsed_us': 45}
# {'clause': 'Projection (RETURN)', 'rows_in': 200, 'rows_out': 200, 'elapsed_us': 30}
For non-profiled queries, result.profile is None.
Diagnostics¶
Every cypher() call attaches lightweight execution diagnostics to the returned ResultView. No prefix required, always on:
result = graph.cypher("MATCH (n:Country {label: 'Norway'}) RETURN n.nid")
print(result.diagnostics)
# {'elapsed_ms': 3, 'timeout_ms': 180000, 'row_limit': None, 'total_rows': None, 'warnings': []}
Keys:
elapsed_ms— wall-clock duration in milliseconds.timeout_ms— the deadline that was in effect, orNonewhen no deadline applied. A fired deadline raisesCypherTimeoutError, so no partialResultViewis ever returned.warnings— non-fatal advisories about the query, empty for a clean one. Each is legal Cypher that quietly returns nothing useful, so it is a warning and not an error — with a “did you mean?” hint where one is genuinely close. Five families:a
MATCHagainst an unknown node label or relationship type (zero rows);a
WHEREon a property no node of that type has (null comparison, so every row is filtered out);a
RETURN/WITH/ORDER BYreading such a property (a silently all-null column — the siblingn.namestill resolves, so the rows read as half-correct);a relationship pattern pointing the wrong way, when every edge of that type runs the other way (zero rows);
a
WHEREcomparison a declared property type makes vacuous —p.age > 'forty'wherep.age IS :: INTEGERis null on every row (the other side may be a literal, a bound$parameter, or a second typed property).
Populated on reads, mutations,
EXPLAIN, and session/transaction queries alike, and mirrored to stderr for interactive users.
result = graph.cypher("MATCH (n:Persn) RETURN n") # typo
result.diagnostics["warnings"]
# ["MATCH references unknown node label 'Persn' — the graph has no such
# type, so this pattern returns no rows. Did you mean 'Person'?"]
graph.cypher("MATCH (p:Port)-[:ARRIVES_AT]->(v:Voyage) RETURN p").diagnostics["warnings"]
# ["MATCH traverses 'ARRIVES_AT' as Port → Voyage, but every 'ARRIVES_AT'
# relationship runs Voyage → Port — this pattern matches no edges.
# Reverse the arrow?"]
graph.cypher("MATCH (v:Vessel) RETURN v.imo").diagnostics["warnings"]
# ["RETURN projects property 'imo' which no Vessel node has — every value
# will be null."]
graph.cypher("MATCH (p:Person) WHERE p.age > 'forty' RETURN p").diagnostics["warnings"]
# ["WHERE compares Person.age (declared INTEGER) with a STRING literal
# 'forty' — a cross-type ordering comparison is null in openCypher, so this
# filters out every row."]
graph.cypher("MATCH (p:Person) WHERE p.age > $cutoff RETURN p",
params={"cutoff": "forty"}).diagnostics["warnings"]
# ["WHERE compares Person.age (declared INTEGER) with a STRING parameter
# $cutoff ('forty') — a cross-type ordering comparison is null in
# openCypher, so this filters out every row."]
The family classifies both operands. The one being compared against the
typed property may be a literal, a $parameter the call actually bound, or
a second property whose type is also known — WHERE p.age > p.email names both
sides and both types. =~ joins STARTS WITH / ENDS WITH / CONTAINS as a
string predicate: it answers false for every non-STRING value whatever the
pattern is.
Two sources of type knowledge feed it, in this order:
REQUIRE p.x IS :: T(below) — the write path enforces it, so a declaredINTEGERproperty cannot come to hold a string and “this comparison is cross-type” is a guarantee rather than a guess.define_schema()field types — declared intent, checked only byvalidate_schema(), so the claim rests on the stored data honouring the declaration.
Where both cover a property the declaration wins — the same precedence the
constraint-vs-lock error messages follow — and each message quotes the
declaration it read in that declaration’s own words: declared INTEGER for
the constraint, schema-defined integer for the schema definition. Every other
type name in a message is the engine’s own, so the casing says which half of the
sentence is yours. Observed per-type metadata (last-write-wins, and not a
declaration) is consulted by neither.
Everything the family cannot place stays silent: numeric properties against
numeric literals (INTEGER/FLOAT are one comparison family, all nine value
pairings intercomparable), DATE/LOCAL DATETIME against a string (the string
is parsed, so whether the answer is null depends on its contents), DURATION
and POINT (no comparison rules of their own), a schema field type it does not
recognise, an unbound parameter, a property pair with one untyped side,
multi-label patterns, WITH-rebound variables and the built-in fields. <>
gets its own wording, because a cross-type <> is true: it matches every row
that has the property rather than filtering them out.
A sparse property never warns: node_type_metadata records a property as
soon as one node carries it, so only a genuinely absent one — a typo, or a
field that belongs to a different type — trips these.
Under lock_schema() the two absent-property families above are errors, not
warnings. A locked schema is the opt-in “catch my typos” mechanism, and it
already rejected MATCH (p:Person {agee: 1}) and MATCH (p:Persn); the same
typo written as WHERE p.agee = 1 or RETURN p.agee now raises SchemaError
with the same “did you mean?” hint rather than returning an empty result or a
null column.
The declared-type family promotes too, but only from its enforced source. A
mismatch against an IS :: T constraint — WHERE p.age > 'forty' where
REQUIRE p.age IS :: INTEGER is in force — raises SchemaError under a lock,
carrying the same sentence the warning would have and the same unlock_schema()
way out. The write path guarantees that declaration, so “no row can answer this
predicate” is a fact about the stored data. A mismatch read from a
define_schema() field type is not promoted, in any schema state: nothing
enforces that declaration at write time, so a lock has no ground to reject the
query on. A property pair promotes only when both of its sides are declared, and
a bound parameter does not block the promotion — the property side is still
enforced and the binding’s type is a fact of that call, so p.age > $cutoff
with a string bound raises while the same statement with an integer bound runs.
unlock_schema() puts every one of them back to a warning. Unknown relationship
type and reversed arrow stay warnings in both states, and every conservatism
above still applies under the lock: sparse
properties, properties the same statement writes, types with no recorded
properties, fields define_schema() declares but nothing has written yet,
multi-label patterns, WITH-rebound variables, the built-ins, and every
comparison the runtime can actually answer are all left alone.
timeout_ms resolution: explicit cypher(..., timeout_ms=N) >
kg.set_default_timeout(ms) > a default of 180,000 ms. Pass
timeout_ms=0 to disable the deadline for one call. The default is
per-surface, and each surface declares its own: the Python API and the MCP
server (cypher_query’s timeout_ms argument) both apply the 180,000 ms
default; the CLI applies none and takes --timeout-ms per call; the Bolt
server applies none, per the Neo4j “absent tx_timeout means no timeout” wire
contract. Expanding, aggregation,
set-operation, subquery-join, procedure, and mutation loops poll cooperatively.
Use Session.execute() or Transaction when a failed/timed-out mutation must
roll back; direct KnowledgeGraph.cypher() writes execute in place.
Parallel runtime¶
One heavy analytical query can use the whole machine. Off by default; opt in per call:
result = graph.cypher(
"MATCH (p:Person) WHERE p.score > 0.5 RETURN p.city AS city, count(*) AS n",
parallel=True,
)
parallel=True is a hint, not an instruction. It never changes an answer:
values, row order, and group order are identical either way. Only operators
that can partition deterministically use it, and each applies its own runtime
gate on candidate count and per-row cost — so a small query stays sequential
however it is flagged, and there is no threshold to tune.
What parallelises:
Scan + filter — the node scan behind
MATCH (n:Label {prop: ...})andMATCH (n:Label) WHERE .... Partitions are contiguous candidate ranges concatenated in candidate order, so the bucket order of an un-ORDER BY’dMATCHis unchanged.Fused scan aggregates —
MATCH (n:Label) [WHERE ...] RETURN <keys>, <aggregates>overcount/count(DISTINCT)/sum/avg/min/max. Groups are emitted first-seen, as always.Grouped aggregation, across groups — each group’s aggregate is computed independently. Within a group nothing changes: rows are still folded in row order, so
collectreturns the same list andmedian/mode/percentile_*keep their per-group tie-breaks.ORDER BYsort keys — the key computation only. The sort itself is stable and stays sequential, so ties keep input order.
What stays sequential, and why:
Row construction and projection. Building a result row allocates, and allocation does not share: partitioning these was measured slower than not, so it is gated by row count rather than by the flag.
The streaming aggregate. Partitioning it would reassociate
sumandavgover floating point and move the last bit of the result. A number that depends on how many cores answered the query is not a number this engine will return.Anything that would change an error. A
LIMITpushed into aMATCH, amax_work_unitsbudget, and a group-limited aggregation all have outcomes that depend on where the sequential scan stopped, so they opt out.
Scope:
Storage modes — memory and mapped fan out. Disk-mode graphs, and graphs with a spatial configuration, ignore the flag and run sequentially rather than refusing it, so code that runs against all three modes is unaffected.
Servers — the Bolt and MCP servers never enable it. A server’s cores belong to its concurrent clients; turning it on there would trade across-query throughput for one query’s latency.
Surfaces — the keyword lives on
KnowledgeGraph.cypher();Session.cypher(),Transaction.cypher()andFrozenGraph.cypher()run sequentially. There is no graph-wide default to set.CLI —
kglite query <graph> "<cypher>" --parallel.Pool width — one shared worker pool, sized from the machine’s available parallelism and built lazily on the first fan-out.
KGLITE_QUERY_THREADS=Npins it; anything that does not parse to a positive integer is ignored.
The runtime gates are measured crossovers, not guesses: a scan or aggregate region fans out at 20,000 candidate rows when every per-row predicate compiles and at 5,000 when one routes through the interpreter, and the projection-shaped regions at 4,096 rows. The count is the real candidate count, never a planner estimate.
Measured on a 1M-node / 11M-edge synthetic graph on a 10-core Apple Silicon machine (4 performance + 6 efficiency cores), release build, minimum of two agreeing runs. Numbers on other hardware will differ, and a machine with fewer performance cores will see less:
Query shape |
Sequential |
Parallel |
|
|---|---|---|---|
Scan + filter + |
34 ms |
6.5 ms |
5.2x |
Scan + filter + grouped aggregate |
68 ms |
13 ms |
5.2x |
Property-filtered scan |
7.6 ms |
1.5 ms |
5.0x |
Interpreted text predicate |
30 ms |
4.6 ms |
6.5x |
Regex ( |
41 ms |
6.9 ms |
6.0x |
Grouped aggregation, few groups |
356 ms |
280 ms |
1.3x |
Grouped aggregation, 800k groups |
480 ms |
440 ms |
1.1x |
|
400 ms |
360 ms |
1.1x |
Scan + filter + 792k-row projection |
132 ms |
122 ms |
1.1x |
The pattern is worth reading: shapes whose cost is scanning gain 5-6x, and
shapes whose cost is building rows gain almost nothing, because the second
kind is bound by the allocator rather than by arithmetic. Reach for
parallel=True on an analytical scan or aggregate over a large graph; it will
not speed up returning a million rows to Python.
Indexes¶
Create an equality index on a (node_type, property) pair so
MATCH (n:T {prop: value}) and WHERE n.prop = value use an indexed lookup
instead of scanning the type:
graph.create_index('Country', 'label')
# {'node_type': 'Country', 'property': 'label',
# 'unique_values': 5, 'persistent': true, 'created': true}
On a storage='disk' graph the index is persistent — written as four mmap’d, SHA-256-addressed files next to the CSR (property_index_v2_{digest}_{meta,keys,offsets,ids}.bin). Versioned metadata stores and validates the exact type/property identity, so punctuation, Unicode, underscores, and case-distinct names cannot overwrite one another. Indexes are lazy-loaded on first query after reopen; legacy filename-based bundles remain readable by exact request. No heap HashMap rebuild occurs on load(). On in-memory graphs the existing property_indices HashMap is used (no change).
describe() annotates indexed properties so agents can see which columns hit the fast path before writing a query. A string index supports both equality and prefix on a storage='disk' graph only — prefix is the sorted mmap’s own capability, and the in-memory hash index annotates eq alone because STARTS WITH full-scans there; numeric indexes support equality only:
<prop name="label" type="String" unique="5" indexed="eq,prefix" vals="Norway|..."/>
<prop name="year" type="Int" unique="20" indexed="eq"/>
STARTS WITH pushdown¶
With a storage='disk' string index in place, WHERE n.prop STARTS WITH 'x' is pushed into the MATCH pattern and served by the prefix side of the sorted mmap:
graph.cypher("MATCH (n:Country) WHERE n.label STARTS WITH 'O' RETURN n.nid")
# O(log N + k) where k is the number of matches
Cypher index DDL¶
Neo4j 5 index DDL runs against KGLite, so a schema-setup script ports unedited. What it builds is not the same thing, because the two engines have different indexes — read this table before assuming a statement did what it does in Neo4j.
CREATE [RANGE] INDEX [name] [IF NOT EXISTS] FOR (n:Label) ON (n.prop [, n.prop2 ...])
DROP INDEX <name> [IF EXISTS]
DROP INDEX FOR (n:Label) ON (n.prop [, ...]) -- KGLite extension
SHOW [ALL] INDEX[ES]
What each statement builds¶
KGLite has three index structures, each serving a different predicate shape.
Neo4j has one RANGE index that serves all of them.
KGLite structure |
Serves |
Equivalent API call |
|---|---|---|
hash equality ( |
|
|
composite hash ( |
conjunctive |
|
B-tree range ( |
|
|
Statement |
Builds |
|
|---|---|---|
|
one hash equality index |
1 |
|
one composite index |
1 |
|
a hash equality index and a B-tree range index |
2 |
The RANGE form builds two structures because that is what Neo4j’s single
RANGE index serves. The bare form deliberately builds only the equality
index, which is where Neo4j 5 (for which bare and RANGE are identical) and
KGLite part ways: doing both for every CREATE INDEX in a ported script would
silently double index memory, and in-memory footprint is this engine’s
product. The divergence costs performance, never correctness — if you need
range acceleration, write CREATE RANGE INDEX.
Index names are canonical, not yours¶
KGLite derives index names from what they index — Label.property, or
Label.(a,b) for a composite. A name in CREATE INDEX <name> FOR … is
accepted but not stored: the persisted .kgl index state is a list of
(label, property) key tuples, so there is nowhere to keep it.
A composite index is keyed by its property names sorted, so declaration
order is not part of its identity: ON (n.city, n.age) and ON (n.age, n.city)
are one index, named L.(age,city), and either spelling drops it.
CREATE INDEX person_email FOR (p:Person) ON (p.email); -- index is created
SHOW INDEXES; -- name is 'Person.email'
DROP INDEX person_email; -- error, explains this
DROP INDEX Person.email; -- works
DROP INDEX FOR (p:Person) ON (p.email); -- also works
DROP INDEX accepts the dotted canonical name unquoted, so SHOW INDEXES
output pastes straight in. DROP INDEX <unknown name> IF EXISTS is a no-op —
truthfully, since no index carries that name — while the bare form errors and
spells the naming rule out, because “person_email doesn’t exist” is baffling to
someone who just created it under that name. The descriptor form
(DROP INDEX FOR …) is a KGLite extension that sidesteps naming entirely.
One canonical name can cover several structures: a property carrying a hash
index, a B-tree index and a BM25 text index shows three SHOW INDEXES rows
sharing a name, distinguished by type (PROPERTY, RANGE, FULLTEXT).
DROP INDEX <name> removes every structure under that name.
SHOW INDEXES¶
A read, so it works on a read-only graph. Returns the same rows and columns as
CALL db.indexes():
Column |
Value |
|---|---|
|
canonical name — |
|
|
|
always |
|
single-element list holding the node type |
|
indexed property names, sorted for a composite |
|
|
|
whether the index is behind the graph. |
|
how many documents (or vectors) the index would re-read to catch up — an upper bound. |
|
|
stale / delta are KGLite-specific and describe the catch-up contract both
opt-in index kinds share: the index does not follow writes eagerly, it records
that they happened and folds them in at query entry while the delta stays under
that index’s auto_refresh_limit — a document count, not a time budget. A
delta above that limit is the signal to rebuild — with
build_text_index(...), or for a vector index with build_vector_index(...) /
refresh_vector_index(...). A text index catching up past its measured
crossover (~1500 documents) rebuilds rather than folding, so no catch-up costs
more than one rebuild.
The two kinds differ in what an over-limit delta serves. A stale text index
returns null for the rows it has no document for, and warns. A stale vector
index is simply stepped over: the query falls back to the exact scan, which is
the oracle the approximate index is measured against, so a stale vector index
costs latency and never accuracy. unembedded is deliberately not folded into
delta, because catch-up indexes vectors that exist and never creates them: a
node with no embedding stays invisible to vector search until embed_texts /
set_embeddings runs. A VECTOR row appears only once an index is built —
embeddings on their own are reported by list_embeddings().
A vector index is listed under its source column (Doc.summary), not the
store name (Doc.summary_emb), so it shares one canonical name with that
property’s other indexes and DROP INDEX Doc.summary removes it along with
them. That drops the accelerator only: the vectors are data, and DDL does not
delete them.
Neo4j 5 also returns id, populationPercent, indexProvider,
owningConstraint, lastRead, and readCount. KGLite has no equivalent
state for any of them, so they are omitted rather than filled with invented
values. For filtering and projection use CALL db.indexes() YIELD … — SHOW INDEXES rejects YIELD / WHERE / BRIEF / VERBOSE rather than accepting
a filter it would ignore.
Forms that are rejected¶
Each fails with a CypherExecutionError naming the construct and the route
that works — never a syntax error, and never a no-op that reports success.
Statement |
Why, and what to use |
|---|---|
|
Neo4j’s TEXT index accelerates |
|
BM25 indexes exist, but Neo4j’s FULLTEXT is multi-label, multi-property and name-addressed while KGLite’s is one node type’s one property, so they are created through |
|
No point index. Spatial predicates and the spatial-join optimiser work on geometry properties without one |
|
Vector indexes exist, but need an existing embedding store and HNSW build parameters, so they are created through |
|
Label and relationship-type lookup is always indexed automatically ( |
|
KGLite indexes node properties only. Relationship properties are queryable, just scanned |
|
No index providers or per-index configuration to apply |
|
The B-tree is single-property. Use a composite equality index, or one |
On disk-backed graphs¶
CREATE INDEX on a storage='disk' graph builds the persistent mmap-backed
index — the same one create_index(...) builds there, not the in-memory
HashMap (which would need multiple GB of heap for a large type and be rebuilt on
every load). Two consequences:
Persistent property indexes cover string columns. A statement that indexes nothing on a populated node type is rejected rather than reported as succeeding, so a numeric property gets an error naming the reason. Use an in-memory or mapped graph if you need to index a non-string property.
SHOW INDEXESandCALL db.indexes()list the in-memory index structures, so a disk graph’s persistent indexes do not appear there yet. The index is real and the planner consults it; only this listing is incomplete. Re-runningcreate_index(...)reportscreated=Falsefor an installed persistent index.
CREATE RANGE INDEX and composite CREATE INDEX build in-memory structures in
every storage mode.
Guards¶
Schema is graph state, so index DDL is a mutation: it is blocked on a
read-only graph (read_only(True)), blocked in a read-only transaction, and
rolled back with the rest of a failed statement. On a schema-locked graph,
indexing an undeclared property is rejected — the same typo-guard writes get.
A schema command is a standalone statement: it cannot follow another clause or
appear inside a CALL { } body. An index belongs to one node type, so
write_scope=[...] applies: CREATE INDEX / DROP INDEX on a type outside the
whitelist is a scope violation. SHOW INDEXES is unaffected — a write scope
restricts mutations, not visibility.
indexes_added and indexes_removed join graph.last_mutation_stats,
mirroring Neo4j’s indexesAdded / indexesRemoved summary counters.
Cypher constraint DDL¶
Neo4j 5 constraint DDL runs against KGLite and routes to real per-write
enforcement — Cypher CREATE / INSERT / MERGE / SET / REMOVE and the bulk
loader (add_nodes, blueprints, from_records, WAL replay). A declaration is
not documentation: once it succeeds, a violating write is rejected.
CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:Label) REQUIRE n.prop IS UNIQUE
CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:Label) REQUIRE (n.a, n.b) IS UNIQUE
CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:Label) REQUIRE n.prop IS NOT NULL
CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:Label) REQUIRE n.prop IS NODE KEY
CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:Label) REQUIRE n.prop IS :: INTEGER
CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR ()-[r:TYPE]-() REQUIRE r.prop IS NOT NULL
CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR ()-[r:TYPE]-() REQUIRE r.prop IS :: INTEGER
DROP CONSTRAINT <name> [IF EXISTS]
SHOW CONSTRAINTS
The Neo4j 4 ASSERT spelling is accepted in place of REQUIRE, so a 4.x-era
script ports without edits. The optional NODE / RELATIONSHIP scope word
before UNIQUE / KEY is accepted too, but it must agree with the FOR
pattern: FOR (n:Label) … IS RELATIONSHIP KEY asks for two different
constraints in one statement and is refused rather than resolved in favour of
one half. Unscoped IS UNIQUE / IS KEY mean “whatever this pattern targets”
and are legal against either.
What each form enforces¶
Statement |
Enforces |
Backed by |
|---|---|---|
|
no two nodes of the type share |
a single-occupant unique index |
|
no two nodes share the tuple |
one composite unique index |
|
every node of the type has a non-null |
the node type’s required-field list |
|
both of the above, at once |
a unique index plus a required field |
|
every value written to |
a declared per-property type, checked before the write lands |
|
every relationship of the type has a non-null |
a declared per-connection-type presence rule |
|
every value written to |
a declared per-connection-type property type |
A constraint declared over several properties constrains the combination, not
each property — REQUIRE (n.city, n.age) IS UNIQUE permits many nodes in the
same city as long as no (city, age) pair repeats.
NULL is exempt, matching Neo4j: a node is outside a uniqueness constraint
unless every property in the tuple is present and non-null. So many nodes may
share “no email” while email is UNIQUE.
IS NODE KEY is served as the conjunction of uniqueness and presence, and the
two halves are installed atomically — if the presence half cannot be declared,
the uniqueness half is rolled back, so a statement that reported failure has
changed nothing. DROP CONSTRAINT on a node key likewise withdraws both halves.
A node type’s declared primary key is not droppable through DDL.
define_schema({"nodes": {"User": {"primary_key": "email"}}}) shows up in
SHOW CONSTRAINTS as a NODE_KEY row, but it is one declaration owned by the
schema rather than a DDL constraint with a store of its own, so a
DROP CONSTRAINT naming User.email is refused — IF EXISTS included, because
the key exists and is enforced; it is simply not droppable here. Withdraw it by
re-declaring the type without a key (define_schema({"nodes": {"User": {}}}))
or with clear_schema(). Every other constraint on a keyed type — including a
composite tuple that contains the key property — is its own declaration and
drops normally.
Withdrawing the key withdraws only the key. A CREATE CONSTRAINT … IS UNIQUE declared on the same property is a separate declaration that happens to
share the key’s index: while both stand the row reads NODE_KEY under the DDL
name, and re-declaring the type without a key leaves that row in force as
UNIQUENESS until DROP CONSTRAINT withdraws it. Declaring it the other way
round is refused as a duplicate — the key already enforces it — so a property
never carries two live uniqueness declarations.
Relationship constraints¶
A constraint on a relationship is written against a relationship pattern, and serves the two kinds that do not depend on relationship identity:
CREATE CONSTRAINT knows_since FOR ()-[r:KNOWS]-() REQUIRE r.since IS NOT NULL
CREATE CONSTRAINT FOR ()-[r:KNOWS]-() REQUIRE r.since IS :: INTEGER
DROP CONSTRAINT knows_since
Everything the node forms promise holds here. Declaring one validates it
against every existing relationship of the type and refuses, installing
nothing, if the data already violates it. Once installed it is enforced on
CREATE / INSERT (and MERGE’s create branch), SET r.p in all three spellings
(SET r.p = v, SET r = {…}, SET r += {…}), REMOVE r.p, and the bulk
add_connections / replace_connections loaders. A refused write changes
nothing — no relationship, no connection-type metadata, and no entry in the
change-capture stream. Declarations survive save() / load().
The pattern’s direction and endpoints are ignored: ()-[r:T]-(),
()-[r:T]->() and ()<-[r:T]-() all declare the same constraint on the
connection type, exactly as they do for Neo4j.
A bulk row is judged on the state it will actually leave behind, which is not
always the row: under conflict_handling='preserve' a value the stored
relationship already has is discarded and therefore never refused, and under
'sum' an addition that turns an integer into a float is refused even
though both operands satisfy the constraint on their own. A frame is refused
whole rather than row-by-row, and replace_connections raises the refusal
before its delete, so a rejected frame never costs the relationships already
stored.
IS UNIQUE and IS RELATIONSHIP KEY on a relationship are refused. This is
a data-model gap, not a missing feature flag: the bulk loader deduplicates
(type, source, target) while Cypher CREATE freely makes parallel edges, so
KGLite has no single answer for when two relationships of a type are the same
one — and a uniqueness declaration would mean different things depending on
which write path produced the data. The refusal names that reason.
Property-type constraints (IS :: T)¶
REQUIRE n.prop IS :: TYPE — and the equivalent IS TYPED TYPE spelling —
declares the type every value written to that property must have. It is checked
on the same three write paths as every other constraint, before the value lands.
The accepted type names are the Neo4j 5 names with an exact KGLite value counterpart:
Declared |
Accepts |
|---|---|
|
|
|
strings |
|
integers, including auto-assigned node ids |
|
floats only — an integer does not satisfy |
|
a calendar date (no time of day) |
|
a date and wall-clock time, with no timezone |
|
a duration value |
|
a point value |
Anything else — LIST<...>, unions, ZONED DATETIME / LOCAL TIME /
ZONED TIME, and decorated forms like STRING NOT NULL — is rejected by
name, and the error lists the names that do work. This is deliberate: KGLite
has no timezone-aware or time-of-day-only value, so accepting ZONED DATETIME
would promise a type it cannot represent, and a constraint that enforces
something other than what was written is worse than one that was refused.
For those shapes use define_schema() + validate_schema(), or lock_schema().
A type constraint is not an existence constraint. As in Neo4j, null and an
absent property both satisfy it, and REMOVE n.prop is not a violation. Declare
IS NOT NULL alongside it when a value is also required:
CREATE CONSTRAINT age_typed FOR (p:Person) REQUIRE p.age IS :: INTEGER;
CREATE CONSTRAINT age_present FOR (p:Person) REQUIRE p.age IS NOT NULL;
A property carries at most one declared type: re-declaring it with a different
one is rejected, so change it with DROP CONSTRAINT then a fresh declaration.
Where a declared type and a lock_schema()-recorded property type both cover a
property, the declaration wins and the error names the constraint you wrote.
Declaring against dirty data fails¶
A constraint the stored data already violates is rejected, and nothing is installed — a constraint that silently exempts the rows already present would be worse than no constraint at all. The error names an offending value:
cannot declare a UNIQUE constraint on Person.email: the existing data already has
2 duplicate values (for example 'email' = 'a@b.c'). Deduplicate the node type
before declaring the constraint.
Deduplicate (or populate, for IS NOT NULL) and retry.
Constraint names are stored¶
This is the opposite of index names, and deliberately so: a ported schema script almost always names its constraints and drops them by name, and there is no descriptor form to fall back on.
CREATE CONSTRAINT person_email_unique FOR (p:Person) REQUIRE p.email IS UNIQUE;
SHOW CONSTRAINTS; -- name is 'person_email_unique'
DROP CONSTRAINT person_email_unique; -- works
A constraint declared without a name gets the canonical descriptor
Label.property (or Label.(a, b)), and DROP CONSTRAINT accepts that spelling
too, so SHOW CONSTRAINTS output pastes straight in. Names are unique per graph:
reusing one for a different constraint is an error rather than a silent
re-pointing. Names persist in the .kgl file and survive save/load; a name whose
constraint has been dropped is discarded at save time, so it cannot resurrect.
The registry is a lookup aid, never the source of truth — the constraint lives in the enforcement structure — so a missing name can cost addressability but never enforcement.
SHOW CONSTRAINTS¶
A read, so it works on a read-only graph and is unaffected by a write scope.
Returns the same rows and columns as CALL db.constraints():
Column |
Value |
|---|---|
|
the declared name, or the canonical descriptor when unnamed |
|
|
|
|
|
single-element list holding the node type — or the relationship type on a relationship row |
|
constrained property names, in declaration order |
|
the declared type on a |
A node key is one row (NODE_KEY), not a uniqueness row plus an existence
row. A declared property type is its own row, never folded into another: a
property can be UNIQUE and typed. Neo4j 5 also returns id and
ownedIndex; KGLite has no equivalent state for either — a unique constraint
is its index rather than owning a separate one — so they are omitted
rather than filled with invented values. For filtering and projection
use CALL db.constraints() YIELD …; SHOW CONSTRAINTS rejects YIELD / WHERE
/ BRIEF / VERBOSE rather than accepting a filter it would ignore.
Forms that are rejected¶
Each fails with a CypherExecutionError naming the construct and the route that
works — never a syntax error, and never a success that enforces nothing.
Statement |
Why, and what to use |
|---|---|
|
Only the type names with an exact KGLite value counterpart are accepted (see Property-type constraints). Lists, unions, zoned temporal types and decorated forms are refused by name rather than approximated; the error lists the names that work. |
|
Uniqueness over the identity field — under any spelling that resolves to it, |
|
KGLite has no single answer for when two relationships of a type are the same one — the bulk loader deduplicates |
|
Nothing to constrain |
|
Names are unique per graph; drop the existing one or choose another name |
Refusing an unmappable type name, rather than approximating it, is the most
important decision in this surface. A CREATE CONSTRAINT that returns cleanly is
a promise users build data-integrity assumptions on; keeping that promise honest
is worth an error.
Guards¶
Schema is graph state, so CREATE CONSTRAINT / DROP CONSTRAINT are
mutations: blocked on a read-only graph (read_only(True)), blocked in a
read-only transaction, and rolled back with the rest of a failed statement. On a
schema-locked graph, constraining an undeclared node property is rejected — the
same typo-guard writes get; on the relationship side the lock gates the
connection type but not the property, because the lock does not check edge
property names on write either. A node constraint belongs to one node type, so
write_scope=[...] applies to it. A relationship constraint is allowed under
any write scope: scopes name node types, and there is no relationship spelling
for one to name. A schema command is a standalone statement: it
cannot follow another clause or appear inside a CALL { } body.
SHOW CONSTRAINTS is unaffected by all of the above except the standalone rule —
a write scope restricts mutations, not visibility.
constraints_added and constraints_removed join graph.last_mutation_stats,
mirroring Neo4j’s constraintsAdded / constraintsRemoved summary counters.
They count constraints, not the structures behind them, so IS NODE KEY
reports 1.
One caveat on error types¶
A constraint violation raised through Cypher arrives as CypherExecutionError,
not as the typed ConstraintViolationError that define_schema raises. The
Cypher executor’s internal error channel is a string, so the structured violation
is rendered to text before any binding sees it. The message is stable and names
the constraint, the property, and the offending value — match on that. Enforcement
is identical either way.
Ontology (declared semantic layer)¶
Declared with the Python define_ontology() (classes with an is_a forest,
abstract supertypes, and relationship semantics), persisted with the graph,
and read-only from Cypher:
SHOW ONTOLOGY
CALL ontology_audit() YIELD entity_kind, rule, severity, violations, exempted, total, pct, domain_class, property
CALL ontology_audit({by: 'domain_class'}) YIELD rule, domain_class, violations -- per violating class
CALL ontology_audit({by: 'property'}) YIELD rule, property, violations, pct -- per declared property
CALL type_domain_violation() YIELD source, target, rule -- no-arg: checks every declaration
CALL edge_property_violation() YIELD relationship, check, source, target, property, properties, exempt
CALL node_property_violation() YIELD class, check, node, property, properties
SHOW ONTOLOGY returns one row per declared class (kind, name, is_a,
abstract, description, required_properties, property_types, enforcement)
and relationship (kind, name, domain,
range, required_properties, property_types, enforcement, exempt,
description); zero rows when nothing is
declared. ontology_audit() is the scorecard: one row per declared check with
its violation count, denominator, percentage, and declared severity. entity_kind
is node for class contracts and edge for relationship checks. Severity is
(advisory / warn / error — acted on by blueprint builds, reported
everywhere else). exempted counts the rows a declaration’s exempt classes
excuse: they are left out of violations (and so out of the severity the gate
acts on), and violations + exempted is everything the check flagged.
{by: 'domain_class'} answers the usual follow-up — which source types are
violating — by fanning each rule’s row out into one row per primary node type
its violations come from. violations and pct are then that class’s share
(they sum back to the rule’s aggregate), while severity, exempted and
total keep their per-rule values on every fanned row. Exempted rows are left
out, so a class whose every violation is excused gets no row at all; a rule
with no violations to break down keeps its single aggregate row. Without the
parameter, domain_class is Null on every row. The domain-side class is the
edge’s source for relationship domain / range / required_properties /
property_types, the node itself for required / cardinality, and for the
pair/triple shapes (inverse, symmetric, transitive) the first bound
node — the source of the edge or chain whose partner is missing. Class property
contracts partition by the violating node’s primary type.
{by: 'property'} answers the other follow-up — which fields are missing —
by fanning the required_properties and property_types rules into one row
per declared property: violations counts the nodes or edges failing that
property, total the rule’s covered nodes or relationship edges, and pct the
share failing it. Every other rule
keeps its aggregate row with a Null property.
The two breakdowns are different kinds of answer, and mixing them up
double-counts. domain_class partitions a rule: every violating row has
exactly one source class, so the rows sum back to the aggregate.
property is a census: a node or edge missing three declared properties is
counted under all three, so the rows sum to at least the aggregate and
adding them up is not the rule’s violation count. A declared property nothing
fails still gets a row, at zero — “this field is complete” is what a census is
for. Only one axis applies at a time; the column you did not ask for is Null.
edge_property_violation() lists the individual edges behind the
required_properties and property_types counts (the two declared checks
with no rule procedure of their own): one row per flagged edge, properties
listing every declared property it fails, property the first of them, and
exempt marking the rows an exempt declaration excuses — so a
relationship’s row count for a check equals that rule’s
violations + exempted however many properties one edge fails. UNWIND the
list to count per property. It takes no arguments.
node_property_violation() is the corresponding no-argument class-contract
drill-down. Each row names the declaring class, check, node and failed properties;
inherited contracts apply through the node’s primary class ancestry. It has no
edge endpoints or exemption column. Required properties reject missing/null values;
type checks use the shared property-type grammar, including outer list/array.
The six declaration-backed rule procedures (type_domain_violation,
type_range_violation, missing_required_edge, cardinality_violation,
inverse_violation, transitivity_violation) called with no arguments
iterate the declarations instead of erroring, and every row carries a rule
column naming the declaration it came from. A domain/range naming an
abstract class widens to the class plus its declared descendants — the
union-endpoint case (HAS_OPERATOR from = six concrete types) a flat schema
cannot declare. Semantics are annotations, not axioms: the ontology never
changes what a MATCH returns, cardinality/required describe outgoing
edges of the domain type, and the layer is deliberately distinct from
set_parent_type (presentation ownership for describe() tiering).
transitive: true and ancestry: true are mutually exclusive annotations of
the same hierarchy shape and mean different things. transitive enrolls
transitivity_violation, which audits a stored closure — every a→b→c
needs a stored a→c edge — so a taxonomy holding only parent pointers
reports 100% violations. ancestry enrolls no check: it records that the
chain is meaningful and is walked with *1.., which is what a parent-pointer
taxonomy (STRAT_PARENT, wdt:P279) is.
materialize_ontology() (Python) stamps each declared ancestor as a real
secondary label, after which MATCH (p:Person) matches every descendant with
ordinary label semantics. On such a label EXPLAIN marks the per-member index
probe with a ClosureProbe row.
Timeseries Functions¶
Query time-indexed numeric data attached to nodes. All date arguments are strings ('2020', '2020-2', '2020-2-15'), and precision is validated against the data’s resolution.
Date-string syntax¶
String |
Depth |
Matches resolution |
|---|---|---|
|
year |
year, month, day |
|
month |
month, day |
|
day |
day only |
Precision rule: Query depth must be ≤ data resolution for range functions (ts_sum, ts_avg, etc.). For exact-lookup functions (ts_at), query depth must equal the data resolution. Querying with day precision on month-resolution data produces an error.
Functions¶
Function |
Arguments |
Returns |
Description |
|---|---|---|---|
|
1 |
Float |
Sum of all values |
|
2 |
Float |
Sum within prefix range |
|
3 |
Float |
Sum in range [start, end] inclusive |
|
1-3 |
Float |
Average (same range rules as ts_sum) |
|
1-3 |
Float |
Minimum value in range |
|
1-3 |
Float |
Maximum value in range |
|
1 |
Integer |
Count of non-NaN values |
|
2 |
Float/null |
Exact key lookup (depth must match resolution) |
|
1 |
Float/null |
First non-NaN value in series |
|
1 |
Float/null |
Last non-NaN value in series |
|
3 |
Float/null |
Value at ‘to’ minus value at ‘from’ (prefix match) |
|
1-3 |
List |
Extract |
NaN values are skipped in all aggregation functions.
Examples¶
# Aggregate monthly data by year
graph.cypher("MATCH (f:Field) RETURN f.title, ts_sum(f.oil, '2020') AS prod")
# Range across months
graph.cypher("MATCH (f:Field) RETURN ts_avg(f.oil, '2020-1', '2020-6') AS h1_avg")
# Multi-year range
graph.cypher("MATCH (f:Field) RETURN ts_sum(f.oil, '2018', '2023') AS total")
# Exact month lookup
graph.cypher("MATCH (f:Field) RETURN ts_at(f.oil, '2020-3') AS march_prod")
# Change between two time points
graph.cypher("MATCH (f:Field) RETURN ts_delta(f.oil, '2019', '2021') AS change")
# Top producers
graph.cypher("""
MATCH (f:Field)
RETURN f.title, ts_sum(f.oil, '2020') AS prod
ORDER BY prod DESC LIMIT 10
""")
# Filter by production threshold
graph.cypher("""
MATCH (f:Field)
WHERE ts_sum(f.oil, '2020') > 100.0
RETURN f.title, ts_sum(f.oil, '2020') AS prod
""")
# Extract full series for plotting
graph.cypher("MATCH (f:Field {title: 'TROLL'}) RETURN ts_series(f.oil, '2015', '2020') AS data")
# Latest reading
graph.cypher("MATCH (s:Sensor) RETURN s.title, ts_last(s.temperature) AS latest")
Precision validation¶
# OK: year query on month data (coarser → aggregates all months)
graph.cypher("MATCH (f:Field) RETURN ts_sum(f.oil, '2020')")
# OK: month query on month data (exact match)
graph.cypher("MATCH (f:Field) RETURN ts_at(f.oil, '2020-3')")
# ERROR: day query on month data (finer than data resolution)
graph.cypher("MATCH (f:Field) RETURN ts_sum(f.oil, '2020-3-15')")
# → "Query precision 'day' (depth 3) exceeds data resolution 'month' (depth 2)"
# ERROR: year query with ts_at on month data (depth must match for exact lookup)
graph.cypher("MATCH (f:Field) RETURN ts_at(f.oil, '2020')")
# → "Exact lookup requires 2 date components for 'month' resolution, got 1"
Naming — identifiers, reserved words & structural accessors¶
Reserved keywords as names (soft keywords)¶
Most reserved keywords can be used directly as a relationship type, node label, or property key — the parser treats them as names in those positions:
CREATE (s:SourceDoc)-[:CONTAINS]->(c:Chunk) // CONTAINS as a rel type
MATCH (n:CONTAINS) // … as a label
CREATE (n:Doc {order: 1, in: true}) // … as property keys
RETURN n.contains, n.order // … and in property access
Keyword names are case-preserving: the stored name is the exact source
spelling ({order: 1} stores the key order; [:contains] creates the
relationship type contains), and names are case-sensitive — n.ORDER and
n.order are different keys. (Releases up to 0.12.14 canonicalized keyword
names to uppercase, so a graph written by an older release may store ORDER
where a lowercase source now reads order; backticks reach the old spelling.)
The soft set covers the operator / comparison / sort / set / mutation
keywords (CONTAINS, IN, IS, NOT, STARTS, ENDS, ORDER, BY,
ASC, DESC, DISTINCT, ALL, MERGE, CREATE, DELETE, SET,
REMOVE, UNION, …), and the value literals TRUE, FALSE and NULL
— openCypher spells a schema name as SchemaName = SymbolicName | ReservedWord, so those three are legal labels, relationship types and
property keys:
CREATE (:TRUE {null: 1})-[:FALSE]->(:Thing)
MATCH (n:TRUE {null: 1})-[:FALSE]->() RETURN n.null
Position decides. The same word in a value position is still the
literal, and nothing about that changed: {x: true} is a boolean property,
WHERE n.x = true is a boolean comparison, RETURN null is null. Only the
name positions — after the : of a label or relationship type, after the |
of a type alternation, and the key side of a property map — read them as
names.
The remaining reserved words are the clause-flow keywords (MATCH, WHERE,
RETURN, WITH, AND, OR, …) and the value-expression keywords
(CASE/WHEN/END, EXISTS). For any of those, quote it with
backticks:
CREATE (n:Doc {`where`: 1})
RETURN n.`where`
Variables are the exception, in both directions. TRUE, FALSE and
NULL are not accepted as bare variable names (MATCH (true:Thing) is a
syntax error) — openCypher’s Variable = SymbolicName excludes them, and a
bare true in an expression is the literal, so such a variable could never be
read back. Backticks make it an ordinary variable:
MATCH (`true`:Thing) RETURN `true`.id
Identifier charset & special characters (hyphens, dots, spaces)¶
A bare identifier (label, relationship type, property key, variable) must
match [A-Za-z_][A-Za-z0-9_]* — a letter or underscore followed by letters,
digits, or underscores. Anything outside that set — a hyphen, dot, space, or
leading digit — must be backtick-quoted. This matters most for
relationship types like supports-claim or refines-idea: written bare, the
- is parsed as the relationship-arrow token and you get a syntax error, so
backtick them:
// Hyphenated / dotted / spaced rel types and labels — backtick them:
CREATE (a)-[:`supports-claim`]->(b)
MATCH (a)-[r:`refines-idea`]->(b) RETURN a, b
MATCH (n:`Legal Document`) RETURN n
RETURN n.`dc.title`
A backtick inside a quoted identifier is written as two backticks (openCypher’s escape), so every name is representable and a quoted identifier cannot be terminated early:
CREATE (:`We``ird`) // the label We`ird
RETURN n.`dc``title` AS t // the key dc`title
This matters beyond exotic labels: if you build a query by interpolating a
label, relationship type, property key, alias or variable, double every
backtick in it (name.replace("”, “")`) before wrapping it in backticks. Without doubling, a name carrying a backtick closes its own quote and the rest of it is read as grammar — a caller passing Person) DETACH DELETE n //`` as a label emitted a query that deleted every Personand reported a count. Values never need this: pass them asparams=` and they can never become
syntax.
The string-typed APIs do not need escaping — they take the type/label as a
plain string, so add_connections(df, "supports-claim", …),
add_nodes(df, "Legal Document", …), and create_index("Doc", "dc.title")
all accept arbitrary characters directly. Backticks are only a Cypher-surface
concern: escape when you name such a type/label/key inside a query, not when
you create it through the Python API.
Structural accessors vs stored properties¶
Every node answers four convenience accessors:
Accessor |
Returns |
|---|---|
|
the node’s unique id (identity — always) |
|
the node’s title (identity — always) |
|
the node’s primary type string |
|
the node’s title |
n.type / n.node_type / n.label / n.name are property-first: if the
node stores a real property of that name, n.<name> returns the stored
value; the structural string is only the fallback when no such property
exists. So a label / type / name column loaded via add_nodes (or set
via CREATE) round-trips and reads back correctly. id and title are the
node’s identity fields and always return the identity (no stored property can
shadow them). Use labels(n) for the label set and id(n) / type(r) for
the structural forms regardless of any same-named property.
Identity (id) and prefixed-id datasets (nid)¶
id(n) reads the node’s id field, not an internal identifier. In Neo4j
and most other Cypher implementations id(n) returns an engine-assigned
integer that has nothing to do with your data; in kglite it returns the source
data’s own key — exactly what n.id returns. Two consequences worth planning
around: it is only as stable across a rebuild as the source key is (a rebuild
that renumbers your keys renumbers id(n)), and it is not a positional
index into anything — treating the value as a row offset or an array position
answers the wrong rows, silently, because the wrong rows are still real nodes.
Use it as a key, join on it, and let labels(n) / type(r) cover the
structural questions.
n.id is the node’s indexed logical identity and behaves identically in every
storage mode (in-memory / mapped / disk). CREATE (n {id: X}) and
add_nodes(unique_id_field='id') both make X the identity; MATCH (n {id: X})
finds it; it survives save → load. id is unique by convention — if duplicate
ids are created, MATCH (n {id: X}) returns one node per id (a stderr warning
is emitted; use MERGE or dedupe the input). Precisely: one node per
(type, id) — an unlabeled {id: X} (or WHERE id(n) = X) anchors through
every type’s id index and returns one node per type holding X, in
deterministic order, identically for literal and $param spellings. With
intra-type duplicates present, the anchored plan still answers one node per
type while disable_optimizer=True filters every duplicate — the documented
one-per-id collapse, only observable on graphs already emitting the
duplicate-id warning. To audit a type for collisions
after the fact, CALL duplicate_id({type: 'Artifact'}) YIELD node yields every
node of that type whose id is shared (the identity-column sibling of
duplicate_title).
A node type loaded with its own column names — add_nodes(df, 'Person', 'person_id', 'person_name') — accepts either spelling on the write path,
not just on reads. CREATE (:Person {person_id: 99, person_name: 'Cleo'})
makes 99 the identity and 'Cleo' the title; the values are promoted into
those fields rather than also stored as properties, so p.person_id and
properties(p)['person_id'] cannot disagree. MERGE matches and creates
through the same resolution. SET / REMOVE on the title spelling write the
title; on the id spelling they are refused — the identity is immutable under
every spelling, exactly as SET n.id is. Giving one CREATE both id and the
type’s own id column with different values is refused rather than resolved
one way.
Durable graphs refuse a duplicate id. With durable= (or a durable
Session), a CREATE whose (type, id) already exists fails: the
write-ahead log names every entity by that pair, so a second node under a live
id cannot be recovered — replay would merge the two and lose one. Use MERGE
to upsert, pick a distinct id, or declare a primary_key to get the same
enforcement in every mode. Without a log the documented opt-in behaviour above
is unchanged.
For datasets whose ids are a prefix + number (Wikidata Q42, P31, …), the
loader stores the integer as id (compact, identical across modes — disk
needs it at 100M-node scale) and the string form as the nid property.
Query by the string form via {nid: 'Q42'} (or by the integer via {id: 42})
— {id: 'Q42'} does not match (ids are integers). n.id → 42,
n.nid → 'Q42', in every mode.
Selected syntax summary¶
This compact table is a non-exhaustive orientation aid. The versioned, machine-checked source of truth is the Cypher Dialect Contract below; do not infer absence from this shorter list.
Category |
Supported |
|---|---|
Clauses |
|
Schema DDL |
|
Patterns |
Node |
WHERE |
|
RETURN |
|
Aggregation |
|
Expressions |
|
Functions |
|
String |
|
Math |
|
Spatial |
|
Temporal |
|
Semantic |
|
Timeseries |
|
Mutations |
|
Procedures |
|
Scoped algorithms |
|
Schema |
|
Rule procedures |
|
Operators |
|
Cypher Dialect Contract¶
The machine-readable source of truth is
tests/api-baselines/cypher-dialect.json.
KGLite is not a complete openCypher or Neo4j implementation.
Covered below means the stated behavior is implemented and locked by local,
independently authored tests. It is not a claim that every grammar production
or edge case from an external conformance suite is implemented. Partial
names a useful implementation with a known gap; Extension is outside the
claimed openCypher-compatible subset.
Clauses¶
Clause |
Status |
Notes |
|---|---|---|
|
Partial |
Node, relationship, variable-length, shortest-path, abbreviated ( |
|
Covered |
Null-extending optional patterns |
|
Covered |
Predicates preserve three-valued boolean, membership, and quantifier semantics |
|
Covered |
Standalone row filter; equivalent to |
|
Covered |
Aliases, |
|
Covered |
Terminal clause that preserves completed side effects and returns no rows |
|
Covered |
Projection, grouping, standalone |
|
Covered |
Multi-column, |
|
Covered |
|
|
Covered |
List expansion, works with |
|
Covered |
|
|
Covered |
Nodes, relationships, inline properties |
|
Covered |
Cypher 25 static insertion: node labels use |
|
Covered |
Property/label assignment plus |
|
Covered |
|
|
Covered |
Property and secondary-label removal |
|
Covered |
|
|
Extension |
KGLite-specific structured plan output |
|
Extension |
KGLite-specific per-clause execution statistics |
|
Extension |
Post-aggregation filter on |
|
Extension |
Namespaced KGLite procedures plus |
|
Partial |
Per-input-row read subqueries with legacy importing |
|
Covered |
Updating bodies, including nested |
|
Partial |
|
Expressions & Operators¶
Feature |
Status |
Notes |
|---|---|---|
Arithmetic ( |
Covered |
Numeric arithmetic plus list/list and element/list composition |
String concat ( |
Extension |
Auto-converts non-strings |
Comparison ( |
Partial |
Scalar comparisons, null propagation and cross-type ordering follow openCypher: |
Boolean ( |
Covered |
Predicate and expression positions preserve three-valued results |
|
Covered |
Also works as expressions in RETURN/WITH |
|
Covered |
Null operands and null-containing no-match lists preserve unknown; an empty list is |
|
Covered |
|
|
Covered |
Full-string match, per openCypher: the pattern must match the entire value, so |
|
Covered |
Simple and generic forms |
Parameter references ( |
Covered |
In WHERE, pattern properties, and expressions |
List comprehensions ( |
Covered |
|
List slicing ( |
Covered |
Open-ended, negative indices |
List quantifiers ( |
Covered |
Decisive results short-circuit; otherwise unknown propagates |
|
Covered |
Brace |
Map projections ( |
Covered |
|
Map literals ( |
Covered |
|
Variable binding in pattern properties |
Covered |
|
Window functions ( |
Extension |
|
Scalar & Aggregation Functions¶
Function |
Status |
Notes |
|---|---|---|
|
Covered |
With |
|
Covered |
|
|
Covered |
|
|
Extension |
Standard deviation helper |
|
Covered |
|
|
Intentional divergence |
|
|
Covered |
Strings (in characters, not bytes), lists, and paths |
|
Covered |
Returns relationship type |
|
Covered |
KGLite logical node identity and stable relationship identity |
|
Intentional divergence |
Primary type first, then secondary labels |
|
Covered |
Returns property names, sorted; the map form covers |
|
Partial |
KGLite’s temporal value model and documented arithmetic subset; a date and a datetime are intercomparable and equatable, a date being midnight on that date |
|
Covered |
|
|
Covered |
Inclusive integer range |
|
Covered |
|
|
Covered |
Exact node order, relationship identity, properties, and traversal direction are preserved for parallel and incoming paths |
String functions |
Covered |
|
Math functions |
Intentional divergence |
|
Trig functions |
Covered |
|
Spatial functions |
Extension |
KGLite’s pragmatic |
Temporal functions |
Extension |
|
Extensions and canonical names¶
Custom callable features have canonical kglite.* spellings. Historical flat
names remain accepted and execute the same AST, so existing applications keep
all functionality while new integrations can distinguish extensions from the
compatible subset.
kglite.pagerank and related procedures(legacy: flat procedure names)kglite.text_score / kglite.vector_score / kglite.score_fuse(legacy: flat function names)kglite.geom_* and KGLite spatial helperskglite.ts_*kglite.text_*HAVINGand window functions usingOVERINTERSECT / EXCEPTEXPLAIN / PROFILE
Architectural Differences from Neo4j¶
Feature |
KGLite |
Neo4j |
Rationale |
|---|---|---|---|
Labels per node |
One primary type + secondary labels |
Multiple equal labels |
Primary type drives indexing ( |
|
|
|
Matches Neo4j since 0.10.5 |
|
Supported (adds a secondary label) |
Supported |
Primary type is immutable; changing it requires node migration/recreation |
Storage |
In-memory, mmap-backed, or disk CSR |
Disk-based |
One Cypher engine spans all three embedded storage modes |
Transactions |
Snapshot isolation + OCC through |
Server/embedded transaction management |
Native session coordination is binding-independent; direct graph writes are in-place |
Indexing |
Three separate structures — hash equality, composite, B-tree range — plus automatic type indexes and vector indexes |
One general |
An equality index cannot serve a range predicate, so KGLite exposes the distinction that Neo4j collapses. |
Index names |
Canonical and derived: |
User-assigned, unique |
A name in |
Constraint DDL |
|
|
|
Constraint names |
Stored, so |
User-assigned, unique per database |
The opposite decision to index names above: a ported schema script almost always drops constraints by name, and the |
|
Local files only — |
|
The engine ships no HTTP client (network dependencies were removed in 0.14.x), so there is nothing to fetch a URL with. Filesystem access is granted per caller: on for in-process use, off for Bolt clients unless the server was started with |
Vector retrieval diagnostics¶
Ordinary query results and PROFILE carry diagnostics.retrieval: distinct
executed vector ranking routes, with requested_policy (auto, exact, or
per_row), actual_mode (hnsw or exact), fallback_reason, and an optional
store (Type.embedding_property). Reasons include forced_exact, no_index,
stale_index, metric_mismatch, filtered_underfill, row_coverage,
row_dependent_selectors, and ordering_requires_exact. A missing store means
that execution did not establish one common store. Identical nested routes are
coalesced; these records are not counters. EXPLAIN describes the requested
policy only, without running search or claiming an actual route.
Empty inputs and LIMIT 0 record no retrieval. Scalar scoring inside arbitrary
expressions remains exact and has no per-row telemetry. Python exposes these
records in ResultView.diagnostics, MCP appends them to result text and includes
them in recipe diagnostics, C exposes kglite_cypher_result_diagnostics_json
and batch diagnostics, and Bolt includes kglite.retrieval in result summaries.