Spatial / Geo
patinaDB has a first-class Point type with Neo4j’s four coordinate
reference systems (CRSs), the point() / distance() /
point.distance.ellipsoidal() / point.withinBBox() functions, and a
CREATE POINT INDEX statement. Points are
stored, indexed on disk with a space-filling-curve key, and queried correctly, and
a planner fast path turns a radius/bbox query (and a kNN
ORDER BY distance(...) LIMIT k) into a curve-range seek instead of a full scan.
The Point type
A point carries a CRS (identified by a Neo4j SRID) and 2 or 3 coordinates:
| CRS | SRID | Dim | Coordinates |
|---|---|---|---|
cartesian | 7203 | 2 | [x, y] |
cartesian-3d | 9157 | 3 | [x, y, z] |
wgs-84 | 4326 | 2 | [longitude, latitude] |
wgs-84-3d | 4979 | 3 | [longitude, latitude, height] |
For geographic (wgs-84) points, x is the longitude and y is the
latitude (Neo4j’s convention), so p.latitude reads the second coordinate.
Two points are equal iff they have the same SRID and coordinates — a
cartesian and a wgs-84 point are never equal even with identical numbers.
< / > on points are undefined (they return null, matching Neo4j);
ORDER BY uses a deterministic total order (the space-filling-curve order).
point({...}) — constructing points
CRS is inferred from the map keys, or set explicitly with crs / srid:
// cartesian (x/y) — 2D and 3D
RETURN point({x: 3.0, y: 4.0})
RETURN point({x: 1.0, y: 2.0, z: 3.0})
// geographic (latitude/longitude) — 2D and 3D
RETURN point({latitude: 52.52, longitude: 13.405})
RETURN point({latitude: 52.52, longitude: 13.405, height: 100.0})
// explicit override
RETURN point({x: 13.4, y: 52.5, crs: 'wgs-84'})
RETURN point({x: 1.0, y: 2.0, srid: 4326})
Out-of-range latitude (|lat| > 90) or longitude (|lon| > 180) raises an
ArgumentError. point(null) returns null.
Points can be stored on nodes and relationships like any other property:
CREATE (:City {name: 'Berlin', loc: point({latitude: 52.52, longitude: 13.405})})
Accessors
Field access reads a coordinate or CRS component:
WITH point({x: 3.0, y: 4.0, z: 5.0}) AS p
RETURN p.x, p.y, p.z, p.crs, p.srid
MATCH (c:City)
RETURN c.loc.latitude, c.loc.longitude
.x/.longitude → axis 0, .y/.latitude → axis 1, .z/.height → axis 2,
.crs → the CRS name string, .srid → the integer SRID. Accessing a missing
component (e.g. .z on a 2-D point) returns null.
distance() / point.distance()
Both spellings work. Returns metres for geographic points (spherical Haversine) and Euclidean distance for cartesian points:
// Euclidean → 5.0
RETURN distance(point({x: 0, y: 0}), point({x: 3, y: 4}))
// Haversine, Berlin → Paris ≈ 878 km (metres)
RETURN point.distance(
point({latitude: 52.52, longitude: 13.405}),
point({latitude: 48.8566, longitude: 2.3522})
)
// radius query (served by a curve-range seek when a POINT INDEX exists)
MATCH (c:City)
WHERE distance(c.loc, point({latitude: 52.5, longitude: 13.4})) < 5000
RETURN c.name
Mixed CRS (or mismatched dimensionality) returns null, matching Neo4j.
wgs-84 distance is spherical (mean Earth radius 6 371 009 m), ~0.3 % off the
true geoid — fine for radius search, not survey work.
point.distance.ellipsoidal() — accurate WGS-84 geodesic
When you need survey-grade accuracy, use the ellipsoidal variant, which computes
the true WGS-84 geodesic distance via the Vincenty-inverse formula (accounting
for the Earth’s oblateness). distance() stays spherical Haversine (Neo4j parity);
point.distance.ellipsoidal() is the distinct, more-accurate spelling:
// WGS-84 ellipsoidal (Vincenty), Berlin → Paris ≈ 878 km — a distinct value
// from Haversine, but within 0.5 %.
RETURN point.distance.ellipsoidal(
point({latitude: 52.52, longitude: 13.405}),
point({latitude: 48.8566, longitude: 2.3522})
)
Same argument conventions as distance(): null/non-point operand → null,
mixed CRS/dimensionality → null. It is WGS-84 only — a cartesian
argument falls back to plain Euclidean (there is no ellipsoid without a geoid).
Near-antipodal pairs (where Vincenty does not converge) fall back to the
always-finite spherical distance rather than emitting NaN. It is an exact scalar
only — it does not drive the radius/bbox seek (which uses the spherical
bounding math); a radius filter should still use distance().
point.withinBBox()
Bool — whether a point lies inside an axis-aligned bounding box:
MATCH (c:City)
WHERE point.withinBBox(c.loc,
point({x: -1, y: -1}),
point({x: 10, y: 10}))
RETURN c.name
A geographic box whose lowerLeft.longitude > upperRight.longitude wraps the
antimeridian — inc 1–3 reject it with a clear error rather than silently
returning false (full antimeridian/pole handling is inc-4).
CREATE POINT INDEX
A point index is a planner-enablement marker. The on-disk curve keys are written for every point property unconditionally (see below), so registering an index is a lightweight no-op backfill; the def tells the planner it may use a curve-range seek for radius/bbox queries over that property (increment 3).
CREATE POINT INDEX city_loc [IF NOT EXISTS] FOR (n:City) ON (n.loc)
DROP POINT INDEX city_loc [IF EXISTS]
SHOW POINT INDEXES -- also folds into SHOW INDEXES
Like every other index, it replicates across a Raft cluster (re-run deterministically on each node) and is carried in snapshots.
How points are stored (the curve key)
Every point property lands in the same label-scoped value index as every other
scalar, under an order-preserving Morton / Z-order key (encode_for_index
tag 0x07):
0x07 ++ srid(u32 BE) ++ morton_interleave(order-preserving axis codes) ++ exact axis codes
Each axis’s f64 runs through the same order-preserving u64 transform the
Float index uses; the per-axis codes are bit-interleaved MSB-first into a
fixed-width big-endian string (16 bytes for 2-D, 24 for 3-D) so byte order equals
Z-curve order. The SRID leads the key, so points of different CRS occupy disjoint
ranges. The exact coordinates are appended so the point decodes back exactly. A
2-D point key is 37 bytes. This is the final on-disk format — it ships in
increment 1 so there is never a migration when the seek arrives.
Accelerating radius / bbox queries: the point-index seek
Create a point index so radius and bounding-box queries seek the Morton curve instead of scanning the whole label:
CREATE POINT INDEX loc_idx FOR (n:City) ON (n.loc)
Once the index exists, a query of the form
MATCH (c:City) WHERE distance(c.loc, point({latitude: 48.85, longitude: 2.35})) < 5000
RETURN c
or
MATCH (c:City) WHERE point.withinBBox(c.loc, point({x: 0, y: 0}), point({x: 10, y: 10}))
RETURN c
runs as a bounded set of curve-range seeks plus an exact post-filter: the
query region is decomposed into a few contiguous Morton ranges (a superset of the
answer), each is seeked in the value index, and the exact distance() /
withinBBox filter trims the false positives. Results are identical to the
full scan — the index only changes speed. EXPLAIN shows entry.spatial_seek in
the Physical: footer when the seek is used. Without a point index the query
still runs correctly, just as a label scan (Neo4j’s declare-to-accelerate model).
A radius seek is roughly an order of magnitude faster than the full scan once
the label is large and the region is selective.
Geographic edge cases are handled: a radius that crosses the ±180° antimeridian
is split into two boxes and unioned; one that reaches a pole widens to all
longitudes (a correct over-approximation the post-filter trims); an
antimeridian-wrapping withinBBox (lower-left longitude greater than
upper-right) covers [ll.lon, 180] ∪ [-180, ur.lon].
The seek also composes with a (non-kNN) ORDER BY — e.g.
MATCH (c:City) WHERE distance(c.loc, point({latitude: 48.85, longitude: 2.35})) < 5000
RETURN c ORDER BY c.name
still uses entry.spatial_seek to produce the candidate set, then applies the
ORDER BY c.name as an ordinary post-sort over just those matches (byte-identical
to a full scan + sort). Only a kNN ORDER BY distance(...) LIMIT k is served
by its own path (above).
Nearest-neighbour (kNN)
MATCH (c:City) RETURN c ORDER BY distance(c.loc, point({latitude: 48.85, longitude: 2.35})) LIMIT 10
With a point index on c.loc, this runs an expanding-ring search over the
curve: it grows a search box until the k-th nearest is provably confirmed (no
un-searched point can be closer), then orders just that candidate set — identical
to the full sort, but without touching every row. Without an index it falls back
to the exact full sort.
Polygons & geometry (geometry MVP)
Beyond points, patinaDB has a first-class Polygon value and a small set of
areal predicates — a scoped geometry MVP, not full PostGIS.
Constructing a polygon
// Exterior ring from a list of points (auto-closed if the last ≠ first):
RETURN polygon([point({x: 0, y: 0}), point({x: 10, y: 0}),
point({x: 10, y: 10}), point({x: 0, y: 10})]) AS square
// With holes — a list of rings, ring 0 = exterior, rings 1.. = holes:
RETURN polygon([
[point({x: 0, y: 0}), point({x: 10, y: 0}), point({x: 10, y: 10}), point({x: 0, y: 10})],
[point({x: 4, y: 4}), point({x: 6, y: 4}), point({x: 6, y: 6}), point({x: 4, y: 6})]
]) AS ring_with_hole
The CRS is inherited from the points (all points must share one CRS — a mixed-CRS set is an error). A ring needs ≥ 3 distinct vertices. A polygon can be stored as a node/relationship property (it round-trips through storage) but is not spatially indexed (see limitations).
Predicates
// Point-in-polygon (ray casting, correct for holes):
MATCH (p:Place) WHERE within(p.loc, $region) RETURN p
// contains() is the same predicate with arguments swapped:
RETURN polygon.contains($region, point({x: 5, y: 5})) // → true/false
// Polygon–polygon intersection (overlap or touch):
RETURN intersects($regionA, $regionB) // → true/false
within(point, polygon)/contains(polygon, point)— even-odd ray-casting point-in-polygon: inside the exterior ring and outside every hole. A point exactly on an edge/vertex is reported inside (a documented boundary convention). Also available as the namespacedpolygon.contains(polygon, point)/polygon.within(point, polygon).intersects(polyA, polyB)— a bounding-box reject fast path, then an edge-segment-crossing test, then a vertex-containment test (so containment with no crossing edges still counts). Returnstruewhen the polygons overlap or touch.- Mixed-CRS operands →
null(parity withdistance()).
Limitations (current increments)
- Geometry is a scoped MVP — a
Polygontype withwithin/contains/intersectsonly. No linestrings, no multipolygon, noST_*OGC function library, no spatial joins beyond the point predicates above. - No polygon spatial index — polygon predicates are always a full scan +
exact filter (a polygon is not added to the tag-
0x07point curve index; a polygon-column BVH / R-tree is an honest follow-on). A “points within a constant query polygon” optimization could later reuse the point bbox-seek. - Polygons are 2-D — a polygon’s footprint is
[x, y]; a 3-D point’s height is dropped on construction. - wgs-84 polygons are treated as planar lon/lat — no antimeridian-crossing and no polar geometry (a polygon spanning the ±180° seam is out of MVP scope). Ray casting / intersection assume a flat plane, which is fine for local regions but not for large geodesic areas.
intersectsassumes well-formed input — degenerate or self-intersecting polygons are undefined (not asserted).distance()is spherical Haversine, not ellipsoidal (Neo4j’s own default; ~0.3% off the true geoid — fine for radius search, not survey work). Usepoint.distance.ellipsoidal()when you need the accurate WGS-84 geodesic.- 3-D seeks are less selective than 2-D (interleaving three axes has worse curve locality), but still correct — the exact post-filter always runs.