Optimizing Cypher Query Plans for Spatial Data

Logistics routing engines and mobility analytics platforms routinely see p99 latency blow out when a spatial proximity filter sits in the same MATCH clause as a relationship expansion. The exact symptom is a PROFILE operator tree where a NodeByLabelScan feeds straight into Expand(All), with the point.distance() predicate demoted to a trailing Filter. The root cause is the cost-based optimizer refusing to anchor the spatial index when a distance function wraps a point property, so every candidate node is materialized before the radius constraint runs. This page resolves that by splitting the query into a deterministic two-phase plan — index-anchored spatial lookup first, traversal second — and shows the async Python that drives it. It is the planner-level counterpart to the index design covered in spatial indexing strategies.

Eager versus isolated Cypher operator trees for a spatial proximity query Left: the eager plan reads NodeByLabelScan into Expand(All) into a trailing Filter on point.distance, materializing every node and producing millions of DbHits. Right: the isolated plan reads NodeIndexSeekByRange on hub_location_idx, crosses a WITH planner barrier, then runs Expand(Into) on only the qualifying hubs, producing sub-500 DbHits. Eager plan — predicate post-filtered single MATCH, distance wraps the point property NodeByLabelScan (h:LogisticsHub) — every labeled node Expand(All) walks :SERVES from each candidate Filter point.distance(…) ≤ r runs last millions of DbHits cost ≈ 10⁴ · scales with N Isolated plan — index-anchored seek spatial lookup split behind a WITH barrier NodeIndexSeekByRange hub_location_idx — hubs inside radius WITH hub eager planner barrier · row set resolved Expand(Into) :SERVES on qualifying hubs only sub-500 DbHits cost ≈ log N + sN · scales with density PROFILE operator order — data flows downward · same query, two plan shapes
The same proximity query compiled two ways: the eager plan defers point.distance() to a trailing Filter over every node, while the WITH barrier forces an index seek that materializes only hubs inside the radius.

Prerequisites & Versions

Library / Component Min version Install / provision
Python 3.10 pyenv install 3.10 (needs tuple[str, str] syntax)
neo4j async driver 5.14 pip install "neo4j>=5.14"
Neo4j server 5.x point index requires the 5.x range-index engine
A point index on the filtered property n/a CREATE INDEX ... FOR (h:LogisticsHub) ON (h.location)

The point index is non-negotiable: without an ONLINE index on the property you filter, the planner has nothing to seek against and will fall back to a scan regardless of how the query is shaped.

Implementation

The script below is self-contained: it provisions the index, then runs the isolated two-phase query through the async driver. Reuse a single SpatialRoutingEngine per process so the connection pool is shared across requests.

// Run once during migration. The planner can only seek a property
// that carries an ONLINE point index.
CREATE INDEX hub_location_idx IF NOT EXISTS
FOR (h:LogisticsHub) ON (h.location);
import asyncio
from neo4j import AsyncGraphDatabase
from neo4j.spatial import WGS84Point


class SpatialRoutingEngine:
    """Two-phase spatial routing queries with a shared async pool."""

    def __init__(self, uri: str, auth: tuple[str, str], pool_size: int = 20):
        self.driver = AsyncGraphDatabase.driver(
            uri,
            auth=auth,
            max_connection_pool_size=pool_size,
            connection_acquisition_timeout=10.0,
            max_transaction_retry_time=15.0,
        )

    async def ensure_index(self) -> None:
        async with self.driver.session() as session:
            await session.run(
                "CREATE INDEX hub_location_idx IF NOT EXISTS "
                "FOR (h:LogisticsHub) ON (h.location)"
            )
            # Block until the index finishes building before the first query.
            await session.run("CALL db.awaitIndexes(300)")

    async def find_service_zones(self, lat: float, lon: float, radius_m: float):
        # WGS84Point takes positional (x, y) = (longitude, latitude). The driver
        # serialises it directly over Bolt — no string parsing on the server.
        target = WGS84Point((lon, lat))

        # Phase 1 isolates the spatial seek behind a WITH boundary;
        # phase 2 expands only the qualifying hubs.
        query = """
        MATCH (hub:LogisticsHub)
        WHERE point.distance(hub.location, $target) <= $radius
        WITH hub
        MATCH (hub)-[:SERVES]->(zone:DeliveryZone)
        RETURN zone.id AS zone_id, zone.name AS zone_name,
               count(*) AS route_count
        ORDER BY route_count DESC
        """

        async def _tx_func(tx):
            result = await tx.run(query, target=target, radius=radius_m)
            return await result.data()

        async with self.driver.session() as session:
            return await session.execute_read(_tx_func)

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


async def main():
    engine = SpatialRoutingEngine(
        "neo4j://localhost:7687", ("neo4j", "password")
    )
    try:
        await engine.ensure_index()
        zones = await engine.find_service_zones(52.5200, 13.4050, 5000.0)
        for row in zones:
            print(row)
    finally:
        await engine.close()


if __name__ == "__main__":
    asyncio.run(main())

How It Works

The whole optimization hinges on the WITH hub boundary and the order of the two MATCH clauses — read the query against the operator tree it produces.

  • The WITH clause is a planner barrier. It forces the spatial predicate to resolve to a complete row set before traversal begins. Instead of NodeByLabelScan -> Expand -> Filter, the planner emits NodeIndexSeekByRange against hub_location_idx, materializing only hubs inside the radius before the SERVES expansion runs. This is the same predicate-pushdown discipline that the distance filter query patterns cluster applies at the pattern level.
  • WGS84Point((lon, lat)) keeps the CRS aligned. The driver serializes the point as native Bolt structure, so the server-side index — also WGS-84 — compares like with like. A mismatched or stringified coordinate triggers a runtime conversion that silently disables the seek.
  • point.distance() stays a great-circle (geodesic) measure on WGS-84 points, returning meters. The seek narrows candidates by bounding region; the exact distance still runs, but only on the reduced set the index handed back.
  • db.awaitIndexes gates the first query. Querying before the index is ONLINE makes the planner choose a scan and cache that plan — a cold-start trap covered under failure patterns below.
The WITH boundary is what forces the spatial predicate to resolve before traversal Two operator trees for the same question. Without the WITH boundary the planner is free to interleave, so a label scan feeds the SERVES expansion and the distance filter runs above it, on rows that were produced by walking relationships from every hub in the label. With the WITH boundary the spatial predicate must resolve to a complete row set first, so the base operator is a point index seek that materialises only the hubs inside the radius, and the expansion runs from that reduced set. no WITH boundary WITH hub before the expansion read bottom-up read bottom-up NodeByLabelScan 180,000 hubs Expand(All) :SERVES 1,240,000 rows Filter point.distance() 1,240,000 distance computations 312 rows returned PointIndexSeekByRange 410 hubs in the bounding region Filter point.distance() 410 distance computations Expand(All) :SERVES 2,800 rows 312 rows returned Same answer, same 312 rows. The difference is that one tree computes 1.24 million great-circle distances to discard almost all of them.

For a deterministic SLA you can pin the plan with a hint:

MATCH (hub:LogisticsHub)
USING INDEX hub:LogisticsHub(location)
WHERE point.distance(hub.location, $target) <= $radius
WITH hub
MATCH (hub)-[:SERVES]->(zone:DeliveryZone)
RETURN zone.id, count(*) AS route_count

USING INDEX anchors the execution plan explicitly and prevents the planner reverting to a scan after a statistics refresh — at the cost of disabling adaptive planning for that query.

Common Failure Patterns

1. The predicate is still post-filtered despite the WITH. If PROFILE shows a Filter on point.distance() after a label scan, the property is not indexed or the parameter CRS does not match the index. Confirm both, then re-profile:

SHOW INDEXES YIELD name, type, state, properties
WHERE 'location' IN properties;   // expect type RANGE/POINT, state ONLINE

2. Arithmetic or a function wraps the indexed property. Writing WHERE point.distance(hub.location + $offset, $target) <= r (or any expression on hub.location) makes the property non-sargable, so the seek is lost. Keep the indexed property bare on one side and move all math into the parameter you pass from Python.

3. A stale plan from a cold start. A query compiled before the index came ONLINE is cached as a scan and reused. Force a recompile after index changes:

CALL db.clearQueryCaches();

Performance Notes

A typical eager plan on a metropolitan graph exhibits cost in the tens of thousands with millions of DbHits; the isolated plan drops to the sub-500 range, with hits scaling against index density rather than total node count. The mechanism is selectivity. Let $N$ be the labeled node count, $s$ the fraction inside the search radius, $r$ the radius, $A_{\text{idx}}$ the indexed extent, $c_d$ the per-node distance cost, and $\bar{d}$ the mean out-degree:

$$ C_{\text{eager}} \approx N,(c_d + \bar{d}) \qquad C_{\text{isolated}} \approx \log_b N + sN,(c_d + \bar{d}), \quad s \approx \frac{\pi r^2}{A_{\text{idx}}} $$

The isolated plan wins whenever $s \ll 1$ — the normal case for a few-kilometre radius over a city-scale graph. Two budget caveats:

  • Memory at the WITH boundary. Materializing the qualifying hubs allocates heap proportional to $sN$. When a wide radius pulls in thousands of nodes, that intermediate set can trigger GC pauses; constrain it with a tighter radius or a tenant boundary, as in node and edge spatial mapping, before the seek runs.
  • When to switch strategies. Past roughly $s > 0.3$ the seek’s selectivity advantage erodes and a bounding-box pre-filter or geohash bucketing becomes cheaper — the trade-off quantified in implementing geohash vs quadtree indexing in Neo4j. Broader query-cache and config tuning lives in Cypher performance tuning.

Validate every change by diffing EXPLAIN/PROFILE output before and after: confirm the NodeByLabelScan became a NodeIndexSeekByRange and that DbHits fell by an order of magnitude.

It is worth being precise about what the barrier does and does not guarantee, because WITH is often described as “forcing” an order and that is a little stronger than the truth. WITH establishes a scope boundary: everything before it must produce a complete row set before anything after it runs. That is enough to stop the planner from interleaving the expansion into the spatial resolution, which is the reordering this query needs prevented. It is not a promise that the resolution itself will use the index — if the predicate above the barrier is not seekable, the barrier simply guarantees a complete label scan before the expansion instead of an interleaved one. The barrier and the seekable predicate are two requirements, and satisfying one without the other buys nothing.

The same caution applies to reading the improvement. A plan that moves from scan-then-expand to seek-then-expand will show a large drop in DbHits and usually in wall time, but the number to check afterwards is the ratio of rows entering the final filter to rows leaving it. A seek that hands 400 candidates to a distance filter which keeps 312 is working as intended; one that hands over 40,000 to keep the same 312 is a seek in name only — the bounding region is far larger than the radius, typically because the box was computed with a fixed degree offset rather than one corrected for latitude. That correction matters more the further from the equator the data sits, and it is invisible in the plan, which reports a healthy index seek in both cases.

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