Forcing Index Seeks with Cypher Planner Hints
The symptom is narrow and infuriating: a proximity query that ran as a PointIndexSeekByRange in staging quietly regresses to a NodeByLabelScan in production, and p99 latency triples overnight. Nothing in the query text changed. What changed is the statistics the cost-based planner reads — a bulk load skewed the row estimates, or an index came online after the plan was already cached — and the optimizer now believes a full scan is cheaper than a seek it can no longer cost correctly. A USING hint pins the planner to the index, restoring the seek. This page shows how to apply USING INDEX, USING POINT INDEX, USING SCAN, and USING JOIN to a spatial predicate from async Python — and, more importantly, why reaching for a hint before you have fixed the predicate shape usually hides the real defect. If your query never seeked in the first place, start with optimizing Cypher query plans for spatial data; a hint cannot rescue a non-sargable predicate.
Prerequisites & Versions
| Requirement | Minimum version | Note |
|---|---|---|
| Python | 3.10+ | list[str] generics used in the plan walker |
| Neo4j | 5.9+ | Typed index hints (USING POINT INDEX, USING RANGE INDEX) land in the 5.x line |
| neo4j (driver) | 5.x | AsyncGraphDatabase, result.consume() profile access |
| A point index on the filtered property | n/a | CREATE POINT INDEX ... ON (w.location), state ONLINE |
pip install "neo4j>=5.18"
A hint only names an index; it does not create one. Provision the point index first, and confirm the property is stored as a native point — the index and the CRS foundation are covered in spatial indexing strategies.
Implementation
The script below profiles a distance query twice against the same warehouse graph: once as the planner compiles it unaided, once with USING POINT INDEX forcing the seek. It walks the profile tree returned by result.consume().profile, reports the base operator of each plan, and asserts that the hint produced a seek rather than a scan.
// One-time: the index the hint will name. Without an ONLINE point index,
// USING POINT INDEX raises a SyntaxError at plan time, not a silent fallback.
CREATE POINT INDEX warehouse_location IF NOT EXISTS
FOR (w:Warehouse) ON (w.location);
import asyncio
from neo4j import AsyncGraphDatabase
# Same predicate, two plans. The hint sits between MATCH and WHERE and names
# the label/property pair the point index is built on.
UNHINTED = """
MATCH (w:Warehouse)
WHERE point.distance(w.location,
point({srid: 4326, latitude: $lat, longitude: $lon})) <= $radius
RETURN w.id AS id ORDER BY w.id
"""
HINTED = """
MATCH (w:Warehouse)
USING POINT INDEX w:Warehouse(location)
WHERE point.distance(w.location,
point({srid: 4326, latitude: $lat, longitude: $lon})) <= $radius
RETURN w.id AS id ORDER BY w.id
"""
def base_operators(plan: dict) -> list[str]:
"""Collect leaf operator types from a driver profile/plan tree."""
leaves: list[str] = []
def walk(node: dict) -> None:
children = node.get("children", [])
if not children:
leaves.append(node.get("operatorType", "?"))
for child in children:
walk(child)
walk(plan)
return leaves
async def profile_base(session, query: str, **params) -> list[str]:
result = await session.run(f"PROFILE {query}", **params)
await result.consume() # drain rows so the profile is populated
summary = await result.consume()
return base_operators(summary.profile)
async def main() -> None:
driver = AsyncGraphDatabase.driver(
"neo4j://localhost:7687",
auth=("neo4j", "password"),
max_connection_pool_size=20,
connection_acquisition_timeout=5.0,
)
params = {"lat": 43.6532, "lon": -79.3832, "radius": 4000.0}
try:
async with driver.session(database="neo4j") as session:
before = await profile_base(session, UNHINTED, **params)
after = await profile_base(session, HINTED, **params)
print("unhinted base:", before)
print("hinted base: ", after)
assert any("IndexSeek" in op for op in after), (
"hint failed to force a seek — predicate is not sargable"
)
finally:
await driver.close()
if __name__ == "__main__":
asyncio.run(main())
Run it against a graph whose statistics favour a scan and the first line prints ['NodeByLabelScan'] while the second prints a seek operator such as ['PointIndexSeekByRange']. The assertion is the load-bearing part: a hint that is silently ignored leaves the base operator unchanged, and only a plan-shape check catches that.
USING POINT INDEX (right). The hint only relocates the base operator from a scan to a seek — it changes nothing about the predicate itself.How It Works
A hint is a directive to the planner, evaluated at compile time, that constrains which starting operator it may choose. Neo4j recognises four families, and each fits a different failure:
USING INDEX w:Warehouse(location)tells the planner to enter through an index on that label/property.USING POINT INDEXandUSING RANGE INDEXare the typed forms that additionally pin which kind of index — essential when a property carries both a point and a range index and the planner picks the wrong one for a spatial predicate.USING SCAN w:Warehousedoes the opposite: it forbids the index and forces a label scan. That is the right hint when the predicate matches almost every node, so a seek’s per-row index descent costs more than a straight scan would.USING JOIN ON wforces a hash join at a named variable instead of an expand. On a two-endpoint route query it lets the planner seek an index at both the origin and destination and join in the middle, rather than seeking one end and expanding the whole corridor.
The hint changes only the shape the planner is allowed to consider; it never changes what the predicate can express. That is the entire reason a hint is a last resort. A point.distance(w.location, $p) <= r predicate is sargable — the point index can seek a bounding region for it — so a hint sticks. But wrap the indexed property in arithmetic, and no hint in the language can force a seek, because the seekable form no longer exists in the query. Fix the predicate first; hint only when a correctly-shaped predicate still mis-plans because the statistics are wrong. The systematic before-and-after diffing that tells you which case you are in is covered in reading EXPLAIN and PROFILE plans for spatial queries.
Common Failure Patterns
1. The hint is silently ignored because the predicate is not sargable. Adding USING POINT INDEX to a query whose filter wraps the indexed property — point.distance(w.location + $shift, $p) or a toString(w.location) comparison — does nothing, because the planner has no seekable form to honour. Depending on version it either raises Cannot use index hint ... no matching predicate or degrades back to a scan. The fix is upstream of the hint: keep w.location bare on one side and move all math into the parameter.
// Wrong — property is wrapped, hint cannot bind, plan falls back to a scan.
MATCH (w:Warehouse) USING POINT INDEX w:Warehouse(location)
WHERE point.distance(point({x: w.location.x + $dx, y: w.location.y}), $p) <= $r
// Right — bare property; precompute the shifted target in Python and pass $p.
MATCH (w:Warehouse) USING POINT INDEX w:Warehouse(location)
WHERE point.distance(w.location, $p) <= $r
2. The hint pins a plan that was optimal only when the data was small. A USING INDEX that made sense at ten thousand nodes can force a seek that touches most of the graph once the label grows and the predicate turns unselective — the seek’s descent-per-row now costs more than the scan the planner would have chosen unaided. A pinned plan opts out of adaptive re-costing, so it never self-corrects. Re-profile hinted queries after any order-of-magnitude data growth and delete hints that no longer beat the unhinted plan.
3. Over-hinting freezes a plan the optimizer would improve. Stacking USING INDEX on every MATCH plus a USING JOIN turns the planner into a stenographer: it stops exploring and simply transcribes your instructions, including the parts that a later engine upgrade would have planned better. Hint the single operator that is mis-chosen, verify with PROFILE, and leave the rest of the plan free.
Performance Notes
The hint does not make the seek faster — it makes the planner choose the seek. The payoff is the selectivity gap between the two plans. Let $N$ be the labeled node count, $s$ the fraction inside the radius, $c_s$ the per-node scan cost, and $c_i$ the per-row index-descent cost:
$$ C_{\text{scan}} \approx N,c_s \qquad C_{\text{seek}} \approx \log_b N + sN,c_i $$
The seek wins whenever $sN,c_i + \log_b N < N,c_s$, i.e. roughly when $s < c_s / c_i$. For a few-kilometre radius over a city-scale graph $s \ll 1$ and the seek is dramatically cheaper — exactly the case where a mis-costing planner needs the hint. But note the crossover: once $s$ approaches $c_s/c_i$ the scan is genuinely cheaper, which is when USING SCAN becomes the correct hint rather than USING POINT INDEX. A hint is only ever right on the side of the crossover it pins you to, so measure $s$ for your real radius before committing one. Broader cache and configuration tuning around these plans lives in Cypher performance tuning.
Related
- Optimizing Cypher Query Plans for Spatial Data — reshape the predicate so it seeks without a hint at all.
- Reading EXPLAIN and PROFILE Plans for Spatial Queries — confirm the hint took effect and diff the operator tree.
- Spatial Indexing Strategies — provision the point index a hint names.
- Cypher Performance Tuning — cache, memory, and config around hinted spatial queries.
This guide is part of Graph Query Planner Optimization, within the Spatial Graph Database Fundamentals for Python reference.