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.

Predicate shape to index type decision matrix A two-column table. The left column lists six predicate shapes: exact equality on one property, range or ordering comparison, equality on two or more properties combined with AND, substring or fuzzy or tokenised text search, spatial distance or bounding-box test, and a bare label match with no property. The right column names the index type each shape resolves to: RANGE or TEXT, RANGE, composite RANGE, FULLTEXT, POINT, and LOOKUP token index respectively. Query predicate shape Index the planner uses n.id = $v · exact equality on one property RANGE (or TEXT) n.ts >= $t · range scan, ORDER BY RANGE n.a = $x AND n.b = $y · multi-property COMPOSITE CONTAINS / fuzzy / tokenised search FULLTEXT point.distance / point.withinBBox POINT MATCH (n:Label) · label-only, no property LOOKUP (token) Selectivity breaks ties When several indexes match, the planner prefers the one estimated to yield the fewest rows.
Match the predicate shape to the index type first; let estimated selectivity break any remaining tie.

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:

  1. 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.
  2. 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.
  3. 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.

cypher
// 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.

cypher
// 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.

cypher
// 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.

python
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.

cypher
// 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 EXPLAIN track 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 point answers distance and bbox directly — the trade-off is analysed in choosing RANGE vs POINT index for spatial queries.

Known Pitfalls

  1. Duplicating a constraint’s backing index. Symptom: SHOW INDEXES lists 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.

  2. 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, and STARTS WITH, but a mid-string CONTAINS degenerates to a scan. Fix: create a FULLTEXT index and query it through db.index.fulltext.queryNodes.

  3. 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.

  4. 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.