Type something to search...
Geospatial Queries in MongoDB: Building Location-Based Features

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 query option 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 key option.
  • distanceMultiplier scales the distance, for example 0.001 to 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

OperatorQuestion it answersSorted by distanceReturns distance
$nearWhat's closest to this point?YesNo
$nearSphereSame, legacy spherical variantYesNo
$geoNear (stage)What's closest, and how far is it?YesYes
$geoWithinWhat's inside this shape or radius?NoNo
$geoIntersectsWhich shapes touch or contain this geometry?NoNo

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.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading