Neo4j — Indexes, Constraints & Query Performance
A graph database's traversals are cheap by default (Chapter 01) — but finding your very first node still needs help. This chapter is indexes, uniqueness constraints, and reading a query plan well enough to know which one you're missing.
Learning objectives
- Create an index on a node property and explain what query shape it speeds up.
- Create a uniqueness constraint and explain how it differs from, and complements, an index.
- Read EXPLAIN and PROFILE output well enough to tell a label scan from an index seek.
◆ The problem
MATCH (p:Person {email: $email}) RETURN p looks instant on a test database with a hundred rows. On a million-Person graph with no index on email, Neo4j has no way to jump straight to the matching node — it has to check every single node labeled :Person, one at a time, comparing its email property until it finds a match (or exhausts all of them). This is a label scan, and it's the graph-database equivalent of a full table scan in SQL: correct, but its cost grows linearly with however many nodes carry that label, regardless of how few actually match your filter.
An index on Person.email turns this into a direct lookup — Neo4j goes straight to the matching node(s) without touching the rest. This matters specifically for the entry point of a query: Chapter 01's index-free adjacency means hops after you've found your starting node are already cheap without any index at all — an index's job is exclusively getting you to that starting node fast in the first place.
CREATE INDEX person_email_index FOR (p:Person) ON (p.email);
This creates a single-property index on Person.email, backing exactly the kind of lookup from §3.1 — MATCH (p:Person {email: $email}) or MATCH (p:Person) WHERE p.email = $email can now use it. Composite indexes across more than one property, and full-text indexes, also exist in Neo4j; reach for the single-property index first, and only add a more specific index type once you have a concrete, measured query shape that needs it.
Indexes aren't free — every CREATE/MERGE/property update on an indexed property also has to update the index, and indexes consume memory and disk. Index the properties your application actually filters or looks nodes up by, not every property a node happens to have.
💻 Code example
CREATE INDEX person_email_index FOR (p:Person) ON (p.email);
▲ Common mistake
Enforcing "one account per email" purely in application code — checking for an existing node before creating a new one — is a race condition waiting to happen: two concurrent signup requests can both check, both find nothing, and both create a node, exactly like the two-customers-one-unit-of-stock race in relational systems. The database-level guarantee is a uniqueness constraint.
CREATE CONSTRAINT person_email_unique FOR (p:Person) REQUIRE p.email IS UNIQUE;
This rejects any CREATE or property update that would result in two Person nodes sharing the same email — enforced by the database itself, not by application logic that can race or simply be forgotten in one code path. A uniqueness constraint also creates a backing index on that property as a side effect, so once it's in place you don't need a separate index on the same property too.
MERGE (p:Person {email: $email}) (Chapter 01) combined with a uniqueness constraint on email is the reliable, concurrency-safe way to guarantee "exactly one node per email" end to end — MERGE handles the common case cleanly, and the constraint is the backstop that makes a duplicate structurally impossible even under concurrent writes.
💻 Code example
CREATE CONSTRAINT person_email_unique FOR (p:Person) REQUIRE p.email IS UNIQUE;
Prefixing any Cypher query with EXPLAIN returns its query plan without running it; prefixing with PROFILE actually runs the query and returns the plan annotated with real row counts and timing per step — the same relationship SQL Mastery, Chapter 11 covers between a plan estimate and EXPLAIN ANALYZE's real numbers.
PROFILE MATCH (p:Person {email: 'alice@example.com'}) RETURN p;
The operator names in the plan tell you exactly what this chapter has been building toward:
| Operator | What it means |
|---|---|
| AllNodesScan | Scanned every node in the entire graph, ignoring labels — almost always a sign a query is missing a label or an index. |
| NodeByLabelScan | Scanned every node with a given label — the label scan from §3.1, meaning no usable index was found for the filter. |
| NodeIndexSeek | Went straight to the matching node(s) via an index — what you want to see for a property-filtered lookup. |
| NodeUniqueIndexSeek | Same as above, backed specifically by a uniqueness constraint's index. |
| Expand(All) | Walked relationships outward from an already-found node — the cheap, index-free hop from Chapter 01. |
The practical habit: run PROFILE on a query that feels slower than it should, and check whether the operator touching your filtered property is a scan or a seek. A NodeByLabelScan where you expected a NodeIndexSeek almost always means the index you thought existed doesn't, or the query isn't written in a way that lets Neo4j use it.
💻 Code example
PROFILE MATCH (p:Person {email: 'alice@example.com'}) RETURN p;
Pulling Chapters 01 through 03 together into one rule of thumb: index the properties your queries start from; don't expect an index to help the traversal that follows.
| Part of the query | Needs an index? | Why |
|---|---|---|
MATCH (p:Person {email: $email}) — the anchor/entry point | Yes | Without one, this is a full label scan (§3.1) |
-[:FOLLOWS*1..3]-> — hops after the anchor | No | Relationships are direct pointer references (Chapter 01, Chapter 02) — an index has nothing to add here |
WHERE candidate.city = 'Pune' — filtering the result of a traversal | Sometimes | Helps if it narrows a large result set; irrelevant if the traversal itself already produced few rows |
▲ Common mistake
Adding an index to every property "just in case" a slow query shows up later is a real cost, not a free safety net — every write to that property now also maintains the index, and an index you never actually query through is pure overhead. Profile first (§3.4), then index the specific property the plan shows you scanning.
Want a visual for this concept?
Generate a diagram tailored to “Neo4j — Indexes, Constraints & Query Performance” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →