Five spatial algorithm functions for Neo4j Cypher — point-in-polygon containment, polygon intersection, buffer zone generation, bounding box extraction, and point-to-polygon distance.
Built on JTS (Java Topology Suite) 1.20.0. Targets Neo4j 2026.01.x Community and Enterprise. Designed as an extension to the neo4j-contrib/spatial-algorithms ecosystem.
Neo4j has excellent built-in spatial support for points (point()), point indexing,
and rectangular bounding box queries (point.withinBBox()). What it lacks are
polygon-based spatial primitives — the ability to test whether a point falls
inside an arbitrary polygon, whether two polygons overlap, or how far a point is
from a polygon boundary.
Without these, any application that needs polygon-based spatial reasoning must maintain a parallel PostGIS/spatial database alongside Neo4j and coordinate queries across both systems. This is operationally expensive and architecturally complex.
These five functions close that gap entirely for the most common spatial query patterns, letting you express polygon-based queries in plain Cypher.
| Function | Signature | Returns |
|---|---|---|
spatial.algo.withinPolygon |
(point, polygonWkt) |
Boolean |
spatial.algo.intersectsPolygon |
(wktA, wktB) |
Boolean |
spatial.algo.buffer |
(wkt, distanceMetres) |
String (WKT) |
spatial.algo.envelope |
(wkt) |
Map |
spatial.algo.pointToPolygonDistance |
(point, polygonWkt) |
Double (metres) |
All functions return null if any input is null, following Neo4j's standard null
propagation convention.
Important: This library handles coordinate order conversion internally.
WKT stores coordinates as (longitude, latitude) — i.e. (x, y) order.
Neo4j's point() type uses named parameters {latitude, longitude}.
You use Neo4j point syntax. The library handles the rest.
-- Correct: just use point() normally
RETURN spatial.algo.withinPolygon(
point({latitude: 51.0447, longitude: -114.0719}), -- Calgary
'POLYGON((-120 49, -110 49, -110 60, -120 60, -120 49))') -- Alberta bbox
-- → true
-- The WKT 'POLYGON((-120 49, ...))' means (lon=-120, lat=49) at each vertex.
-- The function converts the Neo4j point to (lon, lat) before the JTS comparison.spatial.algo.withinPolygon(point :: POINT, polygonWkt :: STRING) :: BOOLEAN
Returns true if point is contained within the geometry described by polygonWkt.
Supports POLYGON and MULTIPOLYGON WKT. A point exactly on the polygon boundary
returns true (JTS contains semantics).
Example — basic containment:
RETURN spatial.algo.withinPolygon(
point({latitude: 53.5461, longitude: -113.4938}), -- Edmonton
'POLYGON((-120 49, -110 49, -110 60, -120 60, -120 49))') -- Alberta
-- → trueExample — find which administrative area contains a coordinate:
MATCH (a:AdministrativeArea)
WHERE a.wkt IS NOT NULL
AND spatial.algo.withinPolygon(
point({latitude: $lat, longitude: $lon}),
a.wkt)
RETURN a.name, a.levelPerformance pattern — bbox pre-filter then precise check:
For large datasets, always pre-filter with indexed bounding box properties before
calling withinPolygon. The bbox indexes reduce the candidate set from thousands
of polygons to a handful before the (more expensive) polygon containment check runs.
-- Assumes AdministrativeArea nodes have bboxMinLat/bboxMaxLat/bboxMinLon/bboxMaxLon
-- properties with RANGE indexes (see Installation section)
MATCH (a:AdministrativeArea)
WHERE a.bboxMinLat <= $lat AND a.bboxMaxLat >= $lat
AND a.bboxMinLon <= $lon AND a.bboxMaxLon >= $lon
AND a.wkt IS NOT NULL
AND spatial.algo.withinPolygon(
point({latitude: $lat, longitude: $lon}),
a.wkt)
RETURN a.name, a.level
ORDER BY a.levelspatial.algo.intersectsPolygon(wktA :: STRING, wktB :: STRING) :: BOOLEAN
Returns true if the two WKT geometries share any space (intersect).
Supports any combination of POLYGON, MULTIPOLYGON, LINESTRING, POINT, etc.
Example — find overlapping zones:
MATCH (a:ConsultationZone), (b:ProtectedArea)
WHERE spatial.algo.intersectsPolygon(a.wkt, b.wkt)
RETURN a.name AS zone, b.name AS protectedAreaExample — find all protected areas that overlap a given parcel:
MATCH (parcel:LandParcel {parcelId: $parcelId})
MATCH (pa:ProtectedArea)
WHERE spatial.algo.intersectsPolygon(parcel.wkt, pa.wkt)
RETURN pa.name, pa.designationspatial.algo.buffer(wkt :: STRING, distanceMetres :: FLOAT) :: STRING
Returns a WKT polygon string representing the input geometry expanded outward
by distanceMetres. The output WKT can be used directly with withinPolygon
or intersectsPolygon.
Accuracy: Uses a planar approximation to convert metres to degrees (1° latitude ≈ 111,320 m). Accurate to approximately 2% for buffer distances under 50 km at latitudes between 45°N and 60°N (covers most of Canada, US, Europe). Not suitable for survey-grade or legally binding setback determinations — use PostGIS
ST_Bufferwithgeographytype for those cases.
Example — facilities within 500m of any protected area:
MATCH (pa:ProtectedArea)
WHERE pa.wkt IS NOT NULL
WITH pa, spatial.algo.buffer(pa.wkt, 500) AS setbackZone
MATCH (f:Facility)
WHERE f.location IS NOT NULL
AND spatial.algo.withinPolygon(f.location, setbackZone)
AND f.status = 'ACTIVE'
MATCH (o:Organization)-[:OPERATES]->(f)
RETURN o.name AS operator, f.facilityId, pa.name AS nearProtectedArea
ORDER BY o.nameExample — consultation zone around a pipeline corridor:
MATCH (pl:PipelineLicence {licenceNumber: $licence})
WITH pl, spatial.algo.buffer(pl.routeWkt, 1000) AS consultZone
MATCH (lp:LandParcel)
WHERE spatial.algo.intersectsPolygon(lp.wkt, consultZone)
RETURN lp.parcelId, lp.ownerspatial.algo.envelope(wkt :: STRING) :: MAP
Returns the axis-aligned bounding box of a WKT geometry as a map with keys
minLon, minLat, maxLon, maxLat (all FLOAT).
Useful for pre-computing bbox properties from WKT, and for quick spatial reasoning without full polygon evaluation.
Example — compute and store bbox from WKT:
MATCH (a:GeographicArea)
WHERE a.wkt IS NOT NULL AND a.bboxMinLat IS NULL
WITH a, spatial.algo.envelope(a.wkt) AS env
SET a.bboxMinLat = env.minLat,
a.bboxMaxLat = env.maxLat,
a.bboxMinLon = env.minLon,
a.bboxMaxLon = env.maxLonExample — quick bbox overlap check before expensive intersection:
WITH spatial.algo.envelope($wkt) AS queryBox
MATCH (a:AdministrativeArea)
WHERE a.bboxMinLon <= queryBox.maxLon AND a.bboxMaxLon >= queryBox.minLon
AND a.bboxMinLat <= queryBox.maxLat AND a.bboxMaxLat >= queryBox.minLat
RETURN a.namespatial.algo.pointToPolygonDistance(point :: POINT, polygonWkt :: STRING) :: FLOAT
Returns the distance in metres from point to the nearest edge of polygonWkt.
Returns 0.0 if the point is inside the polygon (JTS distance() semantics).
Neo4j's built-in point.distance() function only operates point-to-point.
This function enables proximity queries against polygon boundaries — essential for
setback compliance checks, proximity reporting, and spatial ranking.
Accuracy: Same planar approximation as
buffer(). ~2% error for distances under 50 km at mid-latitudes.
Example — how far is a well from the nearest protected area?
MATCH (w:Well {wellId: $wellId})
MATCH (pa:ProtectedArea)
WHERE pa.wkt IS NOT NULL
WITH w, pa,
spatial.algo.pointToPolygonDistance(w.location, pa.wkt) AS distMetres
WHERE distMetres < 5000 -- within 5km
RETURN pa.name, pa.designation, round(distMetres) AS distanceMetres
ORDER BY distMetresExample — find the closest administrative boundary and its distance:
MATCH (a:AdministrativeArea)
WHERE a.wkt IS NOT NULL AND a.level = 'province'
WITH a,
spatial.algo.pointToPolygonDistance(
point({latitude: $lat, longitude: $lon}),
a.wkt) AS distMetres
RETURN a.name, round(distMetres / 1000, 1) AS distanceKm
ORDER BY distMetres
LIMIT 3cp neo4j-spatial-functions-1.0.0.jar $NEO4J_HOME/plugins/The JAR is self-contained (JTS is shaded in with a relocated package prefix to avoid classpath conflicts).
# Linux/Mac service
sudo systemctl restart neo4j
# Or Neo4j Desktop: stop/start from the UIRETURN spatial.algo.withinPolygon(
point({latitude: 51.0, longitude: -114.0}),
'POLYGON((-120 49, -110 49, -110 60, -120 60, -120 49))')
-- → trueIf your nodes have WKT polygon properties, pre-compute bbox properties and index them for fast spatial pre-filtering:
-- Populate bbox properties from WKT (one-time backfill)
MATCH (a:AdministrativeArea) WHERE a.wkt IS NOT NULL AND a.bboxMinLat IS NULL
WITH a, spatial.algo.envelope(a.wkt) AS env
SET a.bboxMinLat = env.minLat, a.bboxMaxLat = env.maxLat,
a.bboxMinLon = env.minLon, a.bboxMaxLon = env.maxLon;
-- Create RANGE indexes on each bbox dimension
CREATE INDEX idx_area_bbox_minlat IF NOT EXISTS FOR (a:AdministrativeArea) ON (a.bboxMinLat);
CREATE INDEX idx_area_bbox_maxlat IF NOT EXISTS FOR (a:AdministrativeArea) ON (a.bboxMaxLat);
CREATE INDEX idx_area_bbox_minlon IF NOT EXISTS FOR (a:AdministrativeArea) ON (a.bboxMinLon);
CREATE INDEX idx_area_bbox_maxlon IF NOT EXISTS FOR (a:AdministrativeArea) ON (a.bboxMaxLon);This pattern filters from the full node set to a handful of bbox candidates before running the precise polygon containment check — typically reducing query time from seconds to milliseconds on large datasets.
Requirements: JDK 21+, Maven 3.9+
git clone https://github.com/DIGITAL-FABRIC-AI/neo4j-spatial-functions.git
cd neo4j-spatial-functions
mvn clean package
# → target/neo4j-spatial-functions-1.0.0.jarWithout a local JDK (Docker-based build):
./build.sh
# → target/neo4j-spatial-functions-1.0.0.jarmvn clean testTests use the Neo4j test harness to run an embedded Neo4j instance. All tests use real Canadian coordinates (Edmonton, Calgary, Vancouver, Regina) against simplified provincial boundary polygons:
| Test | Purpose |
|---|---|
| Edmonton / Calgary inside Alberta | Basic containment |
| Vancouver / Regina outside Alberta | Outside-polygon rejection |
| Coordinate order not swapped | Guards against lat/lon reversal bugs |
| Null inputs → null | Null propagation |
| Buffer expands to contain origin | Buffer correctness |
| Buffer returns valid WKT | Output format |
| Envelope returns correct corners | Bounding box correctness |
| Distance = 0 inside polygon | Interior point |
| Distance > 0 outside polygon | Exterior point |
| MultiPolygon containment | Island / multi-region support |
| Intersection true/false | Polygon overlap |
JTS (Java Topology Suite) is the reference implementation of the OGC Simple Features geometry model and is used by PostGIS, GeoTools, GEOS, and most serious spatial software. Using JTS means:
- Well-tested computational geometry (polygon buffer, distance, containment)
- Correct handling of edge cases (degenerate polygons, self-intersections, holes)
- Full
MULTIPOLYGONand geometry collection support - Maintained, actively developed upstream
The neo4j-contrib/spatial-algorithms repository uses a clean-room approach
(no external geometry libraries). That has merit for simple algorithms, but
polygon buffer generation and robust containment testing are non-trivial to
implement correctly from scratch. Using JTS avoids reimplementing well-solved
problems and lets this library focus on the Neo4j integration.
JTS is shaded into the plugin JAR with a relocated package prefix
(org.neo4j.spatial.shaded.jts), so it cannot conflict with any JTS version
your application or Neo4j itself might use.
JTS buffer() operates in the coordinate space of the geometry. For WGS84
geometries, that means the buffer is computed in degrees, not metres. The
conversion uses a constant: 1° latitude ≈ 111,320 m.
This is a planar approximation. The actual metres-per-degree varies:
- Latitude degrees are nearly constant (~111,320 m everywhere)
- Longitude degrees shrink toward the poles: at 60°N, 1° longitude ≈ 55,660 m
The buffer implementation uses the latitude-degree constant uniformly, which means buffer zones are slightly squashed in the east-west direction at high latitudes. The error is less than 2% for distances under 50 km anywhere between 45°N and 60°N — sufficient for screening, analysis, and visualization queries.
For legally binding setback determinations, use PostGIS ST_Buffer with
geography type.
All five functions follow Neo4j's null propagation convention: if any argument
is null, the function returns null. This lets queries use these functions
safely on nodes that may or may not have WKT properties without needing explicit
null guards:
-- Safe even if a.wkt is null for some nodes
MATCH (a:AdministrativeArea)
RETURN a.name,
spatial.algo.withinPolygon(point({latitude: $lat, longitude: $lon}), a.wkt)
AS containsWKTReader is not thread-safe, so a new instance is created per function call.
GeometryFactory is thread-safe and shared as a static constant. This is the
standard JTS usage pattern.
| Component | Version |
|---|---|
| Neo4j | 2026.01.x (Community and Enterprise) |
| Java | 21+ |
| JTS | 1.20.0 (shaded — no classpath conflicts) |
Note on Neo4j version: This plugin targets Neo4j 2026.01.x. The Neo4j procedure API has been stable since 5.x. If you need compatibility with Neo4j 5.x (non-2026), the code should compile and run without modification — the procedure registration API and
pointcoordinate access are identical. Please open an issue if you encounter version-specific problems.
Issues and pull requests welcome. Please:
- Include a test case for any new function or bug fix
- Run
mvn clean testbefore submitting - Follow the existing code style (Javadoc on all public methods, Apache 2.0 header)
- neo4j-contrib/spatial-algorithms — Clean-room spatial algorithm implementations for Neo4j
- neo4j-contrib/spatial — Full spatial plugin for Neo4j (geometry storage, indexing, procedures)
- locationtech/jts — Java Topology Suite
Apache License 2.0 — see LICENSE
Ashley Dunfield github.com/DIGITAL-FABRIC-AI
Built while developing geospatial capabilities for oil & gas regulatory compliance on a production Neo4j graph with 5,400+ administrative boundary polygons and 3.1M entity-jurisdiction relationships. Validated on Neo4j 2026.01.4 Community Edition.