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 $near with $or.
  • Can't use $near inside 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?".

Three geo questions: $near ranks points by distance, $geoWithin finds points inside a polygon, $geoIntersects finds shapes a route touches


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

Note: this post is in progress. Planned additions: H3 hexagons + Mongo recipe; vector tile generation; Atlas Geospatial with charts; multi-modal queries (geo + vector).