MongoDB Course — 27 · Geospatial Deep Dive — 2dsphere, GeoJSON, $near, Geofences
MongoDB ships first-class geospatial support — 2dsphere indexes, GeoJSON document fields, and $near / $geoWithin / $geoIntersects operators that drive map apps, delivery dispatch, ride-share, geofencing, social location features, and fleet tracking. This post deep-dives every relevant feature: the index types, GeoJSON object format, all the query operators, geofencing patterns, sorting by distance + paginating, sharding by region, and the common pitfalls (lng/lat order, antimeridian wrap, accuracy).
Series map: 25 Testing → 26 OpenTelemetry → 27 Geospatial deep (this post) → 28 Time-series extreme → 29 K8s + mongosync + cost.
Learning Outcome
- Pick between 2dsphere and legacy 2d indexes.
- Store points + polygons + multi-polygons as GeoJSON.
- Query with
$near,$nearSphere,$geoWithin,$geoIntersects,$centerSphere,$polygon. - Implement geofencing (enter / exit events).
- Sort + paginate by distance.
- Shard by region without breaking geo queries.
- Avoid the lng/lat / antimeridian / radians-vs-metres traps.
Chapter 1 — Index types
| Index | Use |
|---|---|
| 2dsphere | spherical geometry on the Earth. The modern choice. Supports GeoJSON. |
| 2d | legacy planar geometry on a flat 2D plane. Use only for non-Earth coords (game maps, indoor coords). |
1
2
db.places.createIndex({ location: "2dsphere" })
2dsphere handles all the spherical math (great-circle distances, antimeridian wrap, polar regions) correctly. Always pick it for real-world maps.
Chapter 2 — GeoJSON
Store coordinates as GeoJSON objects, NOT as bare arrays:
1
2
3
4
5
6
7
8
// Point — for cities, sensors, devices
{
_id: "p1",
name: "London Bridge",
location: { type: "Point", coordinates: [-0.0877, 51.5079] }
// ^lng ^lat
}
Coordinate order: [longitude, latitude]. Not [lat, lng]. This is the #1 mistake — GeoJSON uses lng-first; Google Maps uses lat-first in its URLs. Get it backward and your London point lands in the Indian Ocean, off the coast of Somalia.
Polygon — closed area:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
_id: "z1",
name: "Central London zone",
area: {
type: "Polygon",
coordinates: [[
[-0.13, 51.50],
[-0.08, 51.50],
[-0.08, 51.52],
[-0.13, 51.52],
[-0.13, 51.50] // first + last must match (closed ring)
]]
}
}
Other types: LineString, MultiPoint, MultiLineString, MultiPolygon, GeometryCollection. Each has a defined coordinates shape per the GeoJSON spec.
Chapter 3 — $near + $nearSphere — nearest-K by distance
1
2
3
4
5
6
7
8
9
10
db.places.find({
location: {
$near: {
$geometry: { type: "Point", coordinates: [-0.1, 51.5] },
$minDistance: 0, // metres (Point + 2dsphere)
$maxDistance: 5000 // 5 km
}
}
})
Returns documents sorted by distance ascending. $near requires a 2dsphere index.
For legacy 2d indexes (planar), $near returns distance in radians; that's why most teams ditched 2d.
$nearSphere is the legacy alias when working with 2d indexes for spherical results. With 2dsphere both work identically.
Limitations:
- Can't combine
$nearwith$or. - Can't use
$nearinside aggregation$match— use$geoNear(Chapter 6). - Implicit sort by distance — can't override.
Chapter 4 — $geoWithin — inside an area
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// inside a polygon
db.places.find({
location: {
$geoWithin: {
$geometry: { type: "Polygon", coordinates: [[ ... ]] }
}
}
})
// inside a circle (centre + radius in radians — use earth's radius / metres)
const EARTH_RADIUS_METRES = 6_378_100;
db.places.find({
location: {
$geoWithin: {
$centerSphere: [[-0.1, 51.5], 5000 / EARTH_RADIUS_METRES]
}
}
})
// inside a box (legacy planar)
db.places.find({
location: { $geoWithin: { $box: [[-0.13, 51.50], [-0.08, 51.52]] } }
})
$geoWithin doesn't sort. Faster than $near when you only need set membership.
$centerSphere radius is in radians — divide metres by Earth's radius (≈ 6,378,100 m) to convert.
Trick: for "all delivery couriers within 5 km of this address — order doesn't matter for the dispatcher's filter", use
$geoWithin: { $centerSphere: [...] }. It's noticeably faster than$near.
Chapter 5 — $geoIntersects — overlapping geometries
1
2
3
4
5
6
7
8
9
// find polygons that intersect a line (e.g., flood plain crossing roads)
db.flood_zones.find({
area: {
$geoIntersects: {
$geometry: { type: "LineString", coordinates: [[lng1, lat1], [lng2, lat2]] }
}
}
})
Pairs of geometries that touch or overlap — useful for "does this delivery route pass through any blocked zone?".
Chapter 6 — $geoNear in aggregation
$near doesn't work in aggregation $match. Use $geoNear — must be the first stage.
1
2
3
4
5
6
7
8
9
10
11
12
13
db.places.aggregate([
{ $geoNear: {
near: { type: "Point", coordinates: [-0.1, 51.5] },
distanceField: "distance_m", // adds field with distance in metres
maxDistance: 5000,
spherical: true, // 2dsphere
query: { type: "restaurant" } // additional filter
}},
{ $sort: { distance_m: 1 } },
{ $limit: 20 },
{ $project: { name: 1, distance_m: 1 } }
])
distanceField adds the computed distance per result document. Useful for "show distance in the UI".
Required: 2dsphere index on the near.location field. Must be the first stage.
Chapter 7 — Geofencing — enter / exit events
A geofence is a polygon (or circle) you monitor — when a device enters or exits, fire an event.
Two patterns:
Pattern A — poll per location update
When a device sends { location: {Point} }:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const fences = await db.geofences.find({
area: { $geoIntersects: { $geometry: device.location } }
}).toArray();
const previousFenceIds = await getDeviceFenceState(device.id);
const currentFenceIds = fences.map(f => f._id);
for (const newFence of currentFenceIds.filter(id => !previousFenceIds.includes(id))) {
emit('fence.enter', { device: device.id, fence: newFence });
}
for (const oldFence of previousFenceIds.filter(id => !currentFenceIds.includes(id))) {
emit('fence.exit', { device: device.id, fence: oldFence });
}
await setDeviceFenceState(device.id, currentFenceIds);
Index geofences.area as 2dsphere; the $geoIntersects query is fast.
Pattern B — change-stream + materialised current-location collection
Devices write current_location to a per-device document; a change-stream worker (Post 12) does the fence check + emits events. Decouples ingest from geo logic.
For high-volume telemetry (10k devices, 1 Hz each), pattern B is more scalable.
Chapter 8 — Distance + accuracy
2dsphere uses WGS84 — the standard Earth ellipsoid. Distances are accurate within centimetres of haversine over the surface.
Edge cases:
- Polar regions — geometries cross the pole; spherical math handles them but UIs often need special rendering.
- Antimeridian (180° / -180° longitude in the Pacific) — polygons spanning it must be split. Tooling like
@turf/turf(Node) helps. - Long lines — straight in 2D maps but geodesic in 3D. Use spherical
$geoIntersects; map UIs render great-circle correctly.
Don't store location as { lat: ..., lng: ... } if you want geo queries. Always GeoJSON-shaped ({ type: "Point", coordinates: [lng, lat] }).
Chapter 9 — Sorting + paginating by distance
$near returns sorted ascending — that's the easy case.
For "page 2 of nearest 20", .skip(20).limit(20) works but pays the skip cost (Post 02). For huge result sets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// page 1
const page1 = await db.places.aggregate([
{ $geoNear: { near: pt, distanceField: "dist_m", spherical: true, maxDistance: 50000 } },
{ $sort: { dist_m: 1, _id: 1 } },
{ $limit: 20 }
]).toArray();
const last = page1[page1.length - 1];
// page 2 — cursor-based
const page2 = await db.places.aggregate([
{ $geoNear: { near: pt, distanceField: "dist_m", spherical: true, maxDistance: 50000 } },
{ $match: { $or: [
{ dist_m: { $gt: last.dist_m } },
{ dist_m: last.dist_m, _id: { $gt: last._id } }
]}},
{ $sort: { dist_m: 1, _id: 1 } },
{ $limit: 20 }
]).toArray();
Range pagination on (distance, _id). Scales.
Chapter 10 — Sharding + geospatial
2dsphere works on sharded clusters. Shard keys:
- For multi-region apps, shard on a region tag (
{ region: 1, _id: 1 }) — keeps each region's data on its own shard, geo queries within a region target one shard. - A hashed location shard key spreads writes evenly but scatters geo queries across all shards (bad for hot regions).
For dispatch apps where "find nearest courier" frequently hits one region, region-tagged sharding wins.
Chapter 11 — Putting it together — courier dispatch
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// couriers — current location + zone tag
db.createCollection("couriers")
db.couriers.createIndex({ status: 1, current_location: "2dsphere" })
// zones — geofences for cities / districts
db.createCollection("zones")
db.zones.createIndex({ area: "2dsphere" })
// dispatching nearest available courier
const candidates = await db.couriers.aggregate([
{ $geoNear: {
near: drop_point,
distanceField: "dist_m",
spherical: true,
maxDistance: 10000,
query: { status: "available", capacity: { $gt: 0 } }
}},
{ $limit: 5 }
]).toArray();
Geofence on pickup: courier's location enters the customer's pickup zone → emit pickup.imminent event.
Heat map of demand: $bucket on $geoNear results to produce a histogram of pending orders per distance bin from the depot.
All powered by one 2dsphere index per relevant collection.
Chapter 12 — Useful tooling
| Tool | Use |
|---|---|
@turf/turf (Node) |
client-side geometry math (buffer, simplify, intersect) |
shapely (Python) |
same, server-side computation |
tippecanoe |
convert big GeoJSON → vector tiles |
| GeoJSON.io | visual editor — draw polygons in a browser |
| Mapbox / Leaflet | render maps in the UI |
| H3 (Uber) | hex-grid spatial indexing — useful alongside 2dsphere for batch joins |
Atlas doesn't ship H3 natively; you compute the H3 cell as a derived field + index it for fast spatial joins ("all events in this hex").
Chapter 13 — Common pitfalls
| Pitfall | Cause | Fix |
|---|---|---|
| Point in the wrong ocean | swapped lat/lng | always [lng, lat] |
$near returns no results |
no 2dsphere index | create the index |
$near in aggregation $match |
not allowed | use $geoNear as first stage |
Wrong unit in $centerSphere |
radius in radians, not metres | metres / EARTH_RADIUS_METRES |
| Polygon not closed | first ≠ last coord | repeat the first as the last |
| Polygon crosses antimeridian | rendered as huge wrap | split at ±180° |
| Slow geo queries | low-selectivity index | combine with a query (status, type) filter |
| Distances off by huge factor | mixed 2d (planar) + 2dsphere assumptions | always 2dsphere for Earth coords |
| Inconsistent precision | storing as floats with 4-decimal rounding | store all 7 decimals for sub-cm |
| Sharded geo cross-shard | shard key doesn't include region | shard on region |
Practice Exercises
Exercise 1 — Insert + query points
Insert 100 random points around London (lng -0.4 to 0.2, lat 51.3 to 51.6). Index 2dsphere. Find the 10 nearest to King's Cross.
Exercise 2 — Polygons
Define 3 polygon zones (north, central, south). Find which zone King's Cross is in ($geoIntersects with the point).
Exercise 3 — Geofence pattern
Implement Chapter 7 pattern A. Simulate a device moving through the zones; emit enter / exit events to stdout.
Exercise 4 — $geoNear aggregation
Find the 10 nearest restaurants within 5 km that are rated ≥ 4 stars. Include distance + name.
Exercise 5 — Range pagination by distance
Build the cursor-based geo pagination from Chapter 9. Page through 100 places.
Exercise 6 — Sharded geo
(Atlas / local sharded.) Shard a couriers collection on { city: 1, _id: 1 }. Confirm $geoNear queries with city: "London" target one shard.
Cheat-sheet
| What | How |
|---|---|
| Index | { field: "2dsphere" } |
| Point | { type: "Point", coordinates: [lng, lat] } |
| Polygon | { type: "Polygon", coordinates: [[ [lng,lat], ..., [first lng,lat] ]] } |
| Nearest sorted | $near (find) |
| Nearest in aggregation | $geoNear (first stage) |
| Inside polygon | $geoWithin: { $geometry: polygon } |
| Inside circle | $geoWithin: { $centerSphere: [centre, radius/EARTH_R] } |
| Intersects | $geoIntersects: { $geometry: ... } |
| Distance in metres | use 2dsphere; result in metres |
| Coordinate order | lng, lat — not lat, lng |
| Polygon closure | first = last coord |
| Antimeridian | split polygons crossing ±180 |
| Geofence | poll $geoIntersects per update or change-stream-driven |
| Pagination by distance | cursor on (dist, _id) |
| Sharding | region prefix → in-region queries hit one shard |
| Helper libs | @turf/turf, shapely, H3 |
What's next
Post 28 covers time-series at extreme scale — when native time-series collections hit limits, ClickHouse / TimescaleDB / InfluxDB comparison, and when to leave MongoDB for the analytics layer.
References
- Geospatial Queries
- 2dsphere Indexes
$geoNear- GeoJSON Specification (RFC 7946)
- Turf.js — JS geometry library
- Uber H3 — hex spatial index
Note: this post is in progress. Planned additions: H3 hexagons + Mongo recipe; vector tile generation; Atlas Geospatial with charts; multi-modal queries (geo + vector).










