Cypher Reference¶
The Cypher KGLite supports — broad openCypher coverage, all running in-process (no server). For a quick overview, see the Cypher guide.
Label model: Each node has one immutable primary type plus optional secondary labels (multi-label since 0.10.5).
CREATE (n:A:B),SET n:B,REMOVE n:B, andMATCH (n:A:B)all work;labels(n)returns a list with the primary type first. Change the primary type viaSET n.type = 'NewType'(REMOVE n:Primaryerrors deliberately).
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 |
|
Path finding |
variable-length |
Predicates |
|
Expressions |
list comprehension |
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 ( |
Storage |
identical Cypher across in-memory, mmap, and on-disk modes (1B+ edges) |
Deliberately not supported (by design, not gaps): per-write
UNIQUE / NOT NULL / PRIMARY KEY constraints — uniqueness is a
load-time concern, handled by MERGE, the duplicate-id warning, and the
duplicate_title validator (see the data-integrity recipes), so it never
costs the in-memory write path.
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") # O(1), 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 is O(1) in every storage mode; 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:
No uniqueness constraint (see above):
CREATEdoes not reject a duplicateid— twoCREATE (:T {id: 'k'})make two nodes. For primary-keyed writes useMERGE, notCREATE—MERGE (:T {id: $k})is idempotent.Matching is type-exact:
'42'≠42. Keep id types consistent across writes and reads.Property typos pass silently by default (open schema): an unknown property on
CREATE/SET/MERGEis simply stored. To catch typos (summaryvsnote), calllock_schema()— a write with an unknown property is then rejected with aValid properties: …/ “did you mean?” hint (the same guidance reads give). 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)
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 =~
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")
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 (preserves Int64 when source is integer) |
|
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)")
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
""")
Built-in Functions¶
Function |
Description |
|---|---|
|
Convert to uppercase |
|
Convert to lowercase |
|
Convert to string |
|
Convert to integer |
|
Convert to float |
|
Length of string or list |
|
Relationship type |
|
Node ID |
|
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 |
|
Property names of a node or relationship (as JSON list) |
|
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 date string to DateTime ( |
|
Days between two dates ( |
|
First non-null argument |
|
Generate integer list (inclusive); default step = 1 |
|
First / last element of a list (returns |
|
Path hop count |
|
Nodes in a path |
|
Relationships in a path |
|
Split string into list |
|
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 |
|
Semantic similarity (auto-embeds query text; requires |
|
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) |
|
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 |
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_emband auto-embeds the query for you). 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.
Index-accelerated top-k. When an HNSW index is built (
build_vector_index), a whole-corpus top-k —RETURN vector_score(n, prop, q) AS s ORDER BY s DESC LIMIT k(and thetext_scoreform) — auto-uses it, the same opt-in approximate path the fluent API uses. Without an index, or for a selectiveWHEREthat filters the candidates, scoring is the exact brute-force scan. So building an index speeds up “search the whole corpus by similarity”; a heavily-filtered query stays exact.
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 date string to DateTime value |
|
Today’s date (no-arg form) |
|
Local wall-clock datetime as ISO-8601 string ( |
|
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) |
|
Day-delta between two DateTime values, returned as a Duration |
|
Add a duration to a date |
|
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.
localdatetime() / localtime() / time() return strings, not a temporal Value. KGLite’s Value::DateTime is date-only (NaiveDate), so there is no time-of-day Value variant to carry sub-day precision. Rather than silently dropping the time component, these functions emit ISO-8601 strings (localdatetime() → YYYY-MM-DDTHH:MM:SS, localtime()/time() → HH:MM:SS). The single-string-argument form validates and normalises its input, returning NULL on unparseable input (same contract as datetime(str)).
# 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 day-delta between two DateTime values. Months and
seconds are always 0 because Value::DateTime is currently
date-only (NaiveDate); a calendar-month-aware diff requires the
Value::DateTime → NaiveDateTime refactor (deferred).
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 |
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 |
|
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".
graph.cypher("RETURN split('a,b,c', ',') AS parts") # ["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"
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 (the columnar store holds 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
)
UNWIND¶
Expand a list into rows:
graph.cypher("UNWIND [1, 2, 3] AS x RETURN x, x * 2 AS doubled")
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")
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 hop count, nodes(p) returns node list, relationships(p) returns edge type list.
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.
CALL { ... } Subqueries¶
CALL { ... } nests a complete read sub-pipeline (MATCH/WHERE/WITH/RETURN, including nested CALL { ... }) and evaluates it as part of the outer query. It is the direct expression of post-aggregation enrichment shapes that otherwise require multiple cypher() calls or WITH-chaining workarounds that collapse the per-row cardinality you wanted to keep.
There are two forms, distinguished by whether the body imports outer variables.
The importing WITH — bare variables only¶
The leading importing WITH may list only plain variable references — WITH p, WITH p, c. Projection, aliasing, aggregation, and a WHERE are all rejected in the importing position; re-project inside the body instead.
# Rejected — aliasing in the importing WITH:
# CALL { WITH p AS x MATCH (x)-[:KNOWS]->(f) RETURN count(f) AS c }
# Rejected — projection / aggregation in the importing WITH:
# CALL { WITH p.name AS n ... }
# CALL { WITH p, count(*) ... }
# Correct — import the bare variable, re-project in the body:
graph.cypher("""
MATCH (p:Person)
CALL { WITH p RETURN p.name AS n }
RETURN n
""")
Import is explicit and total: an outer variable is visible inside the body iff it appears in the importing WITH. A bare MATCH (p)-[:KNOWS]->(f) inside the body without WITH p treats p as a fresh, unbound pattern variable — not the outer p.
Cardinality semantics¶
Body shape |
Per outer row |
Outer row fate |
|---|---|---|
Uncorrelated (any) |
runs once, S rows |
every outer row × S (cartesian) |
Correlated, non-aggregating body returning k rows |
runs per row |
k output rows (inner join); |
Correlated, aggregating body ( |
runs per row |
always exactly one row — |
CALL { ... } is an inner join, not an optional one: a non-aggregating body that matches nothing for an outer row removes that row from the output. An aggregating body always returns one row, so those rows survive (e.g. friend_count = 0). A NULL import (e.g. an anchor that came from an upstream OPTIONAL MATCH miss) runs the body with the NULL binding — pattern matches against NULL produce no rows, so the same drop-vs-zero rule applies.
Scoping¶
No outer leakage except via
RETURN. Variables introduced inside the body (fabove) are not visible after theCALL { ... }. Only the columns named in the body’s terminalRETURNescape, under theirRETURNaliases.Returned aliases must not collide with in-scope outer variables.
CALL { ... RETURN p AS p }whenpis already bound outside is a compile error.No auto-correlation. Un-imported outer names are not silently visible inside the body (see the importing-
WITHnote above).
v1 limitations¶
Not supported (v1) |
Why / workaround |
|---|---|
Writes in the body ( |
Rejected at validation. Per-outer-row mutation + atomicity is deferred; do writes in a separate top-level clause. |
Unit subquery (no terminal |
Deferred. The body must end in a |
|
Rejected at validation. Run separate queries and combine outside, or use a top-level |
|
Neo4j-server batching; no in-memory analogue. |
|
Use the explicit |
Schema Introspection (CALL db.*)¶
Neo4j-compatible schema procedures for discovering what’s in the graph
without leaving Cypher. Every Bolt client (cypher-shell, Neo4j Browser,
the Python neo4j driver) calls these to populate type palettes, drive
autocomplete, and surface index advisors.
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 property name (node + relationship), sorted alphabetically |
|
|
One row per node-type with its sorted list of property names — the in-language counterpart of Python |
Procedure names are case-insensitive on dispatch (Neo4j convention
preserves camelCase in docs: db.relationshipTypes, not
db.relationship_types). YIELD columns are case-sensitive.
# 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 |
|
|
|
|
|
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.
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.
Code-graph analysis¶
When the graph was built by kglite.code_tree (a parsed codebase), 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
code_tree.build(path, revs=['v1', 'v2', ...]) (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 code_tree.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_schemareplaces the whole schema — it is not a merge. A call with a subset of types drops the omitted ones, so redefine all types each call.
Every write to an opted-in type then stamps a reserved updated_at (a
Timestamp) — Cypher CREATE/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 / 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)
""")
# 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 — plain DELETE errors if 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'
""")
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}
Cardinality estimates use type_indices counts when available, None otherwise.
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, 'timed_out': False, 'timeout_ms': 10000}
Keys:
elapsed_ms— wall-clock duration in milliseconds.timed_out—Truewhen the deadline fired (rows reflect the partial set).timeout_ms— the deadline that was in effect, orNonewhen no deadline applied.
timeout_ms resolution: explicit cypher(..., timeout_ms=N) > kg.set_default_timeout(ms) > backend-aware default (Disk 10_000, Mapped 60_000, Memory none). Pass timeout_ms=0 to disable the deadline entirely for one call.
Indexes¶
Create an equality index on a (node_type, property) pair to accelerate MATCH (n:T {prop: value}) and WHERE n.prop = value to O(log N):
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 files next to the CSR (property_index_{type}_{property}_{meta,keys,offsets,ids}.bin) and lazy-loaded on first query after reopen. No heap HashMap rebuild 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. String indexes support both equality and prefix; 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 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
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
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, …). A few keywords stay reserved to avoid ambiguity:
the clause-flow words (MATCH, WHERE, RETURN, WITH, AND, OR, …)
and the value keywords (NULL, TRUE, FALSE, CASE/WHEN/END,
EXISTS). For any reserved word, quote it with backticks:
CREATE (n:Doc {`where`: 1, `null`: 'x'})
RETURN n.`where`
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`
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)¶
n.id is the node’s unique 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). 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).
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.
Supported Cypher Subset¶
Category |
Supported |
|---|---|
Clauses |
|
Patterns |
Node |
WHERE |
|
RETURN |
|
Aggregation |
|
Expressions |
|
Functions |
|
String |
|
Math |
|
Spatial |
|
Temporal |
|
Semantic |
|
Timeseries |
|
Mutations |
|
Procedures |
|
Scoped algorithms |
|
Schema |
|
Rule procedures |
|
Operators |
|
openCypher Compatibility Matrix¶
Clause-by-clause comparison with the openCypher specification.
Clauses¶
Clause |
Status |
Notes |
|---|---|---|
|
Full |
Node patterns, relationship patterns, variable-length paths, |
|
Full |
Automatic fusion optimization with aggregation |
|
Full |
All comparison, logical, string, and pattern operators |
|
Full |
Aliases, |
|
Full |
Aggregation passthrough, grouping, chained subqueries |
|
Full |
Multi-column, |
|
Full |
|
|
Full |
List expansion, works with |
|
Full |
|
|
Full |
Nodes, relationships, inline properties |
|
Full |
|
|
Full |
|
|
Full |
|
|
Full |
|
|
Full |
Structured |
|
Full |
Execute + per-clause stats (rows_in, rows_out, elapsed_us) |
|
Full |
Post-aggregation filter on |
|
Full |
Built-in graph algorithm procedures |
|
Partial |
Uncorrelated + correlated (importing |
|
Not supported |
Use |
|
Not supported |
By design — use Python |
Expressions & Operators¶
Feature |
Status |
Notes |
|---|---|---|
Arithmetic ( |
Full |
|
String concat ( |
Full |
Auto-converts non-strings |
Comparison ( |
Full |
Three-valued logic (Null = false) |
Boolean ( |
Full |
|
|
Full |
Also works as expressions in RETURN/WITH |
|
Full |
|
|
Full |
|
|
Full |
Compiled and cached per query |
|
Full |
Simple and generic forms |
Parameter references ( |
Full |
In WHERE, pattern properties, and expressions |
List comprehensions ( |
Full |
|
List slicing ( |
Full |
Open-ended, negative indices |
List quantifiers ( |
Full |
|
|
Full |
Brace |
Map projections ( |
Full |
|
Map literals ( |
Full |
|
Variable binding in pattern properties |
Full |
|
Window functions ( |
Full |
|
Scalar & Aggregation Functions¶
Function |
Status |
Notes |
|---|---|---|
|
Full |
With |
|
Full |
|
|
Full |
|
|
Full |
Standard deviation |
|
Full |
|
|
Full |
|
|
Full |
Strings, lists, and paths |
|
Full |
Returns relationship type |
|
Full |
Returns node id |
|
Full |
Returns the label list, primary type first (multi-label since 0.10.5) |
|
Full |
Returns property names as JSON list |
|
Full |
Parse date string to DateTime; |
|
Full |
|
|
Full |
Inclusive integer range |
|
Full |
|
|
Full |
Path decomposition |
String functions |
Full |
|
Math functions |
Full |
|
Trig functions |
Full |
|
Spatial functions |
Full |
|
Temporal functions |
Full |
|
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 via label ops — change it with |
Storage |
In-memory (petgraph) |
Disk-based |
Embedded use case, explicit |
Transactions |
Snapshot isolation + OCC |
Full ACID |
GIL serializes Python access; OCC catches conflicts |
Indexing |
Type indices + vector index |
Schema indexes |
Automatic type-based lookup, no manual |
|
Not supported |
Supported |
Python ecosystem (pandas) preferred for data loading |