Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 035 / 100

Geospatial queries: a MongoDB & NoSQL guide for developers

Find nearby places.

Expona Admin7 min read

Why geospatial queries matter in production systems

Location-based features are everywhere in modern applications—from ride-hailing to food delivery to retail store locators. If you're building these features, geospatial queries: a MongoDB & NoSQL guide for developers will show you how to query geographic data efficiently at scale. MongoDB's geospatial capabilities let you find nearby places, calculate distances, and filter by geographic boundaries without writing complex spatial logic yourself.

We've seen teams stumble here. They either store latitude and longitude as separate fields (then filter in application code), or they build geospatial queries incorrectly and end up with timeouts on large datasets. In this article, we'll walk through storing GeoJSON properly, indexing for performance, and using query operators that work in real-world scenarios like delivery zones and store locators.

Store GeoJSON points the right way

MongoDB's geospatial queries work with GeoJSON, an open standard for encoding geographic data structures. The format is strict, and small mistakes will cause queries to fail silently or return wrong results.

A GeoJSON point looks like this:

javascript
{
  location: {
    type: "Point",
    coordinates: [-73.97, 40.77]  // [longitude, latitude]
  }
}

Notice the order: longitude first, latitude second. This catches everyone once. Latitude and longitude are reversed from what you might expect if you're used to mapping APIs like Google Maps or Leaflet, which typically use [latitude, longitude]. MongoDB uses the geographic standard: [longitude, latitude].

For a restaurant collection, you might store:

javascript
db.restaurants.insertOne({
  name: "Pasta House",
  cuisine: "Italian",
  location: {
    type: "Point",
    coordinates: [-73.9857, 40.7484]  // Manhattan
  },
  deliveryZone: {
    type: "Polygon",
    coordinates: [[
      [-73.98, 40.74],
      [-73.99, 40.74],
      [-73.99, 40.75],
      [-73.98, 40.75],
      [-73.98, 40.74]
    ]]
  }
})

GeoJSON supports multiple geometry types: Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon, and GeometryCollection. For most use cases (store locations, delivery zones, service areas), Point and Polygon are sufficient. Store points as a nested object with type and coordinates. This structure is what MongoDB's geospatial indexes expect.

Create a 2dsphere index for performance

Without an index, geospatial queries will scan every document. For a collection with millions of locations, this becomes unusable quickly. A 2dsphere index is designed for queries on Earth's surface using WGS84 coordinates (latitude and longitude).

Create a 2dsphere index on your location field:

javascript
db.restaurants.createIndex({ location: "2dsphere" })

If you're querying by multiple fields—say, location and cuisine—create a compound index:

javascript
db.restaurants.createIndex({
  location: "2dsphere",
  cuisine: 1
})

MongoDB will now use the index to quickly narrow down candidates before distance calculations. Without this index, a $near query on 10 million documents might take seconds. With it, you'll see sub-100ms responses.

You can verify your index is being used by running explain():

javascript
db.restaurants.find({
  location: {
    $near: {
      $geometry: {
        type: "Point",
        coordinates: [-73.9857, 40.7484]
      },
      $maxDistance: 5000
    }
  }
}).explain("executionStats")

Look for "stage": "GEONEAR" in the output. If you see "COLLSCAN", the index isn't being used—check your field name and GeoJSON structure.

Query nearby locations with $near

The $near operator finds documents sorted by distance from a point. It's perfect for "show me the 5 closest restaurants to my location."

javascript
// Find restaurants within 5km, sorted by distance
db.restaurants.find({
  location: {
    $near: {
      $geometry: {
        type: "Point",
        coordinates: [-73.9857, 40.7484]  // User's location
      },
      $maxDistance: 5000  // 5km in meters
    }
  }
}).limit(5)

Results come back sorted by distance from closest to farthest. The $maxDistance field is optional but recommended—it acts as a hard cutoff. Without it, MongoDB will return all documents if they exist, which might not be what you want.

If you need exact distances in your response, use the aggregation pipeline instead:

javascript
db.restaurants.aggregate([
  {
    $geoNear: {
      near: {
        type: "Point",
        coordinates: [-73.9857, 40.7484]
      },
      distanceField: "distance",
      maxDistance: 5000,
      spherical: true
    }
  },
  { $limit: 5 },
  { $project: { name: 1, distance: 1 } }
])

This returns each restaurant with its exact distance in meters. The aggregation approach is more flexible—you can add stages to filter by cuisine, rating, or other criteria after the geospatial stage.

Filter by geographic region with $geoWithin

The $geoWithin operator finds all documents inside a geographic shape. Unlike $near, results are not sorted by distance. It answers questions like "which delivery zones cover this address?" or "which stores are in California?"

javascript
// Find all restaurants within a rectangular zone
db.restaurants.find({
  location: {
    $geoWithin: {
      $box: [
        [-74.0, 40.7],  // bottom-left [longitude, latitude]
        [-73.9, 40.8]   // top-right [longitude, latitude]
      ]
    }
  }
})

For more complex shapes, use Polygon:

javascript
// Find restaurants in a specific delivery zone
db.restaurants.find({
  location: {
    $geoWithin: {
      $geometry: {
        type: "Polygon",
        coordinates: [[
          [-73.98, 40.74],
          [-73.99, 40.74],
          [-73.99, 40.75],
          [-73.98, 40.75],
          [-73.98, 40.74]
        ]]
      }
    }
  }
})

Polygon coordinates must be closed (first and last point are the same). The order matters for determining inside vs. outside—follow the right-hand rule: as you trace the boundary, the interior is on your right. For most use cases, either approach works, but Polygon is more intuitive when you're working with predefined zones.

Real-world: Delivery zones in an order system

Imagine a food delivery platform. When a customer places an order, you need to find restaurants that can deliver to their address. Each restaurant has a delivery zone (Polygon). You also want to rank by distance.

javascript
db.restaurants.aggregate([
  {
    $match: {
      deliveryZone: {
        $geoIntersects: {
          $geometry: {
            type: "Point",
            coordinates: [-73.9857, 40.7484]  // Customer address
          }
        }
      }
    }
  },
  {
    $geoNear: {
      near: {
        type: "Point",
        coordinates: [-73.9857, 40.7484]
      },
      distanceField: "distance",
      maxDistance: 10000,
      spherical: true
    }
  },
  { $match: { rating: { $gte: 4.0 } } },
  { $sort: { distance: 1 } },
  { $limit: 10 }
])

This pipeline first filters to restaurants whose delivery zone contains the customer's point using $geoIntersects. Then it sorts by distance and applies additional filters. The customer sees 10 viable restaurants, ranked by how close they are.

Real-world: Store locator on a frontend

For a store locator feature, you want to show users the nearest stores without returning millions of records. A typical frontend request looks like this:

javascript
// Backend API handler
app.get('/api/stores/nearby', async (req, res) => {
  const { lat, lng, maxDistance = 50000 } = req.query;
  
  const stores = await db.collection('stores').aggregate([
    {
      $geoNear: {
        near: {
          type: "Point",
          coordinates: [parseFloat(lng), parseFloat(lat)]
        },
        distanceField: "distanceMeters",
        maxDistance: parseInt(maxDistance),
        spherical: true
      }
    },
    { $limit: 20 },
    {
      $project: {
        id: "$_id",
        name: 1,
        address: 1,
        phone: 1,
        distanceMiles: { $divide: ["$distanceMeters", 1609.34] },
        _id: 0
      }
    }
  ]).toArray();
  
  res.json(stores);
});

The frontend sends latitude, longitude, and optionally a max search radius. The backend converts distance to miles in the projection (1 mile = 1609.34 meters) and returns a clean response. This approach scales to thousands of stores and responds in milliseconds.

Common mistakes to avoid

  • Reversed coordinates: Longitude first, latitude second. Always. If your store locator shows results in the ocean, you've swapped them.
  • Missing or wrong index: Geospatial queries without a 2dsphere index scan the entire collection. Check with explain().
  • Using 2d instead of 2dsphere: The older 2d index is for flat coordinates, not Earth. Always use 2dsphere for real-world locations.
  • Forgetting to close Polygon rings: GeoJSON Polygons require the first and last coordinate to be identical. MongoDB will silently reject invalid geometries.
  • Mixing $near with $geoWithin: You can't use both in the same query. Decide whether you need distance sorting ($near) or inclusion checking ($geoWithin).
  • Not specifying $maxDistance on $near: Without it, a failed query might return thousands of results instead of timing out cleanly.
  • Storing coordinates as separate fields: Don't store lat and lng as separate number fields and try to query them manually. Use the GeoJSON structure.

Key takeaways

  • Store locations as GeoJSON Points with longitude first, latitude second inside a nested object with type and coordinates.
  • Create a 2dsphere index on your location field before running production queries.
  • Use $near to find nearby places sorted by distance, and $geoWithin to check if a point is inside a polygon or region.
  • Distances in MongoDB geospatial queries are in meters. Convert to miles, kilometers, or other units in your application layer.
  • Combine geospatial queries with the aggregation pipeline for filtering by rating, cuisine, delivery zone, and other business logic.
  • For production systems, always verify your index is being used and measure response times under realistic data volumes.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles