Cypher Query Optimization & Index Lifecycle

A well-designed graph does not guarantee a fast one. The same property graph can answer a query in three database hits or three million, and which one you get is decided entirely by the execution plan the Cypher cost-based planner chooses — a choice driven by the indexes you have provisioned, the statistics the planner holds, and whether your query hands it an index-anchored starting point or forces a full-store scan. This reference is for graph developers, data modelers, and platform teams who have a working schema and now need it to perform under production load. It treats query performance as an engineering lifecycle: measure the plan, provision the right index, back it with a constraint, remove the Cypher anti-patterns that defeat the planner, and keep the plan cache warm so that work is never repeated. Everything here targets Neo4j 5.x and the official Python driver, and every DDL statement is idempotent and safe to replay in CI/CD.

The diagram below shows the path every query takes from source text to an execution plan, and the single decision — is there a usable index? — that separates a seek from a scan:

From Cypher text to an execution plan A Cypher query string is parameterized so literals become $parameters, then checked against the query plan cache. On a cache miss the cost-based planner runs, consulting index and constraint statistics. It compiles an execution plan whose leaf operator is a NodeIndexSeek when a usable index exists, or an AllNodesScan / NodeByLabelScan when none does. The seek costs a few db-hits; the scan costs millions. Cypher text query string Parameterize literals → $params Plan cache hit → reuse plan Usable index? NodeIndexSeek ≈ 3 db-hits AllNodesScan millions yes no
The planner's leaf operator — seek or scan — is the difference between a fast query and an outage.

Read the sections below as a single optimization loop: measure the plan, provision and select indexes, guarantee them with constraints, rewrite the Cypher that defeats them, and cache the result. Each concept below has its own in-depth guide.

Query plan analysis with EXPLAIN and PROFILE

You cannot tune what you cannot see. Neo4j exposes the planner’s decisions through two directives — EXPLAIN renders the estimated plan without running the query, and PROFILE runs it and annotates every operator with real db-hits and row counts. Disciplined query plan analysis is the entry point to every other decision on this page: it tells you whether a query anchors on a NodeIndexSeek or collapses into an AllNodesScan, and where the rows explode.

cypher
// PROFILE runs the query and reports db-hits per operator. The goal is an
// index seek at the leaf and a low total — never an AllNodesScan.
PROFILE
MATCH (c:Customer {customer_id: $cid})-[:INITIATED]->(t:Transaction)
WHERE t.processed_at >= $since
RETURN t.tx_id, t.amount
ORDER BY t.processed_at DESC;

Index selection and composite indexing

Once a plan reveals a scan, the fix is almost always the right index. Neo4j 5.x offers several index types — RANGE, TEXT, POINT, composite, full-text, and token LOOKUP — and each serves a different predicate shape. Deliberate index selection matches the index to the query: a RANGE index for equality and range filters, a composite index for a multi-property filter, a full-text index for tokenized search. Choosing the wrong type leaves the planner with a usable-looking index it will never seek.

cypher
// A composite index backs a two-property filter. Property ORDER matters:
// lead with the higher-selectivity, equality-filtered property.
CREATE INDEX tx_status_time IF NOT EXISTS
FOR (t:Transaction) ON (t.status, t.processed_at);

Constraint lifecycle management

Indexes make queries fast; constraints make them correct — and in Neo4j the two are the same object. Every uniqueness or node-key constraint silently provisions a backing RANGE index the planner can seek, so constraint lifecycle management is simultaneously an integrity discipline and a performance one. Constraints must be created before the first write that depends on them, provisioned asynchronously to the ONLINE state, and evolved without downtime as the model changes.

cypher
// A uniqueness constraint that doubles as the planner's backing index.
// Create it BEFORE the first MERGE so writes are race-safe and index-anchored.
CREATE CONSTRAINT customer_id_unique IF NOT EXISTS
FOR (c:Customer) REQUIRE c.customer_id IS UNIQUE;

Cypher query tuning anti-patterns

The planner is only as good as the query it is handed. A disconnected pattern becomes a cartesian product, an unbounded variable-length path walks the whole connected component, and an OPTIONAL MATCH over sparse data multiplies rows before you filter them. Cataloguing these Cypher query tuning anti-patterns — and their mechanical rewrites — is how you keep a well-indexed schema from being defeated by the query text itself. These are distinct from the structural property graph anti-patterns baked into a schema; here the fix is in the Cypher, not the model.

cypher
// Bound every variable-length traversal and anchor on an index. An unbounded
// (:A)-[:REL*]->(:B) can walk the entire component; *1..4 keeps it planner-friendly.
MATCH path = (a:Account {account_id: $aid})-[:TRANSFERRED_TO*1..4]->(b:Account)
RETURN b.account_id, length(path) AS hops
LIMIT 100;

Query parameterization and plan caching

Compiling a plan is expensive, so Neo4j caches plans keyed on the query string. Inline a literal value and every distinct value mints a new cache entry, evicting good plans and driving the hit-rate toward zero; pass the value as a parameter and one cached plan serves them all. Rigorous query parameterization and plan caching is the cheapest performance win available — a one-line change in application code that collapses cache thrash and stabilizes latency.

python
# Parameterized: one cached plan serves every customer_id. String-formatting the
# id into the query instead would mint a new plan-cache entry per value.
session.execute_read(
    lambda tx: tx.run(
        "MATCH (c:Customer {customer_id: $cid}) RETURN c.status",
        cid=customer_id,
    ).single()
)

Constraints & index lifecycle

The five concerns above converge on one operational routine: provision schema-backing constraints and indexes idempotently, then wait for them to come online before running dependent queries. In Neo4j 5.x all schema DDL supports IF NOT EXISTS, and a uniqueness or node-key constraint provisions its own backing index — so you never create a standalone index on a property that already carries a uniqueness constraint.

cypher
// 1. Identity constraint (also creates a backing RANGE index the planner seeks).
CREATE CONSTRAINT customer_id_unique IF NOT EXISTS
FOR (c:Customer) REQUIRE c.customer_id IS UNIQUE;

// 2. Composite NODE KEY for a tenant-scoped identity.
CREATE CONSTRAINT account_tenant_key IF NOT EXISTS
FOR (a:Account) REQUIRE (a.tenant_id, a.account_id) IS NODE KEY;

// 3. Standalone composite index for a hot two-property filter.
CREATE INDEX tx_status_time IF NOT EXISTS
FOR (t:Transaction) ON (t.status, t.processed_at);

// 4. Relationship-property index (5.x) for edge-filtered traversals.
CREATE INDEX initiated_at_idx IF NOT EXISTS
FOR ()-[r:INITIATED]-() ON (r.initiated_at);

Index creation is asynchronous. After issuing DDL in a migration, poll SHOW INDEXES and block until the index reports ONLINE, or the planner will fall back to a scan for the intervening window:

cypher
// Gate the deploy on index readiness before running dependent queries.
SHOW INDEXES YIELD name, state
WHERE name = 'tx_status_time'
RETURN state;   // block until this returns 'ONLINE'

Query planner implications

Every decision on this page is ultimately a message to the cost-based planner, which selects an execution plan from the statistics it holds about label counts, index selectivity, and relationship-type distribution. Two operators are the red flags to hunt in any PROFILE: an AllNodesScan or NodeByLabelScan at a leaf (no usable index for the anchor), and an Expand(All) fanning millions of rows out of a dense node.

How an index flips the planner's leaf operator Left plan without an index: ProduceResults over Filter over NodeByLabelScan, roughly 1,800,000 db-hits. A central labelled arrow — CREATE INDEX, backing RANGE index — transforms it into the right plan: ProduceResults over NodeIndexSeek, 4 db-hits. Same query, same result, radically different cost. Without index — NodeByLabelScan ProduceResults Filter NodeByLabelScan ≈ 1,800,000 db-hits CREATE INDEX backing RANGE index With index — NodeIndexSeek ProduceResults NodeIndexSeek 4 db-hits
Same query, same result — the backing index rewrites a label scan into an anchored seek.

When the estimated rows in an EXPLAIN plan diverge wildly from the actual rows in a PROFILE, the planner’s statistics are stale — most often after a large bulk load. Re-run planning after ingestion so estimates reflect the new distribution, and consult the initial load performance tuning guide when the load itself is the bottleneck.

Python driver integration pattern

Optimization is not a one-off tuning session; it belongs in the code that runs schema and queries in production. The canonical workflow provisions schema idempotently, waits for indexes to come online, then executes read and write work through managed transactions that retry safely on transient cluster errors. The full driver lifecycle — pooling, routing, and retry — is covered in the Python driver integration patterns reference.

python
from neo4j import GraphDatabase

# Idempotent, replayable on every deploy. Bolt runs ONE statement per call,
# so issue each DDL separately rather than semicolon-batching.
SCHEMA_DDL = [
    "CREATE CONSTRAINT customer_id_unique IF NOT EXISTS "
    "FOR (c:Customer) REQUIRE c.customer_id IS UNIQUE",
    "CREATE INDEX tx_status_time IF NOT EXISTS "
    "FOR (t:Transaction) ON (t.status, t.processed_at)",
]


def ensure_schema(driver) -> None:
    """Provision constraints/indexes, then block until every index is ONLINE."""
    with driver.session(database="neo4j") as session:
        for stmt in SCHEMA_DDL:
            session.run(stmt)
        # Do not query a freshly-created index until it finishes provisioning.
        session.run("CALL db.awaitIndexes(300)")


def customer_status(driver, customer_id: str) -> str | None:
    with driver.session(database="neo4j") as session:
        # Parameterized read → one cached plan; execute_read retries transient errors.
        return session.execute_read(
            lambda tx: tx.run(
                "MATCH (c:Customer {customer_id: $cid}) RETURN c.status AS status",
                cid=customer_id,
            ).single()
        )


def main(uri: str, auth: tuple[str, str]) -> None:
    with GraphDatabase.driver(uri, auth=auth) as driver:
        driver.verify_connectivity()
        ensure_schema(driver)
        print(customer_status(driver, "C-100"))

Anti-patterns & failure modes

Five performance failure modes recur across production Neo4j deployments. Each has a mechanical diagnosis in PROFILE and a fix that lives on this page.

1. The unanchored query. A MATCH with no index-backed starting point forces the planner to scan every node of a label. Diagnose: a NodeByLabelScan or AllNodesScan leaf with a huge db-hits count. Fix: create a backing index or constraint on the anchor property and filter on it, as covered in index selection.

2. Literal-polluted plan cache. Concatenating values into the query string mints a fresh plan per value and evicts good plans. Diagnose: near-zero plan-cache hit-rate; identical query shapes recompiling constantly. Fix: pass every value as a $parameter.

3. The accidental cartesian product. Two disconnected MATCH patterns multiply into N×M rows before any filter runs. Diagnose: a CartesianProduct operator in the plan and an exploding row count. Fix: connect the patterns or pipeline them with WITH.

4. The unbounded traversal. A variable-length pattern with no upper bound can walk the entire connected component. Diagnose: an Expand(All) with db-hits far above the result size. Fix: bound the depth (*1..4) and anchor on an indexed node.

5. The redundant index. A standalone index created on a property that already has a uniqueness constraint is dead weight the planner ignores while it still costs write time to maintain. Diagnose: SHOW INDEXES shows two indexes on the same label/property. Fix: drop the standalone index and rely on the constraint’s backing index.