MongoDB Geospatial
MongoDB Geospatial
Description
Use when designing or troubleshooting MongoDB geospatial queries, indexes, or data models. Covers GeoJSON storage, 2dsphere and 2d index types, proximity and containment operators ($near, $geoWithin, $geoNear, $geoIntersects), radius calculations, $lookup pipeline joins across spatial collections, and common anti-patterns. Apply this skill whenever a schema includes location fields, a query filters by distance or bounding region, or a $geoNear aggregation stage needs tuning.
1. GeoJSON Object Types
MongoDB natively stores and queries the GeoJSON spec. Every GeoJSON field is an embedded document with a type string and a coordinates array. Longitude always comes before latitude — the opposite of most mapping UIs.
// Point — single location
{ type: "Point", coordinates: [-73.9857, 40.7484] } // [lng, lat]
// LineString — ordered sequence of positions
{
type: "LineString",
coordinates: [
[-73.9857, 40.7484],
[-74.0060, 40.7128]
]
}
// Polygon — closed ring; first and last coordinate must be equal
{
type: "Polygon",
coordinates: [[
[-73.9580, 40.8003],
[-73.9498, 40.7968],
[-73.9737, 40.7648],
[-73.9818, 40.7681],
[-73.9580, 40.8003] // closes the ring
]]
}
// MultiPoint
{ type: "MultiPoint", coordinates: [[-73.98, 40.75], [-74.01, 40.71]] }
// MultiLineString
{
type: "MultiLineString",
coordinates: [
[[-73.98, 40.75], [-74.00, 40.72]],
[[-73.97, 40.76], [-73.95, 40.74]]
]
}
// MultiPolygon
{
type: "MultiPolygon",
coordinates: [
[[ [-74.0, 40.7], [-73.9, 40.7], [-73.9, 40.8], [-74.0, 40.8], [-74.0, 40.7] ]],
[[ [-73.85, 40.65], [-73.75, 40.65], [-73.75, 40.75], [-73.85, 40.75], [-73.85, 40.65] ]]
]
}
// GeometryCollection — heterogeneous mix
{
type: "GeometryCollection",
geometries: [
{ type: "Point", coordinates: [-73.9857, 40.7484] },
{ type: "LineString", coordinates: [[-73.98, 40.75], [-74.00, 40.72]] }
]
}
Insert a document with a GeoJSON Point field:
db.places.insertOne({
name: "Empire State Building",
location: { type: "Point", coordinates: [-73.9857, 40.7484] },
category: "landmark"
});
2. 2dsphere Indexes
A 2dsphere index supports queries on GeoJSON geometry computed over a sphere modelled on WGS84 (the same datum used by GPS). Version 3 has been the default since MongoDB 3.2; MongoDB 8.3+ defaults to version 4. It handles Points, LineStrings, and Polygons stored as GeoJSON and supports all geospatial query operators.
// Basic 2dsphere index on a GeoJSON field
db.places.createIndex({ location: "2dsphere" });
// Compound index — location plus a scalar field
db.places.createIndex({ location: "2dsphere", category: 1 });
// Check the index was created correctly
db.places.getIndexes();
// → { "key": { "location": "2dsphere" }, "name": "location_2dsphere", "2dsphereIndexVersion": 3 }
// Specify index version explicitly (rarely needed)
db.places.createIndex({ location: "2dsphere" }, { "2dsphereIndexVersion": 3 });
Key properties:
- Handles wraparound at the anti-meridian (180° longitude) correctly.
- Required by
$geoNear,$near,$nearSphere,$geoWithinwith$centerSphere. - 2dsphere indexes are always sparse (MongoDB ignores the
sparseoption). A document missing the geo field — or where it isnullor an empty array — is not indexed, whether the index is standalone or compound. In a compound 2dsphere index, only the geo field determines whether a document is indexed.
3. 2d Indexes
A 2d index uses planar (flat-earth) geometry. It is a legacy index type intended for coordinate pairs stored as [lng, lat] arrays (not GeoJSON documents). Use it only when the coordinate space is genuinely flat (e.g., game maps, CAD drawings, grid systems) and spherical correction is not needed.
// Legacy coordinate pair stored as an array
db.legacy.insertOne({ name: "HQ", loc: [-73.98, 40.75] });
// Create a 2d index
db.legacy.createIndex({ loc: "2d" });
// Optional: define the bounding box and granularity
db.legacy.createIndex({ loc: "2d" }, { min: -180, max: 180, bits: 26 });
// $near on a 2d index — returns sorted by distance, Euclidean
db.legacy.find({ loc: { $near: [-73.98, 40.75], $maxDistance: 0.5 } });
Limitations vs. 2dsphere:
- No polygon-edge wraparound.
- Distance unit is degrees, not metres.
- Does not support GeoJSON input documents.
- Cannot use
$geoNearaggregation withspherical: true.
4. $geoNear Aggregation Stage
$geoNear must be the first stage of an aggregation pipeline. It returns documents sorted by computed distance from a reference point and appends the distance value to each document under distanceField. A geospatial index is required; if multiple exist, specify key.
db.places.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [-73.9857, 40.7484] },
distanceField: "dist.calculated", // field added to output docs
maxDistance: 2000, // metres (spherical: true)
minDistance: 100,
query: { category: "restaurant" }, // pre-filter before distance
spherical: true, // required for 2dsphere index
key: "location" // required when > 1 geo index exists
}
},
{ $limit: 10 },
{ $project: { name: 1, "dist.calculated": 1, _id: 0 } }
]);
distanceMultiplier converts metres to another unit:
{
$geoNear: {
near: { type: "Point", coordinates: [-73.9857, 40.7484] },
distanceField: "distKm",
distanceMultiplier: 0.001, // metres → kilometres
spherical: true
}
}
includeLocs records the matched location field alongside distance:
{ $geoNear: { ..., includeLocs: "matchedLocation", spherical: true } }
5. $geoWithin
$geoWithin finds documents whose geometry is entirely contained within a specified shape. It does not sort results and does not require a geospatial index (though an index improves performance significantly on large collections).
// Within a GeoJSON Polygon
db.places.find({
location: {
$geoWithin: {
$geometry: {
type: "Polygon",
coordinates: [[
[-74.0, 40.7], [-73.9, 40.7],
[-73.9, 40.8], [-74.0, 40.8],
[-74.0, 40.7]
]]
}
}
}
});
// $centerSphere — circle on a sphere; radius in radians
// radians = distanceKm / 6378.1
const radiusKm = 5;
db.places.find({
location: {
$geoWithin: {
$centerSphere: [ [-73.9857, 40.7484], radiusKm / 6378.1 ]
}
}
});
// $box — planar rectangle (2d index only)
db.legacy.find({ loc: { $geoWithin: { $box: [[-74.1, 40.6], [-73.8, 40.9]] } } });
// $polygon — planar polygon (2d index only)
db.legacy.find({ loc: { $geoWithin: { $polygon: [[-74, 40.7], [-73.9, 40.7], [-73.95, 40.85]] } } });
6. $geoIntersects
$geoIntersects finds documents whose GeoJSON geometry intersects — shares any point with — the query geometry. Useful for routes, delivery zones, and region overlap checks. Requires a 2dsphere index for good performance.
// Find all routes that pass through a query polygon
db.routes.find({
path: {
$geoIntersects: {
$geometry: {
type: "Polygon",
coordinates: [[
[-74.02, 40.69], [-73.97, 40.69],
[-73.97, 40.74], [-74.02, 40.74],
[-74.02, 40.69]
]]
}
}
}
});
// Find zones that contain a specific point (point in polygon)
db.zones.find({
boundary: {
$geoIntersects: {
$geometry: { type: "Point", coordinates: [-73.9857, 40.7484] }
}
}
});
// Works with LineString query geometry too
db.regions.find({
area: {
$geoIntersects: {
$geometry: {
type: "LineString",
coordinates: [[-74.0, 40.7], [-73.9, 40.8]]
}
}
}
});
7. $near and $nearSphere
$near and $nearSphere are query operators (not aggregation stages). Both sort results by distance and require a geospatial index. They cannot be used inside $or or $and alongside other $near/$nearSphere expressions.
// $near with GeoJSON (requires 2dsphere index, metres)
db.places.find({
location: {
$near: {
$geometry: { type: "Point", coordinates: [-73.9857, 40.7484] },
$maxDistance: 1500, // metres
$minDistance: 100 // metres
}
}
});
// $nearSphere with GeoJSON (spherical interpretation, metres)
db.places.find({
location: {
$nearSphere: {
$geometry: { type: "Point", coordinates: [-73.9857, 40.7484] },
$maxDistance: 2000
}
}
});
// $near with legacy coordinate pair (2d index, degrees)
db.legacy.find({
loc: { $near: [-73.98, 40.75], $maxDistance: 0.5 }
});
// Combine with additional filter fields
db.places.find({
category: "coffee",
location: {
$near: {
$geometry: { type: "Point", coordinates: [-73.9857, 40.7484] },
$maxDistance: 800
}
}
});
$near vs. $geoNear: Use $near for a simple .find() that returns sorted documents. Use $geoNear when you need the distance value in the result, further pipeline stages, or more control (distanceMultiplier, query pre-filter, key selection).
8. Radius Queries
Converting a real-world radius to the unit each operator expects:
| Operator / context | Unit | Conversion from km |
|---|---|---|
$near / $nearSphere GeoJSON |
metres | km * 1000 |
$geoNear maxDistance (spherical) |
metres | km * 1000 |
$centerSphere radians |
radians | km / 6378.1 (Earth radius km) |
$centerSphere miles |
radians | miles / 3963.2 (Earth radius mi) |
$near legacy 2d |
degrees | km / 111.2 (approx) |
// 5 km radius — $geoWithin $centerSphere (radians)
const radiusKm = 5;
db.places.find({
location: {
$geoWithin: {
$centerSphere: [ [-73.9857, 40.7484], radiusKm / 6378.1 ]
}
}
});
// 10 miles radius — $geoWithin $centerSphere
const radiusMi = 10;
db.places.find({
location: {
$geoWithin: {
$centerSphere: [ [-73.9857, 40.7484], radiusMi / 3963.2 ]
}
}
});
// 2 km radius — $near (metres)
db.places.find({
location: {
$near: {
$geometry: { type: "Point", coordinates: [-73.9857, 40.7484] },
$maxDistance: 2000
}
}
});
// Helper function for application code
function kmToRadians(km) { return km / 6378.1; }
function milesToRadians(mi) { return mi / 3963.2; }
9. Geospatial $lookup Patterns
Geospatial query operators ($geoIntersects, $geoWithin, $near) are query operators, not aggregation expression operators — they cannot be used inside $expr. Inside a $lookup pipeline stage, place them directly inside $match against a field in the joined collection. The outer document’s location must be supplied via $geoNear output or by denormalizing the coordinate.
// Pattern A: two-stage pipeline — $geoNear first, then $lookup on a scalar key
// (most scalable; spatial work happens in the first stage against the driving collection)
db.orders.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [-73.9857, 40.7484] },
distanceField: "dist",
spherical: true,
maxDistance: 5000
}
},
{
$lookup: {
from: "zones",
localField: "zoneId", // scalar ID pre-assigned at write time
foreignField: "_id",
as: "zone"
}
}
]);
// Pattern B: $lookup with a pipeline — geo filter inside $match on the joined collection
// Works when the joined collection (zones) has a 2dsphere index on `boundary`
// and each order document carries a static query polygon (e.g. a stored bounding box)
db.orders.aggregate([
{
$lookup: {
from: "zones",
pipeline: [
{
$match: {
boundary: {
$geoIntersects: {
$geometry: { type: "Point", coordinates: [-73.9857, 40.7484] }
}
}
}
}
],
as: "matchedZones"
}
}
]);
// Limitation: the coordinates above are a literal — to pass a per-document point
// into a $lookup pipeline, use the denormalize pattern below instead.
// Recommended pattern: denormalize zone ID at write time
// Step 1 — resolve the zone when creating the order
async function findZoneForPoint(db, point) {
return db.collection("zones").findOne({
boundary: { $geoIntersects: { $geometry: point } }
});
}
// Step 2 — store zoneId on the order document
await db.collection("orders").insertOne({
_id: orderId,
location: { type: "Point", coordinates: [lng, lat] },
zoneId: zone._id // denormalized scalar — cheap to $lookup later
});
// Step 3 — simple $lookup on zoneId at query time (no per-row geo scan)
db.orders.aggregate([
{ $lookup: { from: "zones", localField: "zoneId", foreignField: "_id", as: "zone" } }
]);
Performance considerations for geospatial $lookup:
$geoIntersects/$geoWithininside a$lookuppipeline runs once per driving document; ensure a 2dsphere index on the joined collection’s geometry field.- Add a
$matchwith a bounding-box$geoWithinbefore$lookupto narrow candidates when the joined collection is large. - Denormalizing the zone or region ID at write time (Recommended pattern above) eliminates the per-row geo scan entirely and scales best.
- For hot-path proximity queries at scale, consider Atlas Search
$searchwith ageoWithinorgeoShapefilter, which uses a dedicated search index and avoids aggregation pipeline overhead.
10. Anti-Patterns
// BAD — coordinate order wrong (lat, lng instead of lng, lat)
{ type: "Point", coordinates: [40.7484, -73.9857] } // silently stores wrong location
// GOOD
{ type: "Point", coordinates: [-73.9857, 40.7484] } // lng, lat
// BAD — querying without a 2dsphere index
// $near will throw: "unable to find index for $geoNear query"
db.places.find({ location: { $near: { $geometry: { type: "Point", coordinates: [-73.98, 40.75] } } } });
// Always create the index first:
db.places.createIndex({ location: "2dsphere" });
// BAD — polygon spanning more than 180 degrees of longitude
// MongoDB interprets the smaller interior; polygons > 180° may be treated as their complement
{
type: "Polygon",
coordinates: [[
[-170, -80], [170, -80], [170, 80], [-170, 80], [-170, -80] // spans 340°, ambiguous
]]
}
// GOOD — split into two polygons or use multipolygon; keep each ring < 180°
// BAD — $geoNear not as first aggregation stage
db.places.aggregate([
{ $match: { category: "cafe" } }, // pre-filter before $geoNear — causes error
{ $geoNear: { near: { ... }, distanceField: "d", spherical: true } }
]);
// GOOD — $geoNear must be stage 0; use query: {} inside $geoNear for pre-filtering:
db.places.aggregate([
{ $geoNear: { near: { ... }, distanceField: "d", spherical: true, query: { category: "cafe" } } }
]);
// BAD — mixing 2d index with GeoJSON queries
db.places.createIndex({ loc: "2d" });
db.places.find({ loc: { $near: { $geometry: { type: "Point", coordinates: [-73.98, 40.75] } } } });
// 2d index does not support GeoJSON $geometry form; use 2dsphere
// BAD — omitting spherical: true on a 2dsphere index with $geoNear
db.places.aggregate([
{ $geoNear: { near: { type: "Point", coordinates: [-73.98, 40.75] }, distanceField: "d" } }
// missing spherical: true — uses planar distance, wrong results over large distances
]);
// GOOD
{ $geoNear: { ..., spherical: true } }
Anti-Patterns Summary Table
| Anti-pattern | Symptom | Fix |
|---|---|---|
| Lat/lng coordinate order | Queries return wrong or empty results | Always use [longitude, latitude] |
| No 2dsphere index | $near / $geoNear throw error |
createIndex({ field: "2dsphere" }) |
| Polygon ring > 180° | Wrong containment, complement selected | Split into MultiPolygon or smaller rings |
$geoNear not first stage |
Aggregation error | Move $geoNear to stage index 0 |
GeoJSON $geometry on 2d index |
Index not used or query error | Use 2dsphere index for GeoJSON queries |
Missing spherical: true |
Planar distance used, wrong results at scale | Always set spherical: true with 2dsphere |
$near inside $or / $and |
Query planner error | Restructure; use $geoNear in pipeline instead |
$centerSphere radius in km not radians |
Radius far too large or small | Divide km by 6378.1 to get radians |
References
- MongoDB Geospatial Queries overview: https://www.mongodb.com/docs/manual/geospatial-queries/
- Geospatial query operator reference: https://www.mongodb.com/docs/manual/reference/operator/query-geospatial/
- 2dsphere index documentation: https://www.mongodb.com/docs/manual/core/2dsphere/
- $geoNear aggregation stage: https://www.mongodb.com/docs/manual/reference/operator/aggregation/geoNear/
- GeoJSON objects reference: https://www.mongodb.com/docs/manual/reference/geojson/
- Geospatial tutorial (find restaurants): https://www.mongodb.com/docs/manual/tutorial/geospatial-tutorial/