Spatial / Geo Queries
Spatial queries let you find documents by geographic relationship — proximity (within a radius, inside a bounding box, nearest to a point) or full-geometry topology (intersects, contains, overlaps, touches, …).
Two feature levels share one MapSpatialProperty registration:
- Point spatial — map a
GeoPointand useWithinRadius/WithinBoundingBox/NearestNeighbors. - Full geometry (v11+) — map any
Geometry(lines, polygons with holes, multi-geometries) and use theGeo-prefixed topological predicate family plusGeoWithinDistance.
Supported providers: SQLite (R*Tree), PostgreSQL / MySQL / SQL Server / Oracle / DuckDB
(dependency-free envelope sidecar), CosmosDB (native GeoJSON), and MongoDB (2dsphere). The
key-partitioned / file stores (LiteDB, IndexedDB, Azure Table, DynamoDB) report SupportsSpatial => false
and throw NotSupportedException.
Checking provider support
Section titled “Checking provider support”Spatial capability is a runtime property — probe it before issuing a query if your provider is configurable:
if (store.SupportsSpatial){ var nearby = await store.WithinRadius<Restaurant>(center, 5000);}| Provider | SupportsSpatial | Mechanism |
|---|---|---|
| SQLite / SQLCipher | true | R*Tree virtual table (bbox) + in-process refine |
| PostgreSQL, MySQL, SQL Server, Oracle, DuckDB | true | envelope-sidecar table (indexed bbox) + in-process refine |
| CosmosDB | true | native GeoJSON spatial index |
| MongoDB | true | 2dsphere index |
| LiteDB, IndexedDB, Azure Table, DynamoDB | false | — |
Spatial types
Section titled “Spatial types”GeoPoint
Section titled “GeoPoint”A WGS84 coordinate. Serializes as GeoJSON {"type":"Point","coordinates":[longitude,latitude]}.
public readonly record struct GeoPoint(double Latitude, double Longitude);A GeoPoint implicitly converts to a Geometry, so you can pass a bare point to any geometry query method.
GeoBoundingBox
Section titled “GeoBoundingBox”A rectangular area, used by WithinBoundingBox and returned by Geometry.GetEnvelope().
public readonly record struct GeoBoundingBox( double MinLatitude, double MinLongitude, double MaxLatitude, double MaxLongitude);SpatialResult<T>
Section titled “SpatialResult<T>”Wraps a matched document with a computed distance. For WithinRadius / NearestNeighbors the distance is
from the query point; for the geometry predicates it is the distance from orderByDistanceFrom (or 0 when
no distance reference was supplied).
public class SpatialResult<T> where T : class{ public required T Document { get; init; } public double DistanceMeters { get; init; }}Configuration
Section titled “Configuration”Register which property drives spatial indexing per document type. The same method accepts a GeoPoint(?)
or a Geometry(?) property:
public class Restaurant{ public string Id { get; set; } = ""; public string Name { get; set; } = ""; public GeoPoint Location { get; set; } public string Cuisine { get; set; } = "";}
var store = new DocumentStore(new DocumentStoreOptions { DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db") } .MapSpatialProperty<Restaurant>(r => r.Location) // GeoPoint .MapSpatialProperty<Zone>(z => z.Area)); // Geometry?One spatial property is mapped per type. The index sidecar (SQLite/relational) or native spatial index (Cosmos/Mongo) is created automatically the first time the table/collection is initialized, and CRUD keeps it in sync — no manual maintenance.
Optional (nullable) locations
Section titled “Optional (nullable) locations”The mapped property may be nullable (GeoPoint? or Geometry?). A document whose spatial value is null is
simply left out of the spatial index — it still stores and non-spatial-queries normally:
public class CalendarEvent{ public string Id { get; set; } = ""; public string Title { get; set; } = ""; public GeoPoint? Location { get; set; } // optional}
options.MapSpatialProperty<CalendarEvent>(e => e.Location);Inserting/updating with a null location does not throw and does not index the document; it never
appears in spatial results. Setting a previously-populated location back to null on update purges the stale
index entry.
Point queries
Section titled “Point queries”WithinRadius
Section titled “WithinRadius”Documents within a distance (meters) of a center point, ordered by distance ascending.
var nearby = await store.WithinRadius<Restaurant>( new GeoPoint(45.5231, -122.6765), // Portland, OR 5000); // 5 km
foreach (var r in nearby) Console.WriteLine($"{r.Document.Name} — {r.DistanceMeters:N0}m away");An optional filter composes with the spatial predicate and is pushed to the database:
var italian = await store.WithinRadius<Restaurant>( new GeoPoint(45.5231, -122.6765), 5000, filter: r => r.Cuisine == "Italian");WithinBoundingBox
Section titled “WithinBoundingBox”Documents inside a rectangular area (no ordering; returns T, not SpatialResult<T>).
var inArea = await store.WithinBoundingBox<Restaurant>( new GeoBoundingBox(45.0, -123.0, 46.0, -122.0), filter: r => r.Cuisine == "Italian"); // filter optionalNearestNeighbors
Section titled “NearestNeighbors”The K closest documents to a point, ordered by distance. Uses an expanding-envelope search so the true nearest are found even when they sit outside a fixed radius. Works over point and geometry mappings (point-to-geometry distance).
var closest = await store.NearestNeighbors<Restaurant>( new GeoPoint(45.5231, -122.6765), count: 10, filter: r => r.Cuisine == "Italian"); // filter optionalFull geometry
Section titled “Full geometry”The geometry model
Section titled “The geometry model”Geometry is the base type; all types serialize as GeoJSON in the document body.
| Type | Shape / rules |
|---|---|
GeoPoint | a single coordinate (implicitly a point geometry) |
GeoLineString | 2+ coordinates |
GeoPolygon | one exterior ring + optional interior rings (holes); each ring closed (first == last), 4+ points |
GeoMultiPoint | set of points |
GeoMultiLineString | set of line strings |
GeoMultiPolygon | set of polygons |
GeoGeometryCollection | heterogeneous set of geometries |
Construction (remember GeoPoint is (Latitude, Longitude)):
// A routevar route = new GeoLineString(new[]{ new GeoPoint(45.50, -122.68), new GeoPoint(45.52, -122.66), new GeoPoint(45.55, -122.64),});
// A delivery zone — the ring must be closed and have >= 4 pointsvar zone = new GeoPolygon(new[]{ new GeoPoint(45.50, -122.70), new GeoPoint(45.50, -122.60), new GeoPoint(45.60, -122.60), new GeoPoint(45.60, -122.70), new GeoPoint(45.50, -122.70), // closes the ring});
// A polygon with a hole (exterior ring + one or more interior rings)var withHole = new GeoPolygon(exteriorRing, new[] { holeRing });
// A region made of several polygonsvar region = new GeoMultiPolygon(new[] { zoneA, zoneB });Geometry also exposes GetEnvelope() (its GeoBoundingBox) and the measurement/validity members described
below.
Predicate queries
Section titled “Predicate queries”Every predicate reads stored-geometry <predicate> query-geometry — the argument is the query shape, the
result is the stored documents that stand in that relationship to it. All share one signature:
Task<IReadOnlyList<SpatialResult<T>>> GeoXxx<T>( Geometry geometry, Geometry? orderByDistanceFrom = null, // point OR geometry; orders + fills DistanceMeters Expression<Func<T, bool>>? filter = null, CancellationToken ct = default) where T : class;| Method | Matches stored documents whose geometry… |
|---|---|
GeoIntersects(g) | shares any point with g (the general “do they touch at all”) |
GeoDisjoint(g) | shares no point with g |
GeoContains(g) | contains g (g lies inside the stored geometry) |
GeoContainedBy(g) | is within g (the stored geometry lies inside g) |
GeoCovers(g) | covers g (boundary-inclusive contains) |
GeoCoveredBy(g) | is covered by g (boundary-inclusive within) |
GeoEquals(g) | is spatially equal to g |
GeoTouches(g) | meets g only at a boundary (interiors do not intersect) |
GeoCrosses(g) | crosses g (interiors intersect in a lower dimension — e.g. a line cutting through a polygon) |
GeoOverlaps(g) | partially overlaps g (same dimension, neither contains the other) |
GeoWithinDistance(g, meters) | lies within meters of g |
// "Which delivery zones contain this GPS point?" — a bare point converts implicitly.var zones = await store.GeoIntersects<Zone>(new GeoPoint(45.52, -122.68));
// Zones fully inside a search polygon, nearest-first from a reference point.var inside = await store.GeoContainedBy<Zone>( searchArea, orderByDistanceFrom: new GeoPoint(45.5, -122.6));
// Zones a road passes through (line crosses polygon).var crossed = await store.GeoCrosses<Zone>(roadLine);
// Everything within 500 m of a route, filtered.var near = await store.GeoWithinDistance<Zone>( route, meters: 500, filter: z => z.Active);LINQ composition with DocumentFunctions
Section titled “LINQ composition with DocumentFunctions”The dedicated store.Geo* methods above run standalone. To compose a spatial predicate with the rest of a
LINQ query — other Where clauses, OrderBy, Count, paging — use DocumentFunctions, which lowers each
predicate to the database’s native spatial function (or a registered UDF on SQLite) so the whole query runs
server-side:
var page = await store.Query<Zone>() .Where(z => DocumentFunctions.Intersects(z.Area!, searchArea) && z.Active) .OrderBy(z => DocumentFunctions.Distance(z.Area!, origin)) .Skip(20).Take(20) .ToList();Available: Intersects, Disjoint, Contains, Within, Covers, CoveredBy, Touches, Crosses,
Overlaps, GeoEquals, WithinDistance(field, query, meters) (all in Where), and Distance(field, query)
(for OrderBy). Read them as field <predicate> query; argument order is handled for the asymmetric ones
(Contains(big, z.Area) is the same as z.Area within big). Distance-in-OrderBy translates to native SQL
on SQLite, PostgreSQL, MySQL, DuckDB and SQL Server (SQL Server sorts by planar STDistance over the indexed
geometry column); on MongoDB use store.NearestNeighbors / orderByDistanceFrom instead.
Provider support for DocumentFunctions-in-Where:
| Provider | Mechanism & index | Predicate coverage |
|---|---|---|
| SQLite | R*Tree bbox prune + registered docdb_st_* UDF refine (dependency-free) | all |
| PostgreSQL | native geometry sidecar column + GiST spatial index (needs PostGIS) | all |
| MySQL | native GEOMETRY column + SPATIAL index | all (Covers≈Contains) |
| DuckDB | native GEOMETRY column + R-Tree index (auto-loads spatial) | all except WithinDistance |
| SQL Server | native planar geometry column + spatial index + .ST* methods | all except Covers/CoveredBy and WithinDistance |
| Oracle | native SDO_GEOMETRY column + MDSYS spatial index + SDO_RELATE operators (requires Oracle Spatial) | all except Crosses |
| CosmosDB | native ST_INTERSECTS/ST_WITHIN/ST_DISTANCE (native spatial index) | Intersects, Within, Disjoint, WithinDistance |
| MongoDB | native $geoIntersects/$geoWithin/$centerSphere (2dsphere index) | Intersects, Within, WithinDistance (point) |
On the relational providers, the DocumentFunctions predicate runs over a spatially-indexed geometry column in
the sidecar, so the engine’s true 2-D spatial index does the pruning (not a scan, and not just the
bounding-box B-tree). The geometry column is populated from the document body on write. The dedicated
store.Geo* methods use the bounding-box envelope tier (which also works under PortableSpatial).
Where a predicate isn’t natively available on a provider, the DocumentFunctions call in a Where throws a
clear message — use the dedicated store.Geo* method, which supports every predicate on every
spatial-capable provider. A handful of predicates are provider approximations (MySQL/SQL Server Covers≈
Contains). WithinDistance in a Where needs a geodesic (metric) distance function: SQL Server (planar
geometry sidecar) and DuckDB (no polygon geodesic distance) don’t have one, so rather than silently
approximating with planar degrees — which is wrong away from the equator — they throw a clear message. Use
store.GeoWithinDistance(...), which refines with an exact Haversine distance in managed code and is correct on
every spatial-capable provider.
Set PortableSpatial = true on a relational provider to force the dependency-free envelope tier (dedicated
store.Geo* methods only; DocumentFunctions-in-Where then throws — no native extension required).
Boundary-case results (e.g. a query polygon sharing an exact edge with a stored one) can differ slightly
between a provider’s native predicate and the C# engine.
String-expression queries
Section titled “String-expression queries”The geo functions also work in the string-expression surface — Where("…"), the interpolated
Where($"…"), OrderBy("…"), and Project("…") — with the same names as the LINQ DocumentFunctions
family. The query geometry is supplied either as an interpolated value (a Geometry or GeoPoint, bound
as a parameter — only available where the string carries arguments, i.e. Where($"…")) or as an inline
GeoJSON string literal (works everywhere, including OrderBy/Project):
// Interpolated geometry (bound, never formatted into the string):await store.Query<Zone>().Where($"intersects(area, {searchArea}) and active == true").ToList();await store.Query<Zone>().Where($"withindistance(area, {origin}, 5000)").Count();
// GeoJSON string literal (for fully-string callers — OData-style gateways, config, OrderBy):await store.Query<Zone>().Where("within(area, '{\"type\":\"Polygon\",\"coordinates\":[...]}')").ToList();await store.Query<Zone>().OrderBy("distance(area, '{\"type\":\"Point\",\"coordinates\":[-122.6,45.5]}')").ToList();contains(field, …) is the geo predicate when field is a Geometry-mapped property, and the ordinary
string Contains when it’s a string field. The field argument must be a Geometry property (use the
dedicated store.Geo* methods for GeoPoint fields).
Distance ordering & geometry k-nearest
Section titled “Distance ordering & geometry k-nearest”Pass orderByDistanceFrom to any predicate to sort the matches by ascending distance and populate
DistanceMeters. The reference can be a point (fast, native on every provider) or a geometry
(geometry-to-geometry distance; native on SQLite/relational/Cosmos, in-process on Mongo):
var origin = new GeoPoint(45.5, -122.6);var ranked = await store.GeoIntersects<Zone>(searchArea, orderByDistanceFrom: origin);// ranked[0] is the closest matching zone; each .DistanceMeters is set.For “the N nearest geometries to a point”, NearestNeighbors<T>(point, count) works directly over a
geometry-mapped type using point-to-geometry distance.
Measurement & validity
Section titled “Measurement & validity”Geometry exposes in-memory helpers computed directly on the object (geodesic approximations):
| Member | Meaning |
|---|---|
Area | area in m² (0 for points/lines) |
Length / Perimeter | total length / ring perimeter in m |
Centroid | the centroid GeoPoint |
NumPoints / NumGeometries | coordinate count / sub-geometry count |
IsValid | coordinate finiteness/range, min-vertex counts, ring closure, and (polygons) exterior ring non-self-intersection |
IsSimple | no self-intersections |
MakeValid() | best-effort repair: closes rings, fixes winding, drops degenerate/duplicate vertices |
var region = (GeoPolygon)zone.Area!;Console.WriteLine($"{region.Area / 1e6:N1} km², perimeter {region.Perimeter / 1000:N1} km");
if (!candidate.IsValid) candidate = candidate.MakeValid(); // e.g. before storing user-drawn shapesPredicate support by provider
Section titled “Predicate support by provider”All predicates return identical results everywhere; the difference is where the work runs. On SQLite and the relational providers, every predicate is a bounding-box prune (index) + in-process relate refine. On the document stores, the coarse filter is native where an operator exists and refines in-process otherwise:
| Predicate | SQLite / relational | CosmosDB | MongoDB |
|---|---|---|---|
GeoIntersects | bbox + refine | native ST_INTERSECTS | native $geoIntersects |
GeoContainedBy | bbox + refine | native ST_WITHIN | native $geoWithin |
GeoWithinDistance | bbox + refine | native ST_DISTANCE | native $near/$centerSphere (point) |
GeoDisjoint | type scan + refine | native NOT ST_INTERSECTS | type scan + refine |
GeoContains / Covers / CoveredBy / Touches / Crosses / Overlaps / Equals | bbox + refine | ST_INTERSECTS candidates + refine | $geoIntersects candidates + refine |
| distance ordering | in-process | native ST_DISTANCE order | point ref native, geometry ref in-process |
How it works
Section titled “How it works”Spatial queries are a two-pass pipeline. Pass 1 uses an index to cheaply narrow candidates by bounding box; pass 2 runs the exact geometry test (and distance) in C#. This is the same shape PostGIS uses internally — the difference between providers is only which index backs pass 1.
SQLite — R*Tree sidecar
Section titled “SQLite — R*Tree sidecar”For each table with a spatial-mapped type, two companion tables are created:
{table}_spatial_map— maps document text IDs to integer rowids (R*Tree requires integer keys){table}_spatial— the R*Tree index storing lat/lng envelopes
Insert/Update/Upsert sync the envelope; Remove/Clear purge it. Pass 1 is an R*Tree bbox join; pass 2 is the Haversine/relate refine.
PostgreSQL / MySQL / SQL Server / Oracle / DuckDB — envelope sidecar
Section titled “PostgreSQL / MySQL / SQL Server / Oracle / DuckDB — envelope sidecar”These providers use a plain, dependency-free sidecar table — no PostGIS, geography, SDO_GEOMETRY, or
DuckDB spatial extension required:
{table}_spatial ( docId, typeName, minLat, maxLat, minLng, maxLng, PRIMARY KEY (docId, typeName) )-- + a composite B-tree index on (typeName, minLat, maxLat, minLng, maxLng)Pass 1 is an indexed range join over the envelope columns; pass 2 is the in-process relate/refine. Each
provider emits its own DDL/upsert dialect (ON CONFLICT / ON DUPLICATE KEY / MERGE), but the behavior is
identical.
CosmosDB — native GeoJSON
Section titled “CosmosDB — native GeoJSON”Geometry serializes as GeoJSON in the document body and CosmosDB indexes it via the container’s spatial
policy (added automatically at init). GeoIntersects/GeoContainedBy/GeoWithinDistance/GeoDisjoint and
distance ordering push down to ST_* functions; the finer predicates fetch the ST_INTERSECTS candidate set
and refine in-process.
MongoDB — 2dsphere
Section titled “MongoDB — 2dsphere”A 2dsphere index is created over the mapped GeoJSON path at first query. $geoIntersects, $geoWithin,
and $near/$centerSphere run natively; the finer predicates and geometry-to-geometry distance refine
in-process over the candidate set.
Limitations & fidelity
Section titled “Limitations & fidelity”GeoDisjointis O(n) on SQLite/relational — its answer is mostly rows outside any bounding box, so the index can’t prune it and it scans the type. Use it deliberately on large collections.- Relational bbox selectivity — the sidecar uses a B-tree over the envelope columns, which is weaker than a
true 2-D spatial index (R*Tree/GiST) for queries spanning a wide longitude range. It’s still index-assisted,
not a full scan. A native
ST_*/SDOpushdown tier could be layered on per engine later. - Distance approximation — SQLite/relational and the in-process refine paths use a Haversine/planar
approximation; CosmosDB and native MongoDB distance use geodesic
ST_DISTANCE. Ordering can differ on near-ties across providers. - Antimeridian / poles — bounding boxes crossing ±180° longitude or the poles are not special-cased.
- One spatial property per type — map a single
GeoPoint/Geometryproperty per document type.