Skip to content
Document DB v12 - Improved Interceptors with Soft Delete Integration, AI protections, & Admin UI with Aspire Integration! How!?

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 GeoPoint and use WithinRadius / WithinBoundingBox / NearestNeighbors.
  • Full geometry (v11+) — map any Geometry (lines, polygons with holes, multi-geometries) and use the Geo-prefixed topological predicate family plus GeoWithinDistance.

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.

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);
}
ProviderSupportsSpatialMechanism
SQLite / SQLCiphertrueR*Tree virtual table (bbox) + in-process refine
PostgreSQL, MySQL, SQL Server, Oracle, DuckDBtrueenvelope-sidecar table (indexed bbox) + in-process refine
CosmosDBtruenative GeoJSON spatial index
MongoDBtrue2dsphere index
LiteDB, IndexedDB, Azure Table, DynamoDBfalse

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.

A rectangular area, used by WithinBoundingBox and returned by Geometry.GetEnvelope().

public readonly record struct GeoBoundingBox(
double MinLatitude, double MinLongitude,
double MaxLatitude, double MaxLongitude);

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; }
}

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.

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.

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");

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 optional

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 optional

Geometry is the base type; all types serialize as GeoJSON in the document body.

TypeShape / rules
GeoPointa single coordinate (implicitly a point geometry)
GeoLineString2+ coordinates
GeoPolygonone exterior ring + optional interior rings (holes); each ring closed (first == last), 4+ points
GeoMultiPointset of points
GeoMultiLineStringset of line strings
GeoMultiPolygonset of polygons
GeoGeometryCollectionheterogeneous set of geometries

Construction (remember GeoPoint is (Latitude, Longitude)):

// A route
var 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 points
var 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 polygons
var region = new GeoMultiPolygon(new[] { zoneA, zoneB });

Geometry also exposes GetEnvelope() (its GeoBoundingBox) and the measurement/validity members described below.

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;
MethodMatches 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);

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:

ProviderMechanism & indexPredicate coverage
SQLiteR*Tree bbox prune + registered docdb_st_* UDF refine (dependency-free)all
PostgreSQLnative geometry sidecar column + GiST spatial index (needs PostGIS)all
MySQLnative GEOMETRY column + SPATIAL indexall (CoversContains)
DuckDBnative GEOMETRY column + R-Tree index (auto-loads spatial)all except WithinDistance
SQL Servernative planar geometry column + spatial index + .ST* methodsall except Covers/CoveredBy and WithinDistance
Oraclenative SDO_GEOMETRY column + MDSYS spatial index + SDO_RELATE operators (requires Oracle Spatial)all except Crosses
CosmosDBnative ST_INTERSECTS/ST_WITHIN/ST_DISTANCE (native spatial index)Intersects, Within, Disjoint, WithinDistance
MongoDBnative $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 CoversContains). 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.

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).

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.

Geometry exposes in-memory helpers computed directly on the object (geodesic approximations):

MemberMeaning
Areaarea in m² (0 for points/lines)
Length / Perimetertotal length / ring perimeter in m
Centroidthe centroid GeoPoint
NumPoints / NumGeometriescoordinate count / sub-geometry count
IsValidcoordinate finiteness/range, min-vertex counts, ring closure, and (polygons) exterior ring non-self-intersection
IsSimpleno 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 shapes

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:

PredicateSQLite / relationalCosmosDBMongoDB
GeoIntersectsbbox + refinenative ST_INTERSECTSnative $geoIntersects
GeoContainedBybbox + refinenative ST_WITHINnative $geoWithin
GeoWithinDistancebbox + refinenative ST_DISTANCEnative $near/$centerSphere (point)
GeoDisjointtype scan + refinenative NOT ST_INTERSECTStype scan + refine
GeoContains / Covers / CoveredBy / Touches / Crosses / Overlaps / Equalsbbox + refineST_INTERSECTS candidates + refine$geoIntersects candidates + refine
distance orderingin-processnative ST_DISTANCE orderpoint ref native, geometry ref in-process

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.

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.

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.

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.

  • GeoDisjoint is 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_*/SDO pushdown 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/Geometry property per document type.