Index Selection & Composite Indexing
An index in Neo4j 5.x is not a switch you flip for “faster queries” — it is a data structure the cost-based planner may or may not choose, and choosing the wrong type leaves you paying for storage and write amplification while the planner quietly falls back to a scan. Neo4j 5.x ships six distinct index kinds — RANGE, TEXT, POINT, composite (multi-property), FULLTEXT, and LOOKUP (token) — and each answers a different predicate shape. This reference gives graph developers and platform teams a deterministic method for mapping a WHERE clause to the one index that serves it, reasoning about selectivity and cardinality the way the planner does, and provisioning every index idempotently so migrations replay cleanly.
Prerequisite Concepts
Index selection sits on top of two foundations, and getting it right presumes both are already stable:
- The optimisation context. Indexes are one lever inside the broader Cypher query optimization and index lifecycle discipline. Read that reference first to understand where index choice fits between plan analysis, parameterisation, and constraint management.
- Constraint-backed identity. A uniqueness or node-key constraint silently provisions its own backing index, which is why the constraint lifecycle management rules determine when a standalone index is redundant. Never create an index that a constraint already covers.
- Native property typing. An index is only usable when the property it covers carries a type the index understands, so deliberate graph data type selection — native
datetime,point, numeric primitives — is what keeps a property eligible for a RANGE, POINT, or composite index instead of forcing a scan. - Plan reading. You confirm every index decision by inspecting the plan; comfort with query plan analysis using EXPLAIN and PROFILE is assumed throughout.
Conceptual Model
The planner treats index selection as a lookup keyed on the shape of the predicate. An equality or range comparison against an ordered value routes to a RANGE index; a tokenised or fuzzy string search routes to FULLTEXT; a geospatial distance or bounding-box test routes to POINT; and a bare label match with no property predicate routes to the token LOOKUP index. The matrix below is the decision the planner makes internally — and the one you should make deliberately at design time.
Design Rules
The following table is the authoritative mapping. Read the left column as the query you are optimising and the right columns as the index to create and the reason the planner will pick it.
| Predicate shape | Index type | Why the planner chooses it |
|---|---|---|
Exact equality on one scalar (c.email = $e) |
RANGE (default) | Sorted key structure resolves equality with a NodeIndexSeek; also serves STARTS WITH. |
Range or ordering (t.ts >= $from, ORDER BY t.ts) |
RANGE | Ordered leaves let the seek return an already-sorted stream, removing a Sort operator. |
Two or more equality/range predicates (WHERE a=$x AND b=$y) |
Composite (multi-property) | One structure keyed on the property tuple; resolves both predicates in a single seek. |
| Case-insensitive or normalised exact match | TEXT | Optimised for string equality and STARTS WITH/ENDS WITH/CONTAINS on a single property. |
| Substring, fuzzy, relevance-ranked, or multi-property search | FULLTEXT | Tokenises values into a Lucene inverted index; the only index that serves CONTAINS efficiently at scale and returns a score. |
Distance or bounding-box on a point (point.distance(...) < r) |
POINT | Space-filling-curve index answers proximity and bbox tests a RANGE index on raw coordinates cannot. |
| Bare label match with no property predicate | LOOKUP (token) | The token index lets MATCH (n:Label) seek by label instead of an AllNodesScan; one exists by default. |
Three rules cut across the table:
- One predicate, one index type — never stack redundant kinds. A property covered by a uniqueness constraint already has a backing RANGE index; adding a standalone RANGE index on the same property is dead weight the planner ignores.
- Composite before multiple singles. Two single-property indexes force the planner to seek one and filter the other. A composite index keyed on both resolves the whole conjunction in one seek — provided the property order is correct.
- Text search is a different problem.
CONTAINS/fuzzy matching against a RANGE or TEXT index still scans; only a FULLTEXT index tokenises. Deciding between them is the crux of composite index vs full-text index selection.
Step-by-Step Implementation
Step 1 — Inventory what already exists
Before creating anything, list current indexes and their state. A constraint-backed index shows a populated owningConstraint; that property is already covered and needs no standalone index.
// Every index, its type, the properties it covers, and its readiness.
SHOW INDEXES YIELD name, type, entityType, labelsOrTypes, properties, state, owningConstraint
RETURN name, type, labelsOrTypes, properties, state, owningConstraint
ORDER BY type, name;
Any index whose state is not ONLINE is still populating and will not be used by the planner. Any index with a non-null owningConstraint is provisioned by a constraint — do not duplicate it.
Step 2 — Create the index matching each predicate
Every statement uses IF NOT EXISTS so a migration replays cleanly, and each is issued as a single Bolt call — Neo4j 5.x does not accept semicolon-batched DDL over the driver.
// RANGE (the default): exact equality and range/ordering on one property.
CREATE INDEX customer_email_range IF NOT EXISTS
FOR (c:Customer) ON (c.email);
// COMPOSITE: a two-property conjunction resolved in a single seek. Order matters —
// lead with the equality-filtered, higher-selectivity property.
CREATE INDEX tx_status_time IF NOT EXISTS
FOR (t:Transaction) ON (t.status, t.processed_at);
// TEXT: single-property string index for CONTAINS / STARTS WITH / ENDS WITH.
CREATE TEXT INDEX product_sku_text IF NOT EXISTS
FOR (p:Product) ON (p.sku);
// POINT: geospatial distance and bounding-box predicates on a native point.
CREATE POINT INDEX store_location_point IF NOT EXISTS
FOR (s:Store) ON (s.location);
// FULLTEXT: tokenised, relevance-ranked search across one or more properties.
CREATE FULLTEXT INDEX customer_search IF NOT EXISTS
FOR (c:Customer) ON EACH [c.display_name, c.email];
// LOOKUP (token): one node-label and one relationship-type index exist by default.
// Recreate only if a prior migration dropped them.
CREATE LOOKUP INDEX node_label_lookup IF NOT EXISTS
FOR (n) ON EACH labels(n);
Step 3 — Gate the migration on ONLINE state
Index creation is asynchronous. CREATE INDEX returns before the index finishes populating on a non-empty database, and a query issued in that window plans a scan. Block the deploy until every index is live.
// Deterministic wait — returns once all indexes reach ONLINE or the timeout fires.
CALL db.awaitIndexes(300);
Step 4 — Provision idempotently from Python
The driver pattern issues one DDL statement per call, waits for readiness, then runs the dependent workload — the same replay-safe shape used across the migration reference.
from neo4j import GraphDatabase
# One statement per Bolt call; every statement is idempotent on replay.
INDEX_DDL = [
"CREATE INDEX customer_email_range IF NOT EXISTS "
"FOR (c:Customer) ON (c.email)",
"CREATE INDEX tx_status_time IF NOT EXISTS "
"FOR (t:Transaction) ON (t.status, t.processed_at)",
"CREATE POINT INDEX store_location_point IF NOT EXISTS "
"FOR (s:Store) ON (s.location)",
"CREATE FULLTEXT INDEX customer_search IF NOT EXISTS "
"FOR (c:Customer) ON EACH [c.display_name, c.email]",
]
def ensure_indexes(uri: str, auth: tuple[str, str]) -> None:
with GraphDatabase.driver(uri, auth=auth) as driver:
driver.verify_connectivity()
with driver.session(database="neo4j") as session:
for stmt in INDEX_DDL:
session.run(stmt).consume()
# Gate on readiness before any dependent query runs in this deploy.
session.run("CALL db.awaitIndexes(300)").consume()
Constraint & Validation Layer
Index correctness is a claim you verify against the plan, not an assumption. The decisive test is that the leaf operator is a seek, not a scan, and that db-hits are proportional to rows returned rather than to store size.
// Prove the planner uses the intended index. Expect NodeIndexSeek at the leaf.
PROFILE
MATCH (t:Transaction)
WHERE t.status = $status AND t.processed_at >= $since
RETURN t.tx_id
ORDER BY t.processed_at DESC;
A NodeByLabelScan followed by a Filter is the fingerprint of a missing, wrong-typed, or not-yet-online index. Two invariants keep the layer honest: every property you filter on in a hot path resolves to a seek, and no index duplicates a constraint’s backing index — confirm the second with the owningConstraint column from Step 1. Where identity uniqueness is the concern, the backing index is provisioned by the constraint itself, governed under constraint lifecycle management.
Performance & Scale Considerations
- Selectivity is the tie-breaker. When several indexes could serve a query, the planner estimates how many rows each returns and prefers the most selective. A RANGE index on a low-cardinality boolean-like property (two distinct values across ten million nodes) barely narrows the search; the planner may still choose a scan. Reserve indexes for properties with high distinct-value counts, and lead composite keys with the most selective property.
- Cardinality drives the plan, and statistics can go stale. After a bulk load the planner’s row estimates reflect the old distribution until statistics refresh, so a query that seeked yesterday may scan today. Re-run planning after large loads and confirm the estimated rows in
EXPLAINtrack reality. - Every index is a write tax. Each index must be maintained on every insert, update, and delete of a covered property. A composite index over two properties is cheaper to maintain than two single-property indexes and answers the conjunction better — prefer it. FULLTEXT indexes carry the heaviest write cost because they tokenise on every write; do not create one for predicates a RANGE or TEXT index already serves.
- POINT vs RANGE for coordinates. A RANGE index on latitude and longitude stored as two floats cannot answer a radius query without scanning a bounding band and post-filtering. A POINT index on a native
pointanswers distance and bbox directly — the trade-off is analysed in choosing RANGE vs POINT index for spatial queries.
Known Pitfalls
-
Duplicating a constraint’s backing index. Symptom:
SHOW INDEXESlists two indexes on the same label and property, and the standalone one never appears in any plan. Root cause: a uniqueness or node-key constraint already provisioned a RANGE index, and a migration added another. Fix: drop the standalone index; rely on the constraint’s backing index, which the planner treats identically. -
Expecting a RANGE index to serve
CONTAINS. Symptom: a substring search still shows a full scan despite a RANGE (or TEXT) index existing. Root cause: only a FULLTEXT index tokenises values; RANGE serves equality, ranges, andSTARTS WITH, but a mid-stringCONTAINSdegenerates to a scan. Fix: create a FULLTEXT index and query it throughdb.index.fulltext.queryNodes. -
Wrong composite property order. Symptom: a two-property index exists but a query filtering only the second property plans a scan. Root cause: a composite index is only usable for a prefix of its key, so a predicate on the trailing property alone cannot seek it. Fix: lead the key with the property that is always equality-filtered, per ordering properties in a composite index correctly.
-
Querying before ONLINE. Symptom: the first queries after a deploy scan, then later runs seek. Root cause: the workload ran while the index was still populating. Fix: gate on
CALL db.awaitIndexes(...)before any dependent query, as in Step 3.
Related
- Cypher Query Optimization & Index Lifecycle — the parent reference this index-selection method belongs to.
- Constraint Lifecycle Management — how constraint-backed indexes make standalone indexes redundant.
- Composite Index vs Full-Text Index Selection — choosing between exact multi-property and tokenised search.
- Choosing RANGE vs POINT Index for Spatial Queries — when geospatial predicates require a POINT index.
- Ordering Properties in a Composite Index Correctly — leading-property selectivity and usable key prefixes.
- Graph Data Type Selection — native typing that keeps a property index-eligible.