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.
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.
Related
- Reading EXPLAIN and PROFILE Plans for Spatial Queries — reading the operator that started this investigation.
- Forcing Index Seeks with Cypher Planner Hints — what a hint can fix once the catalogue is healthy.
- Composite Index Key Order for Spatial Filters — a seek that happens but returns far too much.
- Spatial Indexing Strategies — choosing the index type this page checks for.
This guide is part of Graph Query Planner Optimization, within Spatial Graph Database Fundamentals for Python.