Why a Point Index Is Not Being Used

PROFILE shows NodeByLabelScan under a spatial predicate, the index exists, and nothing about the query has changed. This is the single most common spatial-query complaint, and it has six causes worth checking — but they are not equally likely and they are not equally cheap to test. Checked in the wrong order you spend an afternoon rewriting Cypher when the index was in POPULATING all along; checked in the right order, most cases resolve in two queries. This page gives that order, and what confirms each cause rather than merely being consistent with it.

Prerequisites & Versions

Everything here reads the server’s own catalogue and plans; nothing is written.

Requirement Minimum version Install
Python 3.11
neo4j (async driver) 5.20 pip install "neo4j>=5.20"
Neo4j Server 5.15 SHOW INDEXES, PROFILE

Implementation

The diagnostic below runs the checks in ascending cost and stops at the first confirmed cause, so the cheap catalogue queries never wait behind a profile of a slow query.

import asyncio
import re
from dataclasses import dataclass

from neo4j import AsyncGraphDatabase

INDEX_STATE = """
SHOW INDEXES YIELD name, type, state, entityType, labelsOrTypes, properties,
                  populationPercent
WHERE $label IN labelsOrTypes AND $property IN properties
RETURN name, type, state, populationPercent
"""

VALUE_TYPES = """
MATCH (n)
WHERE $label IN labels(n) AND n[$property] IS NOT NULL
RETURN valueType(n[$property]) AS value_type, count(*) AS n
ORDER BY n DESC
LIMIT 10
"""

MISSING = """
MATCH (n) WHERE $label IN labels(n) AND n[$property] IS NULL
RETURN count(n) AS missing
"""


@dataclass(frozen=True)
class Finding:
    cause: str
    confirmed: bool
    detail: str
    fix: str


class IndexDiagnostic:
    def __init__(self, uri: str, auth: tuple[str, str]) -> None:
        self._driver = AsyncGraphDatabase.driver(uri, auth=auth)

    async def close(self) -> None:
        await self._driver.close()

    async def run(self, label: str, prop: str, query: str) -> list[Finding]:
        findings: list[Finding] = []
        async with self._driver.session() as session:
            # 1. Cheapest and most common: is there an ONLINE index at all?
            result = await session.run(INDEX_STATE, label=label, property=prop)
            indexes = [dict(r) async for r in result]

            if not indexes:
                findings.append(Finding(
                    "no index", True,
                    f"nothing indexes :{label}({prop})",
                    f"CREATE POINT INDEX FOR (n:{label}) ON (n.{prop})",
                ))
                return findings

            online = [i for i in indexes if i["state"] == "ONLINE"]
            if not online:
                states = ", ".join(f"{i['name']}={i['state']}" for i in indexes)
                findings.append(Finding(
                    "index not ONLINE", True, states,
                    "wait for population, or drop and recreate a FAILED index",
                ))
                return findings

            # 2. A POINT INDEX and a RANGE INDEX are not interchangeable: a range
            #    index cannot answer a two-dimensional bounding-box seek.
            if not any(i["type"] == "POINT" for i in online):
                kinds = ", ".join(sorted({i["type"] for i in online}))
                findings.append(Finding(
                    "wrong index type", True,
                    f"only {kinds} present; a distance/bbox predicate needs POINT",
                    f"CREATE POINT INDEX FOR (n:{label}) ON (n.{prop})",
                ))

            # 3. Mixed property types poison the index for the rows that differ.
            result = await session.run(VALUE_TYPES, label=label, property=prop)
            types = [dict(r) async for r in result]
            non_point = [t for t in types if "POINT" not in t["value_type"].upper()]
            if non_point:
                findings.append(Finding(
                    "mixed property types", True,
                    "; ".join(f"{t['value_type']}×{t['n']:,}" for t in non_point),
                    "normalise at ingestion — a string coordinate is not indexable "
                    "as a point, and its rows fall out of the seek",
                ))

        # 4. The predicate shape. Checked last because it needs the query text,
        #    and because the catalogue causes above are far more common.
        findings.extend(_predicate_findings(query, prop))
        return findings


def _predicate_findings(query: str, prop: str) -> list[Finding]:
    """Shape checks over the query text.

    Deliberately conservative: these are cheap heuristics meant to point at the
    line worth reading, not a Cypher parser. A false positive costs a glance; a
    false negative just leaves you at the PROFILE, which is where you already were.
    """
    findings: list[Finding] = []
    if re.search(rf"\w+\.{prop}\s*[+\-*/]", query):
        findings.append(Finding(
            "property wrapped in an expression", True,
            "the indexed property has arithmetic applied to it in the predicate",
            "keep the property bare on one side and move the arithmetic into "
            "the parameter, computed client-side",
        ))
    if re.search(rf"toString\s*\(\s*\w+\.{prop}", query):
        findings.append(Finding(
            "property coerced", True,
            "toString() around the indexed property",
            "compare against a point, not against its string form",
        ))
    if not re.search(r"\$\w+", query):
        findings.append(Finding(
            "literals instead of parameters", False,
            "no parameters in the query text",
            "parameterise: literals still seek, but each distinct text compiles "
            "its own plan and evicts the others",
        ))
    return findings


async def main() -> None:
    query = """
    MATCH (h:Hub)
    WHERE point.distance(h.location, $centre) <= $radius
    RETURN h.id
    """
    diag = IndexDiagnostic("neo4j://localhost:7687", ("neo4j", "password"))
    try:
        for f in await diag.run("Hub", "location", query):
            mark = "CONFIRMED" if f.confirmed else "possible"
            print(f"[{mark}] {f.cause}\n    {f.detail}\n    fix: {f.fix}")
    finally:
        await diag.close()

How It Works

The order is the whole point, and it follows from how often each cause occurs against how much it costs to rule out.

Catalogue causes come first because they are free to check and account for most cases. An index that does not exist, or exists in POPULATING or FAILED, explains the symptom completely and is answered by one SHOW INDEXES. A FAILED index is particularly worth knowing about: it stays in the catalogue, so a naive “does the index exist” check says yes, and the planner ignores it entirely.

Index type is next because it is a category error rather than a bug. A RANGE INDEX on a point property is a legitimate index that simply cannot serve a bounding-box seek — range indexes are one-dimensional. A team that created the wrong kind sees an index in the catalogue, an ONLINE state, and a scan in the plan, and reasonably concludes the planner is misbehaving.

Mixed types are third because they are invisible until you look. If ninety per cent of a label stores a POINT and ten per cent stores a string left over from an old importer, the index covers the ninety and the query still has to consider the ten. Depending on version and predicate, the planner may decline the index rather than produce a partial answer.

Predicate shape is last, despite being the cause everyone reaches for first. It is the most expensive to establish — it needs the query text and an understanding of what is seekable — and it is genuinely less common than a stale index in a running system.

Six causes, ordered by how cheap they are to rule out A diagnostic ladder. The first three rungs are answered by a single catalogue query costing milliseconds: no index at all, an index that is not ONLINE, and an index of the wrong type for a two-dimensional seek. The fourth is a cheap aggregation over the label that finds mixed property types. Only the last two need the query text and a profile — a property wrapped in an expression, and literals in place of parameters. The ladder is ordered this way because the catalogue causes are both cheaper to test and more common in a running system than the predicate-shape causes people reach for first. Check in this order — stop at the first confirmed cause cost to check 1 · no index on the property SHOW INDEXES ~1 ms 2 · index POPULATING or FAILED — still in the catalogue same query ~1 ms 3 · RANGE index where a POINT index is required same query ~1 ms 4 · mixed property types across the label one aggregation seconds 5 · property wrapped in an expression, or coerced read the query judgement 6 · literals instead of parameters — seeks, but thrashes the plan cache

Common Failure Patterns

1. Concluding “the planner is wrong” from an index that exists. Existence is three separate conditions — present, ONLINE, and of the right type — and the catalogue reports all three in one row. Reading only the name is how a FAILED index survives an investigation.

SHOW INDEXES YIELD name, type, state, labelsOrTypes, properties, populationPercent
WHERE 'Hub' IN labelsOrTypes AND 'location' IN properties;
-- type must read POINT for a distance or bounding-box predicate
-- state must read ONLINE; POPULATING and FAILED both leave the planner blind

2. Reaching for a hint before checking the catalogue. USING POINT INDEX cannot conjure an index that is not ONLINE, and depending on version it either raises or silently degrades — so the hint changes the symptom without touching the cause. The hint’s real job is narrower, and is covered in forcing index seeks with Cypher planner hints.

3. Testing on a small graph. Below a few thousand nodes the planner will legitimately prefer a scan, because descending an index costs more than reading the label. A query that scans in development and seeks in production is not a bug; a conclusion drawn from the development plan is.

Performance Notes

Two of these causes are transient and worth monitoring rather than diagnosing repeatedly. An index rebuild — after a schema migration, a restore, or a bulk import — leaves the index POPULATING for as long as it takes to scan the label, and every query planned during that window gets a scan plan and caches it. The plan survives the index coming online, so the slowdown outlasts its cause.

CALL db.awaitIndexes(600);   -- block until ONLINE before serving traffic

That single call in a deployment’s readiness check removes the whole class of problem, and it pairs with the page-cache warm-up for the same reason: both are about not serving traffic against a server that is not ready.

The mixed-type cause is worth an ingestion-time constraint rather than a periodic check. A property that must always hold a point should be validated where it is written, because by the time it is queried the offending rows are indistinguishable from the rest without an aggregation over the whole label — which is itself the expensive scan the index was meant to avoid.

$$\text{cost}{\text{diagnose}} \ll \text{cost}{\text{scan}} \quad\text{for causes 1–3, and}\quad \approx \text{cost}_{\text{scan}} \quad\text{for cause 4}$$

That asymmetry is why the order matters at all: the first three checks are free even on a graph where the failing query takes minutes.

An index rebuild's slowdown outlasts the rebuild A timeline across an index rebuild. While the index is POPULATING the planner has no seekable access path, so queries are planned as scans and those plans are cached. When the index comes ONLINE the cached scan plans do not re-plan themselves — they keep being served until something evicts them, so latency stays elevated well past the point where the index was ready. Calling db.awaitIndexes before accepting traffic removes the window entirely, because no query is ever planned against a half-built index. Latency across an index rebuild index POPULATING ONLINE, but scan plans still cached index ready here plans finally evicted the gap nobody expects Investigating during the red band finds an ONLINE index, a correct query, and a scan in the plan — which is exactly the state that sends people rewriting Cypher. db.awaitIndexes before taking traffic removes both bands at once.

This guide is part of Graph Query Planner Optimization, within Spatial Graph Database Fundamentals for Python.