Property Graphs and Cypher: Model, Load and Query in One Evening
11 min read · updated August 4, 2026
A property graph is nodes and relationships, both of which carry labels and key/value properties. Cypher is its query language, and its central idea is that you draw the pattern you want with ASCII art. This page goes from an empty database to a constrained, loaded, queryable graph, and then covers the four mistakes that make a first graph silently wrong.
The property graph model
Four concepts, and the difference from RDF is in the third:
- Nodes carry zero or more labels (
:Company,:Person) and a property map. - Relationships are directed, have exactly one type (
:SUPPLIES), and always have a start and an end node. - Relationships carry properties too. This is the big one.
(acme)-[:SUPPLIES {since: 2014, volume: 1200}]->(vdb)is a single edge with data on it. In RDF that requires reifying the statement into a node, which is why property graphs feel lighter for this kind of modelling. - Identity is local. There are no global IRIs. Node identity is whatever key you enforce, which is why the constraints step below is not optional.
MERGE, LOAD CSV, uniqueness constraints, variable-length patterns — are long-standing. Procedure names and index syntax do move between major versions, so check anything with a CALL in it against the docs for the version you are running.Step 1: constraints before data
Create the uniqueness constraints first. A constraint on a property also gives you an index on it, and both MERGE and every lookup you write depend on that index existing. Loading first and constraining afterwards means the load is slow and then the constraint fails on the duplicates the slow load created.
CREATE CONSTRAINT company_id IF NOT EXISTS FOR (c:Company) REQUIRE c.id IS UNIQUE; CREATE CONSTRAINT person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.id IS UNIQUE; CREATE CONSTRAINT category_id IF NOT EXISTS FOR (k:Category) REQUIRE k.id IS UNIQUE;
The id property here should be your own canonical identifier, not the database’s internal node id, which is not stable across reimports. Why that matters enough to have its own page is the subject of canonical entity ids.
Step 2: load the nodes, then the edges
Two passes, always. Nodes first, so that when the relationship pass runs, both ends already exist and can be found by index. Given companies.csv with columns id,name,city,founded and supplies.csv with supplier_id,buyer_id,category,since:
// pass 1 — nodes
LOAD CSV WITH HEADERS FROM 'file:///companies.csv' AS row
CALL {
WITH row
MERGE (c:Company {id: row.id})
SET c.name = row.name,
c.city = row.city,
c.founded = toInteger(row.founded)
} IN TRANSACTIONS OF 5000 ROWS;
// pass 2 — relationships
LOAD CSV WITH HEADERS FROM 'file:///supplies.csv' AS row
CALL {
WITH row
MATCH (s:Company {id: row.supplier_id})
MATCH (b:Company {id: row.buyer_id})
MERGE (k:Category {id: row.category})
MERGE (s)-[r:SUPPLIES]->(b)
SET r.since = toInteger(row.since)
MERGE (s)-[:PRODUCES]->(k)
} IN TRANSACTIONS OF 5000 ROWS;Three details in there are doing real work. MERGE (c:Company {id: row.id}) matches on the key only and then SETs the rest, which makes the load idempotent — run it twice and you get the same graph, which is the property you need for keeping the graph fresh. MATCH rather than MERGE for the two endpoints in pass two means a row referencing a company that was not in companies.csv is skipped rather than silently creating a property-less ghost node. And the batching keeps one enormous transaction from exhausting the heap; a load that dies at row 800,000 having rolled back everything is a bad afternoon.
Step 3: the queries worth knowing
Pattern matching with a filter and an aggregate:
MATCH (s:Company)-[r:SUPPLIES]->(b:Company) WHERE s.city = 'Dresden' AND r.since <= 2018 RETURN b.name AS buyer, count(DISTINCT s) AS suppliers ORDER BY suppliers DESC LIMIT 10
Variable-length traversal, with a bound, which you always want:
MATCH path = (s:Company {id: 'acme'})-[:SUPPLIES*1..6]->(d:Company)
RETURN d.name AS downstream, min(length(path)) AS hops
ORDER BY hopsThe shortest path between two named things, which is the query that usually sells the graph to whoever is paying for it:
MATCH (a:Company {id: 'acme'}), (z:Company {id: 'zenith'})
MATCH p = shortestPath((a)-[:SUPPLIES*..10]-(z))
RETURN [n IN nodes(p) | n.name] AS route, length(p) AS hopsNote the undirected pattern -[:SUPPLIES*..10]- in that one: “connected to” is usually the question, and requiring the arrows to line up along the whole route almost always returns nothing.
WITH is the pipe between stages and is what turns Cypher from a pattern matcher into something you can compute in:
MATCH (c:Company)-[:PRODUCES]->(k:Category) WITH c, collect(k.id) AS categories, count(k) AS breadth WHERE breadth >= 3 MATCH (c)-[:SUPPLIES]->(b:Company) RETURN c.name, breadth, count(DISTINCT b) AS customers ORDER BY customers DESC
Four mistakes that cost a day each
1. MERGE on a whole pattern
MERGE (a:Company {id:'x'})-[:SUPPLIES]->(b:Company {id:'y'}) does not mean “find or create the relationship”. MERGE treats the entire pattern as one unit: if the whole pattern does not exist, it creates all of it, including new copies of both nodes. Run that load twice with a constraint missing and the node count doubles. Always MERGE each node separately, then MERGE the relationship between the matched variables.
2. The cartesian product
Two disconnected patterns in one MATCH produce every combination:
// wrong: rows = companies x people MATCH (c:Company), (p:Person) WHERE c.city = p.city RETURN c.name, p.name
At 10,000 companies and 50,000 people that is 500 million intermediate rows before the filter runs. Connect the patterns with a relationship, or model the shared value as a node — a :City node here — so that the join is a traversal rather than a comparison.
3. Unbounded variable-length patterns
-[:SUPPLIES*]-> with no upper bound on a graph containing a cycle explores an enormous number of paths. Always write *1..6 or similar, and pick the bound from the domain: a supply chain deeper than six hops is usually a data error rather than a finding.
4. Property-typed relationships
Modelling a relationship as [:RELATED {type: 'supplies'}] rather than [:SUPPLIES] throws away the one thing the database indexes natively. Relationship type is part of the storage layout; a property on a relationship is not, and every traversal then has to read and test it. Use types for the small closed set of relationships you traverse by, and properties for everything else.
Reading a query plan
EXPLAIN shows the plan without running it; PROFILE runs it and reports rows and database hits per operator. The one thing to look for in either is how the query starts. A plan beginning with NodeByLabelScan is reading every node with that label; a plan beginning with NodeIndexSeek or NodeUniqueIndexSeek found its anchor by index. If a query is slow, the answer is nearly always that the anchor is not index-backed.
The second thing to look at is where the row count explodes. Each operator reports rows in and rows out; the operator where that number jumps by orders of magnitude is the one to fix, usually by moving a filter earlier or by adding a WITH ... LIMIT to cut the intermediate set before an expensive expansion. Deliberately, no timings appear on this page: throughput depends entirely on your data, your heap and your page cache, and a number from somebody else’s machine would tell you nothing.
When a table is the better answer
The argument for a property graph is traversal. Where traversal is not the workload, a relational database is a better tool and it is worth saying so plainly:
- Fixed-depth joins. If every query joins two or three known tables, that is a join, and a relational planner with good statistics will do it well. Variable depth is the thing a graph is for; three hops known in advance is not variable depth.
- Heavy aggregation over columns. Summing a hundred million order lines by month is a columnar-warehouse problem. Graph engines are optimised for pointer-chasing, not for scanning one attribute across everything.
- Strict schemas with strong constraints. Foreign keys, check constraints, referential actions and transactions across complex invariants are more mature in relational engines, and a catalogue or a ledger benefits from all of them.
- Operational familiarity. Backups, replicas, connection pooling, point-in-time recovery and the person who is on call already exist for your relational database. Adding a second stateful system has a running cost that is easy to leave out of the comparison and impossible to leave out of the rota.
A common and sensible arrangement is relational as the system of record with the graph as a derived, rebuildable projection of it. If the graph can be dropped and reloaded from the source in an hour, an outage in it is an inconvenience rather than an incident — and the reload is exactly the two-pass load written above.