Choosing RANGE vs POINT index for spatial queries

You are storing locations on your nodes and running proximity queries — “stores within 5 km of the user,” “sensors inside this bounding box” — and the query is slow because you indexed the coordinates the wrong way. The decision is narrow but consequential: a RANGE index on scalar latitude/longitude numbers can only answer axis-aligned range filters and forces a post-filter for true distance, whereas a POINT index on a native point value answers point.distance and point.withinBBox directly through a space-filling-curve structure. This page shows exactly when each is required, using the native point type, and proves the difference with PROFILE. It is part of index selection and composite indexing.

Prerequisites

When each index is required

Use a RANGE index only when your spatial predicate is genuinely a scalar range on independent coordinate numbers — for example, “all readings with lat BETWEEN 51.0 AND 51.5” where you never combine the axes into a true radius. Use a POINT index whenever the query asks a proximity question: a distance within a radius, or containment inside a bounding box. A RANGE index cannot express “within 5 km” because Euclidean/geodesic distance couples both axes; the best a RANGE index does is narrow one axis and then evaluate distance as a Filter on every candidate. A POINT index encodes both axes into a single space-filling curve, so point.distance and point.withinBBox become an index range on the curve rather than a scan-and-filter.

Why a POINT index beats a RANGE index for proximity A coordinate plane holds scattered location points. A RANGE index on one coordinate selects a vertical band spanning the full height, capturing many points that lie outside the target circle; each must then be evaluated by point.distance in a Filter. A POINT index instead selects the tight bounding box around the target circle and the circle itself, so only the points genuinely within the radius are returned, with far fewer db-hits. RANGE band whole vertical strip → distance-filter each withinBBox distance < r
The RANGE band (amber) keeps every point in the strip and distance-filters them all; the POINT index returns only the teal points inside the radius.

Core implementation

Model the location as one native point property, index it with a POINT index, and let the planner turn point.distance and point.withinBBox into index reads.

cypher
// 0) STORE A NATIVE POINT. One point value per node — not two floats, not a string.
//    WGS-84 lat/long lets point.distance return metres over the geodesic model.
MERGE (s:Store {store_id: $id})
  SET s.location = point({latitude: $lat, longitude: $lon});

// 1) POINT INDEX. Required for proximity: it encodes both axes on a space-filling
//    curve so distance and bbox predicates become an index range, not a scan.
CREATE POINT INDEX store_location_point IF NOT EXISTS
FOR (s:Store) ON (s.location);

// 2) RADIUS QUERY. point.distance is index-backed by the POINT index above.
MATCH (s:Store)
WHERE point.distance(s.location, point({latitude: $lat, longitude: $lon})) < $radius_m
RETURN s.store_id, s.location
ORDER BY point.distance(s.location, point({latitude: $lat, longitude: $lon}));

// 3) BOUNDING-BOX QUERY. point.withinBBox is likewise served by the POINT index.
MATCH (s:Store)
WHERE point.withinBBox(
        s.location,
        point({latitude: $south, longitude: $west}),
        point({latitude: $north, longitude: $east}))
RETURN s.store_id;

Contrast that with the scalar-coordinate model, which is the right choice only when you never ask a true proximity question:

cypher
// SCALAR MODEL — acceptable ONLY for axis-aligned range filters, never for radius.
// A composite RANGE index narrows both axes to a box, but genuine distance still
// needs an arithmetic Filter on every candidate the box returns.
CREATE INDEX reading_lat_lon IF NOT EXISTS
FOR (r:Reading) ON (r.lat, r.lon);

MATCH (r:Reading)
WHERE r.lat >= $south AND r.lat <= $north
  AND r.lon >= $west  AND r.lon <= $east
RETURN r.reading_id;   // an approximate box, not a circle

The decisive point: the POINT index makes distance and bounding-box predicates seekable. The scalar RANGE model can approximate a box but never a radius, and even the box costs a Filter pass because the planner seeks one axis and filters the other. If proximity matters, the native point plus a POINT index is not an optimisation — it is the only correct structure.

Validation & verification

PROFILE the radius query and confirm the leaf reads the POINT index instead of scanning the label.

cypher
PROFILE
MATCH (s:Store)
WHERE point.distance(s.location, point({latitude: $lat, longitude: $lon})) < $radius_m
RETURN s.store_id;
// Expect a point-index seek at the leaf and db-hits proportional to nearby stores,
// NOT a NodeByLabelScan over every Store followed by a distance Filter.

Then confirm the index exists, is a POINT index, and is ONLINE — a distance query issued while it is still populating falls back to a scan:

cypher
SHOW INDEXES YIELD name, type, state, labelsOrTypes, properties
WHERE type = 'POINT'
RETURN name, state, labelsOrTypes, properties;

If the radius query still shows NodeByLabelScan with a point.distance Filter, the property is not a native point, the POINT index is missing, or it is not yet online — check each in turn.

Edge cases & gotchas

1. Coordinate Reference System must match. A point created with latitude/longitude uses a geographic CRS and point.distance returns metres; a point created with x/y uses a Cartesian CRS and returns raw units. Mixing them yields null distances and silently empty results. Standardise the CRS at write time.

cypher
// Geographic (metres) — for real-world proximity:
SET s.location = point({latitude: $lat, longitude: $lon});
// Cartesian (unitless) — only for planar/game/screen coordinates:
SET p.pos = point({x: $x, y: $y});

2. A string or two-float location cannot be POINT-indexed. Storing "51.5,-0.12" or separate lat/lon floats makes the POINT index impossible and forces the scalar model. Cast to a native point during ingestion, in line with disciplined graph data type selection.

3. ORDER BY point.distance(...) still computes distances. The POINT index answers the < $radius predicate, but ranking the surviving rows by distance is an arithmetic sort over that reduced set — expected, and cheap because the index already trimmed the candidates. Keep the WHERE bound tight so the sort input stays small.