Graph databases, Neo4j and first steps in Cypher
Inès sketches on the board a question Veille would love to answer for its customers: "who writes in the same categories as Lee Moran, and how far apart do their bylines sit?". In SQL, the query becomes a stack of recursive JOINs. The same problem, in a graph, fits on three lines. Sami installs Neo4j locally through the kit; this module teaches him the vocabulary, the Cypher language, and has him build a mini-graph of the team that will serve as practice ground.
The Neo4j labels and relationship types used across this module and the next three (:Personne, :Ville, :Competence, :PUBLIE_DANS, :ECRIT_PAR, :HABITE, :MAITRISE, :ENCADRE, and later :Article, :Categorie, :Auteur, :MENTIONNE) are kept in French because they are defined in the shared kit .cypher scripts and loaded into the graph by ./lab.sh cypher. Renaming them in the docs would break every query against the real dataset.
When the graph beats the relational model
A relational database is excellent at aggregating columns, less good at following relationships at variable depth. Three signals telling you the problem is a graph problem:
- A question involves a path: "is there a link between A and B?", "how far apart are they?".
- Depth is not fixed: "who mentors whom, at one or two levels?", "recommendations at two hops".
- Entities play several roles: an author can also be a source, a category can also be an area of interest.
In SQL, a path of length 3 translates to three explicit JOINs. In Cypher, you write (a)-[*1..3]->(b) and the engine finds the path. The gain is not just expressiveness: the graph engine stores relationships as pointers and traverses them in constant time, unlike a join that leans on an index over the foreign key.
The property model
Neo4j follows the property graph model. Four building blocks are enough.
- Node — the entity. Written between parentheses:
(p). - Label — the node type, prefixed with
::(:Personne),(:Ville). A node can carry several labels. - Typed and directed relationship — the link, between square brackets and arrows:
-[:ENCADRE]->. The type is mandatory, the direction too (even though it can be traversed either way when read). - Property — a key/value pair carried by a node or a relationship:
{nom: 'Inès', anciennete: 6}.
You assemble these blocks into patterns written in ASCII art:
(p:Personne)-[:HABITE {depuis: 2018}]->(v:Ville {nom: 'Montréal'})
This line reads as "there is a node p labeled Personne in relation HABITE with the node v labeled Ville with name Montréal, the relation carrying the property depuis set to 2018". The same line is used both to read (inside a MATCH) and to write (inside a CREATE or a MERGE). This is Cypher's first big bet: a single vocabulary for querying and for building.
Open Neo4j Browser
The kit exposes Neo4j Community 5.26 at http://localhost:7474 (HTTP) and bolt://localhost:7687 (the binary protocol used by drivers). Open the browser.
- Connection URL:
bolt://localhost:7687 - User:
neo4j - Password:
veille2026
Once connected, the top bar accepts three families of input. Cypher queries (anything starting with MATCH, CREATE, MERGE, RETURN…). Browser commands, prefixed with : : :play (built-in mini-tutorials), :schema (list of labels, constraints, indexes), :sysinfo (server state, heap sizes, page cache), :history (history). Finally, EXPLAIN and PROFILE (module 12) prefix a query to visualize its execution plan.
After a RETURN, the result panel offers three icons: graph (visualization), table (columns), text (raw JSON). The table is ideal for checking values; the graph is essential for understanding the topology.
The kit script: 10-premiers-pas.cypher
The kit ships a mini-graph of eleven nodes and fourteen relationships describing the Veille team. Run it with one command from the host shell.
./lab.sh cypher 10-premiers-pas.cypher
The file is mounted read-only in the veille-neo4j container under /cypher. Let's walk through it block by block.
Block 1 — the uniqueness constraints
CREATE CONSTRAINT personne_nom IF NOT EXISTS FOR (p:Personne) REQUIRE p.nom IS UNIQUE;
CREATE CONSTRAINT ville_nom IF NOT EXISTS FOR (v:Ville) REQUIRE v.nom IS UNIQUE;
CREATE CONSTRAINT competence_nom IF NOT EXISTS FOR (c:Competence) REQUIRE c.nom IS UNIQUE;
Three uniqueness constraints on the nom property, one per label. Two effects. First, they guarantee uniqueness: Neo4j will refuse to insert two Personne nodes with the same nom. Second, they implicitly create an index on nom: future MATCH (p:Personne {nom: 'Inès'}) queries become direct lookups, not scans. IF NOT EXISTS makes the command idempotent — you can rerun the script without error.
Since Neo4j 5, you write CREATE CONSTRAINT name IF NOT EXISTS FOR (n:Label) REQUIRE n.prop IS UNIQUE. The older CREATE CONSTRAINT ON ... ASSERT form is deprecated and no longer works in a fresh project.
Block 2 — the nodes
MERGE (:Personne {nom: 'Inès', role: 'lead data', anciennete: 6});
MERGE (:Personne {nom: 'Sami', role: 'data engineer', anciennete: 1});
MERGE (:Personne {nom: 'Léa', role: 'analyste', anciennete: 3});
MERGE (:Personne {nom: 'Karim', role: 'développeur', anciennete: 4});
Each MERGE first attempts a MATCH on the pattern and, if none is found, executes a CREATE. This is Cypher's upsert pattern. Thanks to the constraints from block 1, the lookup is instant. The three cities and four skills follow the same scheme:
MERGE (:Ville {nom: 'Montréal', pays: 'Canada'});
MERGE (:Competence {nom: 'Elasticsearch'});
// and so on
Block 3 — the relationships
Relationships come after the nodes, with the pattern MATCH ... MERGE (a)-[:REL]->(b) :
MATCH (p:Personne {nom: 'Inès'}), (v:Ville {nom: 'Montréal'})
MERGE (p)-[:HABITE {depuis: 2018}]->(v);
The MATCH retrieves both nodes (indexed lookup thanks to the constraint), and the MERGE creates the relationship if it does not already exist. The depuis property carries the year of arrival. MAITRISE relationships carry a niveau from 1 to 5:
MATCH (p:Personne {nom: 'Inès'}), (c:Competence {nom: 'Elasticsearch'})
MERGE (p)-[:MAITRISE {niveau: 5}]->(c);
Finally, ENCADRE materializes the mentorship:
MATCH (a:Personne {nom: 'Inès'}), (b:Personne {nom: 'Sami'})
MERGE (a)-[:ENCADRE]->(b);
Block 4 — the tally
MATCH (n) WITH count(n) AS noeuds
MATCH ()-[r]->() RETURN noeuds, count(r) AS relations;
Expected result at the end of the run:
noeuds | relations
-------+----------
11 | 14
Eleven nodes: 4 people, 3 cities, 4 skills. Fourteen relationships: 4 HABITE, 7 MAITRISE, 3 ENCADRE.
Why MERGE rather than CREATE
CREATE inserts without looking. Rerunning the script twice with CREATE (:Personne {nom: 'Inès'}) would insert two separate Inès nodes, with two different internal ids. On a real dataset, this mistake becomes invisible and costly.
MERGE is the equivalent of an INSERT ... ON CONFLICT DO NOTHING doubled with an implicit SELECT: the row exists, keep it; it does not, create it. The kit script is therefore idempotent: rerunning it always produces the same graph.
MERGE (a)-[:HABITE]->(b) creates the relationship and both nodes if they do not exist. To avoid inventing a node, first do two MATCHes, then a single MERGE on the relationship, as in the kit script. That is the safe pattern.
First read queries
The graph is in place. Open the Browser and run the following queries.
See the whole team:
MATCH (p:Personne) RETURN p.nom, p.role, p.anciennete ORDER BY p.anciennete DESC;
p.nom | p.role | p.anciennete
-------+----------------+-------------
Inès | lead data | 6
Karim | développeur | 4
Léa | analyste | 3
Sami | data engineer | 1
Who lives in Montréal — two-node pattern with a filter on the city:
MATCH (p:Personne)-[:HABITE]->(:Ville {nom: 'Montréal'})
RETURN p.nom;
Expected: Inès, Sami.
Who masters Elasticsearch at level 4 or above — the filter is on the relationship:
MATCH (p:Personne)-[r:MAITRISE]->(:Competence {nom: 'Elasticsearch'})
WHERE r.niveau >= 4
RETURN p.nom, r.niveau ORDER BY r.niveau DESC;
Expected: Inès at level 5.
Who mentors whom — read the relationship in its natural direction:
MATCH (mentor:Personne)-[:ENCADRE]->(mentore:Personne)
RETURN mentor.nom, mentore.nom;
Expected: Inès → Sami, Inès → Léa, Karim → Sami.
Mentorship chain up to two levels — variable-length path from 1 to 2:
MATCH chemin = (mentor:Personne)-[:ENCADRE*1..2]->(mentore:Personne)
RETURN mentor.nom, mentore.nom, length(chemin) AS profondeur
ORDER BY profondeur, mentor.nom;
Here the depth stays at 1 (no mentee mentors anyone in turn). The *1..2 pattern sets up module 12: once the News graph is loaded, paths will really come into their own.
*min..max(a)-[:REL*1..3]->(b) asks Neo4j to follow 1, 2 or 3 REL relationships in the given direction. That is precisely what SQL sorely lacks. In practice, keeping an upper bound avoids queries that explode: *1.. without a bound is technically valid but expensive.
ORDER BY, LIMIT, WHERE and friends
Cypher borrows the familiar SQL keywords with the same semantics.
MATCH (p:Personne)
WHERE p.anciennete > 2 AND p.role CONTAINS 'data'
RETURN p.nom, p.role
ORDER BY p.anciennete DESC
LIMIT 3;
Create with CREATE, modify with SET, delete with DELETE or DETACH DELETE. That last one is crucial: DELETE fails if the node still has relationships, DETACH DELETE removes the node and all its relationships in a single pass.
// Add a skill, fix a role, drop a mentorship
CREATE (:Competence {nom: 'Docker'});
MATCH (p:Personne {nom: 'Sami'}) SET p.role = 'data engineer senior';
MATCH (:Personne {nom: 'Karim'})-[r:ENCADRE]->(:Personne {nom: 'Sami'}) DELETE r;
Never run MATCH (n) DELETE n on a real database: you need either a DETACH DELETE or a batched sweep — which is exactly what 99-reset.cypher does with apoc.periodic.iterate (module 11).
The interactive shell
Neo4j Browser is comfortable for exploration and visualization. To script queries or plug into a pipeline, the kit exposes a native shell:
./lab.sh cypher-shell
You can paste a Cypher block there, end with ; and see the answer as a table. The :begin, :commit, :rollback commands hand you control over transactions; :exit leaves the shell.
Try it 1 — Skills mastered by at least two people
Write the query that lists the skills mastered by at least two members of the Veille team, along with the number of people.
Solution
MATCH (:Personne)-[:MAITRISE]->(c:Competence)
WITH c, count(*) AS n
WHERE n >= 2
RETURN c.nom, n ORDER BY n DESC, c.nom;
On the mini-graph: Neo4j is mastered by Inès and Karim (2), Python by Sami and Karim (2), Elasticsearch by Inès and Sami (2). Kibana does not appear.
Try it 2 — Elasticsearch and Neo4j in Montréal
Find who, in Montréal, masters both Elasticsearch and Neo4j (at any level).
Solution
MATCH (p:Personne)-[:HABITE]->(:Ville {nom: 'Montréal'})
MATCH (p)-[:MAITRISE]->(:Competence {nom: 'Elasticsearch'})
MATCH (p)-[:MAITRISE]->(:Competence {nom: 'Neo4j'})
RETURN p.nom;
Expected: Inès (Sami does not have Neo4j in the mini-graph).
Try it 3 — Add Docker to Sami
Add the "Docker" skill and assign it to Sami at level 2, then query again who masters Docker at level 2 or above.
Solution
MERGE (c:Competence {nom: 'Docker'});
MATCH (p:Personne {nom: 'Sami'}), (c:Competence {nom: 'Docker'})
MERGE (p)-[:MAITRISE {niveau: 2}]->(c);
MATCH (p:Personne)-[r:MAITRISE]->(:Competence {nom: 'Docker'})
WHERE r.niveau >= 2
RETURN p.nom, r.niveau;
Expected: Sami at level 2.
Key takeaways
- A graph is four building blocks: node, label, typed directed relationship, property.
- Cypher uses ASCII-art patterns to read and to write:
(a)-[:REL]->(b). - Setting a uniqueness constraint before loading data gives you a free index and makes the script idempotent.
MERGEdoes an upsert;CREATEinserts blindly; for a rerunnable script,MERGE.DETACH DELETEremoves a node and its relationships in one pass;DELETEalone refuses if the node has relationships.- The Veille mini-graph contains 11 nodes and 14 relationships: 4 people, 3 cities, 4 skills, 4
HABITE, 7MAITRISE, 3ENCADRE. *1..2on a relationship opens variable-length paths — the single keyword that changes everything compared to SQL.
Troubleshooting
- Neo4j Browser refuses the password → the
neo4j-datavolume was created with an older password; changingNEO4J_PASSWORDin.envis not enough →./lab.sh resetthen./lab.sh up. ./lab.sh cypher 10-premiers-pas.cypherreturns "neo4j/cypher/... not found" → you are outside the kit directory →cdintokits/42-elasticsearch-neo4j/before rerunning.:schemashows "No constraints" → the script was not executed or an error stopped the first block →./lab.sh logs neo4j; rerun the script after fixing.- The graph shows five people instead of four → a
CREATEwas used instead ofMERGEduring the workshop →./lab.sh cypher 99-reset.cypherthen rerun10-premiers-pas.cypher.
Further reading
- Official Cypher manual: https://neo4j.com/docs/cypher-manual/current/
MERGEguide: https://neo4j.com/docs/cypher-manual/current/clauses/merge/- Constraints and indexes: https://neo4j.com/docs/cypher-manual/current/constraints/
- Neo4j Browser (
:play,:schema,:sysinfocommands): https://neo4j.com/docs/browser-manual/current/
Next module: model a graph and load it with LOAD CSV, set constraints and indexes, and use the CALL { ... } IN TRANSACTIONS pattern to move from the team mini-graph to the real News graph.