Skip to content
Client v5: BLE, BLE Hosting, HTTP, Jobs - Linux, MacOS, & Blazor Support! Full AOT, RX on BLE only & MANY other features! Power up!

Spatial / Geo Queries

Spatial queries let you find documents by geographic proximity — within a radius, inside a bounding box, or nearest to a point. Supported on SQLite (via R*Tree virtual tables) and CosmosDB (via native GeoJSON). Other providers throw NotSupportedException.

A geographic coordinate using WGS84. Serializes as GeoJSON {"type":"Point","coordinates":[longitude,latitude]}.

public readonly record struct GeoPoint(double Latitude, double Longitude);

A rectangular area for bounding box queries.

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

Wraps a document with its computed distance from the query center point.

public class SpatialResult<T> where T : class
{
public required T Document { get; init; }
public double DistanceMeters { get; init; }
}

Register which GeoPoint property to use for spatial indexing per document type using MapSpatialProperty:

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

You can map multiple types:

var options = new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}
.MapSpatialProperty<Restaurant>(r => r.Location)
.MapSpatialProperty<Hotel>(h => h.Coordinates);

The mapped property may be a nullable GeoPoint? — map it the same way, and a document whose location is null is simply left out of the spatial index (it still stores and queries normally otherwise). This is the common case for records where coordinates are optional, e.g. a calendar event that may or may not have a place attached:

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 or updating an event with a null location does not throw and does not index it; those documents never appear in WithinRadius/WithinBoundingBox/NearestNeighbors results. Clearing a previously-set location on update (setting it back to null) purges the stale index entry, so subsequent spatial queries no longer match it.

Not all providers support spatial queries. Check at runtime:

if (store.SupportsSpatial)
{
var nearby = await store.WithinRadius<Restaurant>(center, 5000);
}
ProviderSupportsSpatial
SQLitetrue (when spatial properties are mapped)
SQLCiphertrue (inherits SQLite R*Tree support)
CosmosDBtrue (when spatial properties are mapped)
LiteDBfalse
MySQLfalse
SQL Serverfalse
PostgreSQLfalse

Find documents within a distance (meters) of a center point, ordered by distance ascending. Returns SpatialResult<T> with the computed distance.

var nearby = await store.WithinRadius<Restaurant>(
new GeoPoint(45.5231, -122.6765), // Portland, OR
5000); // 5km radius
foreach (var result in nearby)
{
Console.WriteLine($"{result.Document.Name}{result.DistanceMeters:N0}m away");
}

With an additional predicate filter:

var italianNearby = await store.WithinRadius<Restaurant>(
new GeoPoint(45.5231, -122.6765),
5000,
filter: r => r.Cuisine == "Italian");

Find documents within a rectangular geographic area.

var inArea = await store.WithinBoundingBox<Restaurant>(
new GeoBoundingBox(45.0, -123.0, 46.0, -122.0));

With a filter:

var inArea = await store.WithinBoundingBox<Restaurant>(
new GeoBoundingBox(45.0, -123.0, 46.0, -122.0),
filter: r => r.Cuisine == "Italian");

Find the K closest documents to a point, ordered by distance ascending.

var closest = await store.NearestNeighbors<Restaurant>(
new GeoPoint(45.5231, -122.6765),
count: 10);
foreach (var result in closest)
{
Console.WriteLine($"{result.Document.Name}{result.DistanceMeters:N0}m");
}

With a filter:

var closestItalian = await store.NearestNeighbors<Restaurant>(
new GeoPoint(45.5231, -122.6765),
count: 5,
filter: r => r.Cuisine == "Italian");

SQLite uses R*Tree virtual tables for spatial indexing. For each table with spatial-mapped types, the library creates two companion tables:

  • {table}_spatial_map — maps document text IDs to integer rowids (R*Tree requires integer keys)
  • {table}_spatial — the R*Tree index storing latitude/longitude bounding boxes

These tables are created automatically when the table is first used. CRUD operations automatically sync the spatial index:

OperationSpatial Sync
Insert / Update / UpsertExtracts GeoPoint from document and upserts into R*Tree
RemoveDeletes spatial entry for that document
ClearRemoves all spatial entries for that type

Radius queries use a two-phase approach: the R*Tree provides a fast bounding box pre-filter, then a Haversine post-filter computes exact distances for precision.

Nearest neighbor queries use an expanding bounding box strategy — starting at 10km and doubling until enough candidates are found.

CosmosDB has built-in geospatial support. The GeoPoint type serializes as GeoJSON directly in the document:

{
"id": "rest-1",
"typeName": "Restaurant",
"data": {
"name": "Pizzeria Roma",
"location": {
"type": "Point",
"coordinates": [-122.6765, 45.5231]
}
}
}

CosmosDB automatically indexes GeoJSON properties. The library adds spatial index policies to the container during initialization. Queries use native SQL functions:

  • WithinRadius: ST_DISTANCE(c.data.location, point) <= @radius
  • WithinBoundingBox: ST_WITHIN(c.data.location, polygon)
  • NearestNeighbors: ORDER BY ST_DISTANCE(c.data.location, point) OFFSET 0 LIMIT @count

No sidecar tables or manual sync needed — CosmosDB handles everything natively.