
Geospatial Queries in MongoDB: Building Location-Based Features
"Show coffee shops near me." "Do you deliver to my address?" "Which drivers are within 3 km of this pickup?" Location features look simple on a product spec and then turn into a mess of haversine formulas, bounding boxes, and queries that scan every row in the table. Doing the math in application code works for a hundred locations. It doesn't work for a million.
MongoDB has geospatial support built in. You store locations as GeoJSON, create a 2dsphere index, and query with operators like $near, $geoWithin, and $geoIntersects, or the $geoNear aggregation stage. The index understands that the Earth is round, distances come back in meters, and queries that would be full scans become fast index lookups.
This guide covers storing points and shapes, creating geospatial indexes, finding nearby places, sorting by distance, checking whether a point falls inside a delivery zone, combining location with other filters, and the coordinate mistakes almost everyone makes once.
Storing Locations as GeoJSON
MongoDB uses the GeoJSON standard for geographic data. The most common shape is a Point:
{
name: "Blue Door Coffee",
category: "cafe",
location: {
type: "Point",
coordinates: [-0.1276, 51.5072] // [longitude, latitude]
}
}
The order matters and it's the opposite of what most people expect: longitude first, then latitude. Map apps and most humans say "latitude, longitude," so this is the single most common geospatial bug. A point with the values swapped either lands somewhere unexpected (often in the ocean) or is rejected because latitude must be between -90 and 90.
Longitude ranges from -180 to 180, latitude from -90 to 90. If a coordinate is out of range, inserting it into a collection with a 2dsphere index fails with a "Can't extract geo keys" error, which is actually helpful: it catches swapped values early.
Other GeoJSON types you'll use:
// A delivery zone
{
name: "Zone A - Central",
area: {
type: "Polygon",
coordinates: [[
[-0.15, 51.50],
[-0.10, 51.50],
[-0.10, 51.53],
[-0.15, 51.53],
[-0.15, 51.50] // first and last point must be identical
]]
}
}
// A route
{
name: "Bus 38",
path: {
type: "LineString",
coordinates: [[-0.14, 51.51], [-0.12, 51.52], [-0.10, 51.53]]
}
}
Polygons are arrays of rings, and each ring must be closed: the last coordinate repeats the first. The first ring is the outer boundary; additional rings are holes. MultiPoint, MultiLineString, MultiPolygon, and GeometryCollection are supported too.
Creating a 2dsphere Index
Geospatial queries need an index. For GeoJSON data on a globe, that's a 2dsphere index:
db.places.createIndex({ location: "2dsphere" });
Let's load some sample places around central London:
db.places.insertMany([
{
name: "Blue Door Coffee",
category: "cafe",
rating: 4.6,
location: { type: "Point", coordinates: [-0.1276, 51.5072] },
},
{
name: "Tidewater Books",
category: "books",
rating: 4.8,
location: { type: "Point", coordinates: [-0.1246, 51.5114] },
},
{
name: "Ember Pizza",
category: "restaurant",
rating: 4.3,
location: { type: "Point", coordinates: [-0.1337, 51.5136] },
},
{
name: "Morning Roast",
category: "cafe",
rating: 4.1,
location: { type: "Point", coordinates: [-0.0877, 51.5155] },
},
{
name: "Riverside Cafe",
category: "cafe",
rating: 4.4,
location: { type: "Point", coordinates: [-0.1195, 51.5033] },
},
]);
You'll also see 2d indexes in older code. Those treat coordinates as points on a flat plane, which is fine for things like positions on a game map but inaccurate for real-world locations over any meaningful distance. For anything on Earth, use 2dsphere.
Finding Nearby Places With $near
$near returns documents sorted from nearest to farthest. $maxDistance and $minDistance are in meters when you use GeoJSON:
db.places.find(
{
location: {
$near: {
$geometry: { type: "Point", coordinates: [-0.1269, 51.508] },
$maxDistance: 1000,
},
},
},
{ name: 1, _id: 0 },
);
[
{ name: "Blue Door Coffee" },
{ name: "Tidewater Books" },
{ name: "Riverside Cafe" },
{ name: "Ember Pizza" },
];
"Morning Roast" is about 2.8 km away, so it's excluded. The results are already sorted by distance, which is exactly what a "near me" list needs. Add .limit(20) and you have a working feature.
What $near doesn't give you is the actual distance. For that, use $geoNear.
Getting Distances With $geoNear
The $geoNear aggregation stage does everything $near does and also writes the distance into each document:
db.places.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [-0.1269, 51.508] },
distanceField: "distanceMeters",
maxDistance: 1500,
query: { category: "cafe" },
spherical: true,
},
},
{
$project: {
_id: 0,
name: 1,
rating: 1,
distanceMeters: { $round: ["$distanceMeters", 0] },
},
},
]);
[
{ name: "Blue Door Coffee", rating: 4.6, distanceMeters: 101 },
{ name: "Riverside Cafe", rating: 4.4, distanceMeters: 732 },
];
A few rules for $geoNear:
- It must be the first stage in the pipeline.
- Use its
queryoption for extra filters instead of a later$match. That way the filter is applied during the geo search, so you don't lose results to a limit before filtering. - If the collection has more than one geospatial index, specify which field to use with the
keyoption. distanceMultiplierscales the distance, for example0.001to get kilometers.
Once distances are in the pipeline, you can do things like show "0.6 km away," or build a ranking that blends distance and rating:
db.places.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [-0.1269, 51.508] },
distanceField: "dist",
maxDistance: 3000,
spherical: true,
},
},
{
$set: {
rankScore: {
$subtract: [{ $multiply: ["$rating", 1000] }, "$dist"],
},
},
},
{ $sort: { rankScore: -1 } },
{ $limit: 10 },
]);
The formula here is deliberately simple (each star of rating is worth a kilometer), but the pattern of "geo first, then custom scoring" is how most real "recommended nearby" lists work.
Searching Within an Area With $geoWithin
$geoWithin returns documents located entirely inside a shape. It doesn't sort by distance, which makes it cheaper than $near, and it works in places $near can't, like countDocuments().
Within a Circle
For a radius search without sorting, use $centerSphere. The radius is in radians, which means dividing the distance by the Earth's radius (about 6,378.1 km or 3,963.2 miles):
const radiusKm = 2;
db.places.countDocuments({
location: {
$geoWithin: {
$centerSphere: [[-0.1269, 51.508], radiusKm / 6378.1],
},
},
});
4;
This is a good way to show "42 places within 2 km" on a map without fetching or sorting them.
Within a Polygon
To find everything inside an arbitrary shape, like a neighborhood boundary or a region the user drew on a map:
db.places.find({
location: {
$geoWithin: {
$geometry: {
type: "Polygon",
coordinates: [
[
[-0.14, 51.505],
[-0.12, 51.505],
[-0.12, 51.515],
[-0.14, 51.515],
[-0.14, 51.505],
],
],
},
},
},
});
Viewport Queries for Maps
When a user pans a map, you usually want everything inside the visible rectangle. Build a polygon from the map's bounds:
function boundsToPolygon({ west, south, east, north }) {
return {
type: "Polygon",
coordinates: [
[
[west, south],
[east, south],
[east, north],
[west, north],
[west, south],
],
],
};
}
const places = await db
.collection("places")
.find({ location: { $geoWithin: { $geometry: boundsToPolygon(bounds) } } })
.limit(500)
.toArray();
Always cap the number of results. A zoomed-out map of a whole country can match hundreds of thousands of points, and at that zoom level you should be showing clusters, not pins.
Delivery Zones With $geoIntersects
$geoIntersects flips the question around. Instead of "which points are in this shape?", it asks "which stored shapes contain or touch this point?" That's exactly the delivery-zone problem: you store zones as polygons and look up which one a customer's address falls in.
db.zones.createIndex({ area: "2dsphere" });
db.zones.insertMany([
{
name: "Central",
deliveryFee: 2.5,
area: {
type: "Polygon",
coordinates: [
[
[-0.15, 51.5],
[-0.1, 51.5],
[-0.1, 51.53],
[-0.15, 51.53],
[-0.15, 51.5],
],
],
},
},
{
name: "East",
deliveryFee: 3.5,
area: {
type: "Polygon",
coordinates: [
[
[-0.1, 51.5],
[-0.05, 51.5],
[-0.05, 51.53],
[-0.1, 51.53],
[-0.1, 51.5],
],
],
},
},
]);
db.zones.findOne(
{
area: {
$geoIntersects: {
$geometry: { type: "Point", coordinates: [-0.0877, 51.5155] },
},
},
},
{ _id: 0, name: 1, deliveryFee: 1 },
);
{ name: "East", deliveryFee: 3.5 }
If findOne returns null, you don't deliver to that address. Notice that the two zones share a boundary at longitude -0.10. A point exactly on that line intersects both, so decide on a tie-breaker (such as the lowest fee or an explicit priority field) and sort by it.
$geoIntersects also works for lines: find every zone a delivery route passes through by passing a LineString as the geometry.
Combining Location With Other Filters
Most location queries also filter on something else: category, open now, price range. A compound index can include the geo field alongside regular fields:
db.places.createIndex({ category: 1, location: "2dsphere" });
With the equality field first, queries that filter by category and location can use one index efficiently:
db.places.find({
category: "cafe",
location: {
$near: {
$geometry: { type: "Point", coordinates: [-0.1269, 51.508] },
$maxDistance: 2000,
},
},
});
A document missing the geo field isn't indexed by a 2dsphere index (it's sparse by default for the geo key), so such documents never appear in geo queries.
Building a Nearby API
Here's a Node.js endpoint that returns nearby places with distances, filtered by category:
import express from "express";
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI);
const places = client.db("city").collection("places");
const app = express();
function parseCoord(value, min, max) {
const n = Number(value);
return Number.isFinite(n) && n >= min && n <= max ? n : null;
}
app.get("/api/nearby", async (req, res) => {
const lng = parseCoord(req.query.lng, -180, 180);
const lat = parseCoord(req.query.lat, -90, 90);
if (lng === null || lat === null) {
return res.status(400).json({ error: "lng and lat are required" });
}
const radius = Math.min(Number(req.query.radius) || 1000, 10000);
const query = req.query.category
? { category: String(req.query.category) }
: {};
const results = await places
.aggregate([
{
$geoNear: {
near: { type: "Point", coordinates: [lng, lat] },
distanceField: "distance",
maxDistance: radius,
query,
spherical: true,
},
},
{ $limit: 25 },
{
$project: {
name: 1,
category: 1,
rating: 1,
distance: { $round: ["$distance", 0] },
lng: { $arrayElemAt: ["$location.coordinates", 0] },
lat: { $arrayElemAt: ["$location.coordinates", 1] },
},
},
])
.toArray();
res.json({ results });
});
app.listen(3000);
Validating coordinates up front gives users a clear error instead of a database exception, and capping the radius prevents someone from requesting "everything within 20,000 km."
In Python, the equivalent $near query looks like this:
from pymongo import MongoClient, GEOSPHERE
places = MongoClient("mongodb://localhost:27017")["city"]["places"]
places.create_index([("location", GEOSPHERE)])
nearby = places.find(
{
"location": {
"$near": {
"$geometry": {"type": "Point", "coordinates": [-0.1269, 51.5080]},
"$maxDistance": 1000,
}
}
},
{"name": 1, "_id": 0},
).limit(10)
for place in nearby:
print(place["name"])
Tracking Moving Things
For drivers, couriers, or devices that move, store the latest position in the entity's document and update it as new coordinates arrive:
db.drivers.updateOne(
{ _id: "driver_17" },
{
$set: {
location: { type: "Point", coordinates: [-0.1198, 51.5101] },
locationUpdatedAt: new Date(),
status: "available",
},
},
);
Then "find the nearest available driver" is a $near query with status: "available" and a freshness check on locationUpdatedAt so you don't dispatch someone whose phone went offline ten minutes ago. Keep the full location trail, if you need it, in a separate collection. A time series collection is a great fit for that, covered in time series collections for IoT and metrics data.
Operator Cheat Sheet
| Operator | Question it answers | Sorted by distance | Returns distance |
|---|---|---|---|
$near | What's closest to this point? | Yes | No |
$nearSphere | Same, legacy spherical variant | Yes | No |
$geoNear (stage) | What's closest, and how far is it? | Yes | Yes |
$geoWithin | What's inside this shape or radius? | No | No |
$geoIntersects | Which shapes touch or contain this geometry? | No | No |
Common Pitfalls
Swapping latitude and longitude. GeoJSON is [longitude, latitude]. If your results are in the wrong country or inserts fail with "Can't extract geo keys," check the order first.
Unclosed polygons. Each ring's last coordinate must equal its first. Without it, the polygon is invalid and the query or insert fails.
Mixing up units. $maxDistance with GeoJSON is in meters, while $centerSphere takes radians. Convert kilometers with km / 6378.1.
Using $near with countDocuments(). It isn't allowed, since $near sorts. Use $geoWithin with $centerSphere for counts.
Filtering after $geoNear with $match. If you also limit results, you may filter out everything. Put filters in the stage's query option.
Polygons spanning huge areas. For shapes larger than a hemisphere, MongoDB's default behavior may pick the smaller of the two possible interpretations. Keep zones reasonably sized, or read up on custom coordinate reference systems for "big polygons."
Storing coordinates as strings. They'll fail validation or be ignored. Store numbers, and consider schema validation to enforce the GeoJSON shape.
Conclusion
MongoDB turns location features into ordinary queries. Store points and shapes as GeoJSON with longitude first, create a 2dsphere index, and pick the right operator: $near or $geoNear for distance-sorted results, $geoWithin for anything inside a shape or radius, and $geoIntersects for looking up which zone a point belongs to. Compound indexes let you mix location with category or status filters without giving up speed.
Take one location feature on your roadmap, whether it's a store finder or a delivery-zone check, and prototype it in mongosh with a handful of real coordinates. Once the query returns the right results, the API around it is just the endpoint above with your own field names.


