Neo4j Cypher Cheatsheet - MATCH, CREATE & Indexes

Neo4j's advantage over a relational database is not generically that it queries faster, but that relationships themselves are queryable: who is reachable within two hops of a person, what the shortest dependency chain between two nodes is. In a relational model those questions need self-joins across several join tables; in a graph model each is one typed path pattern. This sheet moves from CREATE (a)-[:KNOWS]->(b) through variable-length patterns like [:KNOWS*1..3], shortestPath, UNWIND, subqueries and percentileCont aggregation, grouped into creating nodes and relationships, matching, WITH and subqueries, updates and deletes, aggregation and ordering, and indexes and constraints. Reach for it while writing a query: decide first whether relationships carry types, because typed patterns beat indiscriminate matching, and always cap path length so a variable-length pattern cannot walk the whole graph. One production rule is not negotiable - create uniqueness constraints before ingesting data, because MERGE cannot remove duplicates that already exist in history.

Databases·40 commands·Last updated 2026-07-21
neo4jGraph Databasescypher

Typical Use Case

For engineers who need traversal of arbitrary depth: friend recommendations, knowledge graphs, permission inheritance, organisation hierarchies and fraud ring detection. Relational databases need recursive CTEs for these, a graph database states in one line. Covers node and relationship creation and the choice between CREATE and MERGE, variable-length patterns and shortestPath, deletion including DETACH DELETE, aggregation, and indexes. Without indexes a lookup scans every label.

Create Nodes & Relationships 5

CREATE (n:Person {name: "Alice"})
Create a node
CREATE (a:Person)-[:KNOWS]->(b:Person)
Create a relationship
CREATE (a:Person {name: "Alice"})-[:KNOWS {since: 2020}]->(b:Person {name: "Bob"})
Create node & relationship with properties
MERGE (n:Person {name: "Alice"})
Match if exists, else create
MERGE (a)-[:KNOWS]->(b) ON CREATE SET a.created = timestamp()
MERGE with onCreate

Query & Match 9

MATCH (n:Person) RETURN n
Match all Person nodes
MATCH (n:Person {name: "Alice"}) RETURN n
Match by property
MATCH (n:Person) WHERE n.age > 18 RETURN n
WHERE filter
MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b
Match a relationship
MATCH (a:Person)-[:KNOWS*2]->(b:Person) RETURN a, b
2-hop relationship
MATCH (a:Person)-[:KNOWS*1..3]->(b:Person) RETURN a, b
1-3 hop relationship
MATCH (n) OPTIONAL MATCH (n)-[:KNOWS]->(m) RETURN n, m
Optional match (null if no rel)
MATCH path = (a)-[*]->(b) RETURN path
Match a path
MATCH path = shortestPath((a)-[:KNOWS*]->(b)) RETURN path
Shortest path

WITH & Subqueries 5

MATCH (n:Person) WITH n WHERE n.age > 18 RETURN n.name
WITH filter & pass
MATCH (n:Person)-[:KNOWS]->(m) WITH n, count(m) AS friends RETURN n.name, friends
WITH aggregate & pass
UNWIND [1, 2, 3] AS x RETURN x
Unwind array into rows
CALL { MATCH (n:Person) RETURN n ORDER BY n.name LIMIT 5 } RETURN count(*)
CALL subquery
MATCH (n:Person) RETURN n.name AS name UNION MATCH (n:Company) RETURN n.name AS name
UNION merge results

Update & Delete 6

MATCH (n:Person {name: "Alice"}) SET n.age = 30
Update a property
MATCH (n:Person {name: "Alice"}) SET n += {age: 30, city: "Beijing"}
Update multiple props
MATCH (n:Person {name: "Alice"}) REMOVE n.age
Remove a property
MATCH (n:Person {name: "Alice"}) DELETE n
Delete node (must have no rels)
MATCH (n:Person {name: "Alice"}) DETACH DELETE n
Delete node and its relationships
MATCH (a)-[r:KNOWS]->(b) DELETE r
Delete a relationship

Aggregation & Order 9

MATCH (n:Person) RETURN count(n)
Count
MATCH (n:Person) RETURN DISTINCT n.city
Distinct
MATCH (n:Person) RETURN n.name, n.age ORDER BY n.age DESC
Order by
MATCH (n:Person) RETURN n.age, count(*) ORDER BY n.age
Group & count
MATCH (n:Person) RETURN collect(n.name)
Collect into array
MATCH (n:Person) RETURN avg(n.age), min(n.age), max(n.age)
Aggregate functions
MATCH (n:Person) RETURN percentileCont(n.age, 0.5)
Median (percentile)
MATCH (n:Person) RETURN n LIMIT 10
Limit results
MATCH (n:Person) RETURN n SKIP 5 LIMIT 10
Pagination

Index & Constraint 6

CREATE INDEX ON :Person(name)
Create an index
CREATE CONSTRAINT ON (n:Person) ASSERT n.name IS UNIQUE
Unique constraint
CREATE CONSTRAINT ON (n:Person) ASSERT exists(n.name)
Existence constraint
DROP INDEX ON :Person(name)
Drop an index
DROP CONSTRAINT ON (n:Person) ASSERT n.name IS UNIQUE
Drop a constraint
CALL db.indexes()
List all indexes

Parameter matrix

ParameterEffectExample
CREATE (n:Person {name: "Alice"})Creates a node unconditionally. Running it twice produces duplicates, which is why MERGE is the default choice in write scripts.CREATE (n:Person {name: "Alice"})
CREATE (a:Person)-[:KNOWS]->(b:Person)Creates a pattern that includes the relationship; a relationship written without properties ends up null.CREATE (a:Person)-[:KNOWS]->(b:Person)
CREATE (a)-[:KNOWS {since: 2020}]->(b)Attaches properties to the relationship, which is the main way to weight an edge.CREATE (a:Person {name: "Alice"})-[:KNOWS {since: 2020}]->(b:Person {name: "Bob"})
MERGE (n:Person {name: "Alice"})Matches when the pattern exists and creates it otherwise, so a write script is idempotent by construction.MERGE (n:Person {name: "Alice"})
MERGE (a)-[:KNOWS]->(b) ON CREATE SET a.created = timestamp()Writes a property only when the node is genuinely created, so the timestamp is not refreshed on every run.MERGE (a)-[:KNOWS]->(b) ON CREATE SET a.created = timestamp()
MATCH (n:Person) RETURN nMatches every node carrying the label; without an index this is a full label scan.MATCH (n:Person) RETURN n
MATCH (n:Person {name: "Alice"})Matching on a property; it only becomes an index lookup once that property is indexed.MATCH (n:Person {name: "Alice"}) RETURN n
MATCH (a)-[:KNOWS*2]->(b)A fixed-length variable-length path of exactly two hops, the standard shape for shortest chain queries.MATCH (a:Person)-[:KNOWS*2]->(b:Person) RETURN a, b
MATCH (a)-[:KNOWS*1..3]->(b)Traverses a depth range. Cost climbs steeply with the upper bound, so an explicit ceiling is mandatory.MATCH (a:Person)-[:KNOWS*1..3]->(b:Person) RETURN a, b
MATCH path = (a)-[*]->(b) RETURN pathWalks any relationship type at any depth and returns the full path; without a bound this is extremely expensive on a dense graph.MATCH path = (a)-[*]->(b) RETURN path
shortestPath((a)-[:KNOWS*]->(b))Shortest path traversal stops at the first shortest path found, which is far cheaper than enumerating all paths.MATCH path = shortestPath((a)-[:KNOWS*]->(b)) RETURN path
OPTIONAL MATCHReturns null instead of dropping the row when nothing matches, which is what makes "show it when present, leave it blank otherwise" possible.MATCH (n) OPTIONAL MATCH (n)-[:KNOWS]->(m) RETURN n, m
WITH n WHERE n.age > 18 RETURN n.nameFiltering and projection inside the pipeline: narrow first, expand afterwards, which is the main lever for keeping intermediate result sets small.MATCH (n:Person) WITH n WHERE n.age > 18 RETURN n.name
WITH n, count(m) AS friends RETURN n.name, friendsAggregates and groups by n, which is the shape behind questions such as how many friends each person has.MATCH (n:Person)-[:KNOWS]->(m) WITH n, count(m) AS friends RETURN n.name, friends
UNWIND [1, 2, 3] AS x RETURN xExpands a list into rows, used both to build test data and to flatten array properties.UNWIND [1, 2, 3] AS x RETURN x
CALL { MATCH ... RETURN ... } RETURN count(*)A subquery: a match is encapsulated as an independent unit and then aggregated by the outer query.CALL { MATCH (n:Person) RETURN n ORDER BY n.name LIMIT 5 } RETURN count(*)
RETURN ... UNION MATCH ...Unions two result sets of the same shape; the column names on both sides must agree or the query fails.MATCH (n:Person) RETURN n.name AS name UNION MATCH (n:Company) RETURN n.name AS name
SET n += {age: 30, city: "Beijing"}Merges properties without dropping the ones not listed, unlike SET n = {...} which replaces the whole property set.MATCH (n:Person {name: "Alice"}) SET n += {age: 30, city: "Beijing"}
REMOVE n.ageRemoves a single property; what disappears is the property, not the node.MATCH (n:Person {name: "Alice"}) REMOVE n.age
DELETE n vs DETACH DELETE nDELETE removes the node alone and fails while relationships are still attached; DETACH DELETE takes the relationships with it.MATCH (n:Person {name: "Alice"}) DETACH DELETE n
DELETE rDeletes the relationship itself, which is safer than deleting nodes when the goal is only to drop an edge.MATCH (a)-[r:KNOWS]->(b) DELETE r
RETURN DISTINCT n.cityDeduplicates. In graph queries DISTINCT slows down noticeably as data grows, so aggregating earlier usually wins.MATCH (n:Person) RETURN DISTINCT n.city
RETURN collect(n.name)Collapses many rows into one list, the standard aggregation for "every friend name of this person".MATCH (n:Person) RETURN collect(n.name)
percentileCont(n.age, 0.5)Percentile, with 0.5 giving the median; the cont variant interpolates continuously and a discrete variant is also available.MATCH (n:Person) RETURN percentileCont(n.age, 0.5)
RETURN n SKIP 5 LIMIT 10Paging. SKIP still scans linearly for deep pages, so seek-style paging is the right production approach.MATCH (n:Person) RETURN n SKIP 5 LIMIT 10
CREATE INDEX ON :Person(name)Creates a property index so equality matching seeks the index instead of scanning the whole label.CREATE INDEX ON :Person(name)
CREATE CONSTRAINT ... IS UNIQUEA uniqueness constraint protects data correctness and lets MERGE use the index; worth adding for fields MERGE matches on frequently.CREATE CONSTRAINT ON (n:Person) ASSERT n.name IS UNIQUE
CREATE CONSTRAINT ... ASSERT exists(n.name)An existence constraint keeps the property non-null and stops downstream queries from seeing null.CREATE CONSTRAINT ON (n:Person) ASSERT exists(n.name)
DROP INDEX / DROP CONSTRAINTRemoves an index or a constraint; dropping an index degrades queries to full scans, so weigh the production impact first.DROP INDEX ON :Person(name)
CALL db.indexes()Lists every index and constraint in the database, which is the direct way to settle whether an index really exists.CALL db.indexes()

Common pitfalls

SymptomRe-running a script leaves behind a large number of duplicate nodes with the same name.

CauseCREATE is used instead of MERGE; CREATE inserts unconditionally and adds a new node on every execution.

FixSwitch to MERGE and add a uniqueness constraint on the matched fields, or query with MATCH first and only write when nothing came back.

SymptomDeleting a node fails with Node cannot be deleted because it still has relationships.

CauseDELETE removes only the node, and the database refuses while relationships remain attached.

FixUse DETACH DELETE, or delete the relationships separately first when some of them must be preserved.

SymptomQueries by name get slower and PROFILE shows a full label scan.

CauseThe property used for equality matching has no index, or the index was only just created and has not taken effect.

FixCreate the index with CREATE INDEX ON :Person(name) plus a uniqueness constraint, and confirm with EXPLAIN or PROFILE that the plan uses NodeIndexSeek.

SymptomA variable-length path query exhausts memory or times out.

CauseThe depth bound is missing or far too large, and the number of paths grows exponentially on a dense graph.

FixSet an explicit depth ceiling and use shortestPath; prune by property or label before expanding whenever possible.

SymptomOne edge is missing from the result and OPTIONAL MATCH returns null for m.

CauseThe relationship direction is reversed: (:A)-[:KNOWS]->(:B) and (:A)<-[:KNOWS]-(:B) mean different things.

FixCount the relationships in each direction first to confirm orientation, then standardise how the arrow is written.

SymptomNodes that were meant to be matched after a WITH have vanished.

CauseWITH behaves as a filter: variables not projected through it are no longer in scope, and DISTINCT also changes the row count.

FixList every variable that must survive in the WITH clause explicitly, and aggregate with count before expanding again where needed.

SymptomUNION fails with a complaint that the result columns differ in name or count.

CauseUNION requires both sides to return the same number of columns with matching types, and it aligns them by position rather than by name.

FixAlias both sides to one consistent set of column names and check the types match, since numbers and strings cannot be mixed.

SymptomDisk usage explodes after hours of import while the node count barely moves.

CauseMERGE on an unindexed field degrades into a full scan and leaves a large volume of transaction logs waiting to be flushed.

FixCreate the indexes and constraints before importing, and for large volumes use APOC batching while keeping the transaction size under control.

Troubleshooting

  1. 1Confirm indexes and constraints really exist

    CALL db.indexes()

    If the index is absent from the list, full scans are the direct explanation.

  2. 2Verify label and relationship type spelling

    grep -rn "CREATE \(:\|MERGE (:" data

    Mismatched case or an underscore is a frequent reason something that was created cannot be found.

  3. 3Inspect the plan the query actually used

    PROFILE MATCH (n:Person {name: "Alice"}) RETURN n

    Look for NodeIndexSeek and TotalDbHits in the operators section.

  4. 4Size the data before choosing an optimisation

    MATCH (n:Person) RETURN count(n)

    Volume decides whether indexes, paging and depth limits are needed at all.

  5. 5Check whether write scripts use MERGE

    grep -rn "CREATE (\|MERGE (" load

    The root cause of duplicated imports is almost always in this one detail.

  6. 6Check whether a delete cascades to relationships

    grep -rn "DELETE" data

    Any case that must take relationships with it should say DETACH DELETE explicitly.

Command Examples

MERGE for idempotent writes

MERGE (a:Person {name: "Alice"}) MERGE (a)-[:KNOWS {since: 2020}]->(b:Person {name: "Bob"})

CREATE adds another copy every run, so re-running an import script pollutes the graph; MERGE matches the whole pattern first, reuses what it finds, and only creates when there is no match, which makes the script safe to run repeatedly. MERGE matches the entire pattern: with two property conditions both must hold, and omitting one degrades it to a single-property match.

Output

Added 1 node, set 2 properties, created 0 relationships

Narrow with WITH before the second match

MATCH (n:Person) WITH n WHERE n.age > 18 RETURN n.name MATCH (a:Person)-[:KNOWS]->(b:Person) WITH a, count(b) AS friends RETURN a.name, friends

Chaining two MATCH clauses makes the second run once per row produced by the first; an intervening WITH filters and aggregates first, shrinking cardinality before the next match. Traversal cost grows linearly with row count, so on a large graph the missing WITH is the difference between minutes and seconds.

Output

friends
30

Detach relationships before deleting a node

MATCH (n:Person {name: "Alice"}) DELETE n MATCH (n:Person {name: "Alice"}) DETACH DELETE n

A node that still has relationships is rejected by DELETE and the whole transaction rolls back; DETACH DELETE removes the relationships as well. On a hub node referenced by many others, DETACH DELETE severs a large part of the graph, so check the blast radius with MATCH (n)-[r]->(m) RETURN count(*) first.

Output

Cannot delete node<4>, because it still has relationships. (ConstraintViolation)

Count per label instead of scanning the whole database

MATCH (n:Person) RETURN count(n) MATCH (n:Person) RETURN n.age, count(*) ORDER BY n.age CREATE INDEX ON :Person(name) CALL db.indexes()

MATCH (n) walks every node in the database just to test labels, which costs seconds on a multi-million-node graph. Naming the label lets Neo4j use a label scan, and an index turns property filtering into an index seek. Verify with EXPLAIN: you want NodeByLabelScan plus NodeIndexSeek, not AllNodesScan.

Bound the hop count instead of an unbounded graph search

MATCH (a:Person)-[:KNOWS*2]->(b:Person) RETURN a, b MATCH (a:Person)-[:KNOWS*1..3]->(b:Person) RETURN a, b MATCH path = shortestPath((a)-[:KNOWS*]->(b)) RETURN path

A variable-length -[:KNOWS*] with no upper bound expands in proportion to how connected the graph is and can exhaust memory on a few hundred thousand nodes. Write *2 or *1..3 for routine queries, and reserve shortestPath for real shortest-path work while pinning both ends to specific nodes so the search does not start from the whole graph.

Common Pitfalls

  • MATCH (a)-[r]-(b) with no label or property on either side is a full database scan: a few characters of difference turn an index lookup into a whole-graph traversal. Add labels and property predicates, and use PROFILE to confirm an index is actually used.
  • A variable-length [:KNOWS*] with no upper bound is the most common way to take down a database: expansion grows explosively with connectivity and aggregations materialize the whole result in memory. Always bound it as *1..3, or make shortestPath the explicit intent.
  • MATCH (n) RETURN n ships every node with all its properties to the client, so on millions of nodes the result set itself is the memory problem. An index is not enough — project only the properties you need in RETURN, or add LIMIT / SKIP.
  • MATCH (a:Person)-[:KNOWS]->(b:Person) and MATCH (a:Person)-[:KNOWS*1..2]->(b:Person) look almost identical but return very different counts, and an off-by-one hop silently produces wrong answers for friend counts and second-degree reach. Comment the intended hop count next to every variable-length relationship.
  • Forgetting CREATE INDEX ON :Person(name) means the WHERE equality check compares against every node. The difference is invisible at small data volumes and becomes minutes instead of milliseconds in production. Index the property your predicates filter on.
  • DETACH DELETE quietly severs links other code depends on, while DELETE rejects a node that still has relationships and rolls back the whole transaction. Before bulk cleanup check MATCH (n:Person {name: "Alice"})-[r]-() RETURN count(r); after a DELETE failure, do not retry the same query — first decide whether to detach or to migrate the references.

Tips

  • Run EXPLAIN before MATCH to avoid full-graph scans.
  • MERGE is safer than CREATE — it avoids duplicating nodes.
  • Long path queries (*1..10) can be slow; add LIMIT or use graph algorithms.

FAQ

Should I use CREATE or MERGE, and will re-running an import create duplicates?

It will, which is why MERGE is the default choice. CREATE (n:Person {name: "Alice"}) always inserts, so a second run adds a second node. MERGE (n:Person {name: "Alice"}) matches on label plus properties first and only creates when nothing matches. Back the property with a unique constraint via CREATE CONSTRAINT ON (n:Person) ASSERT n.name IS UNIQUE, adding an exists(n.name) constraint when presence matters. Reserve CREATE for test fixtures you know will not be re-run. MERGE also takes a callback: MERGE (a)-[:KNOWS]->(b) ON CREATE SET a.created = timestamp() writes the property only on actual creation.

Deleting a node errors because relationships are still attached — what now?

Cypher refuses to delete a node that still has relationships. Pick by intent: MATCH (a)-[r:KNOWS]->(b) DELETE r removes relationships on their own; MATCH (n:Person {name: "Alice"}) DETACH DELETE n drops the node together with every attached relationship in one statement, which suits test-data cleanup. Property-level edits are not deletes — use SET and REMOVE instead, such as SET n += {age: 30, city: "Beijing"} for a bulk update or REMOVE n.age to drop a key. Before any large delete, run the MATCH with RETURN first and confirm how many rows you actually matched.

What does WITH actually do in a query, and can it replace subqueries?

WITH passes an intermediate result down the pipeline, allowing both filtering and aggregation, which is how you narrow in stages. MATCH (n:Person) WITH n WHERE n.age > 18 RETURN n.name filters first and only then outputs; MATCH (n:Person)-[:KNOWS]->(m) WITH n, count(m) AS friends RETURN n.name, friends aggregates per person before returning. For a bounded sub-result use CALL: CALL { MATCH (n:Person) RETURN n ORDER BY n.name LIMIT 5 } RETURN count(*). UNWIND [1, 2, 3] AS x RETURN x expands an array into rows, and UNION combines the results of two similarly shaped queries.

How do I write multi-hop queries like friends-of-friends or grandparent chains without killing performance?

Variable-length relationships use an asterisk with a hop count. Exactly two hops: (a:Person)-[:KNOWS*2]->(b:Person); a hop range: (a:Person)-[:KNOWS*1..3]->(b:Person). A fixed-depth grandparent chain is better expressed by self-joining three times, as in the tree t1 / t2 / t3 pattern. To return the path itself use MATCH path = (a)-[*]->(b) RETURN path, and for the shortest route MATCH path = shortestPath((a)-[:KNOWS*]->(b)) RETURN path. Unbounded patterns such as *1..10 can be extremely slow, so EXPLAIN the plan to avoid a full graph scan and add LIMIT to bound the result.

My queries keep getting slower — what should I optimise first?

In order of payoff. First, create indexes: CREATE INDEX ON :Person(name) lets property lookups avoid scanning, confirm them with CALL db.indexes() and clean up with DROP INDEX ON :Person(name) when obsolete. Second, run EXPLAIN before the real query to confirm no full graph scan. Third, constraints help lookup performance as well, e.g. CREATE CONSTRAINT ON (n:Person) ASSERT n.name IS UNIQUE. Fourth, bound the output with RETURN n LIMIT 10, or page with RETURN n SKIP 5 LIMIT 10. Fifth, push filtering into an early WITH instead of materialising the whole graph and filtering afterwards.

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.

Maintained by LaoHand

Publicly updated on Jul 21, 2026, continuously proofread against official docs.

Contact Us

Wrong command or description? Send us corrections, business inquiries or product feedback by email.

Contact Us