Skip to main content

Geospatial features of MongoDB

5 min read

Older Article

This article was published 13 years ago. Some information may be outdated or no longer applicable.

In previous articles I covered converting data from MySQL to MongoDB and the reasons you’d want to. This time, let’s look at MongoDB’s geospatial features.

I’ve worked with MySQL a lot and I like it. For applications with rigid data and stable schemas, it does the job. But MySQL’s geospatial capabilities feel thin compared to what MongoDB ships with. Here’s a look at some of them.

There are several ways to index geospatial data in MongoDB. We’ll focus on two: 2d and 2dsphere. But first, let’s talk about what kind of location data you can store, and how.

MongoDB supports two surface types: Spherical and Flat. Spherical calculates geometry over Earth-like spheres. Flat uses the Euclidean plane. If your data is Spherical, you need the 2dsphere index. The 2d index is for Flat surfaces.

Time to store some objects. MongoDB supports three types of GeoJSON objects: Points, LineStrings, and Polygons. As I mentioned in a previous post, we’re working with public transportation. That means detecting a person’s location and then listing nearby public transport stops.

For simplicity, I’ve ported all the stops into one separate collection. Here’s a sample query returning one stop so you can see the document structure:

db.stops.find().limit(1).pretty();
{
  "_id" : ObjectId("52275591f0a49d6b3b8b93ad"),
  "stopID" : 1,
  "name" : "Dulceri",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.5386254,
      41.8839509
    ]
  }
}

Now we add a 2dsphere index on the gps key:

db.stops.ensureIndex({ gps: '2dsphere' });

Let’s verify the index was created:

db.stops.getIndexes();
[
  {
    v: 1,
    key: {
      _id: 1,
    },
    ns: 'test.stops',
    name: '_id_',
  },
  {
    v: 1,
    key: {
      gps: '2dsphere',
    },
    ns: 'test.stops',
    name: 'gps_2dsphere',
  },
];

First, grab the user’s location using the HTML5 Geolocation API and print the latitude and longitude:

if (navigator.geolocation) {
  console.log('Geolocation API is supported!');
  window.onload = function () {
    navigator.geolocation.getCurrentPosition(
      function (position) {
        document.getElementById('lat').innerHTML = position.coords.latitude;
        document.getElementById('lon').innerHTML = position.coords.longitude;
      },
      function (error) {
        alert('Error occurred. Error code: ' + error.code);
      }
    );
  };
} else {
  console.log('Geolocation API is not supported.');
}
Lat: <span id="lat"></span> | Lon: <span id="lon"></span>

With the user’s location in hand, we can search for the nearest public transport stops using the $near operator:

db.stops.find( { 'gps' : { $near : { $geometry: { type: 'Point', coordinates: [<lon>, <lat>] } }, $maxDistance: <meters> } } );

Let’s say our user is near the Colosseum and we want all stops within 200 metres. The Colosseum’s GPS coordinates are lon: 12.492269, lat: 41.890169.

Coordinates are specified in longitude-latitude order. This trips people up.

db.stops.find( { 'gps' : { $near : { $geometry: { type: 'Point', coordinates: [12.492269, 41.890169] } }, $maxDistance: 200 } } ).pretty();
{
  "_id" : ObjectId("52275592f0a49d6b3b8b96cd"),
  "stopID" : 70744,
  "name" : "Celio Vibenna",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.4928042,
      41.8893081
    ]
  }
}
{
  "_id" : ObjectId("52275592f0a49d6b3b8b9704"),
  "stopID" : 70816,
  "name" : "Celio Vibenna",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.4926191,
      41.8891265
    ]
  }
}
{
  "_id" : ObjectId("52275591f0a49d6b3b8b9584"),
  "stopID" : 70339,
  "name" : "Colosseo/Salvi",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.4935037,
      41.8908301
    ]
  }
}
{
  "_id" : ObjectId("52275595f0a49d6b3b8bb1d9"),
  "stopID" : 79524,
  "name" : "salvi n.",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.492466647743,
      41.891388486684
    ]
  }
}
{
  "_id" : ObjectId("52275591f0a49d6b3b8b9585"),
  "stopID" : 70340,
  "name" : "Colosseo",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.4914758,
      41.8912735
    ]
  }
}
{
  "_id" : ObjectId("52275592f0a49d6b3b8b972d"),
  "stopID" : 70865,
  "name" : "colosseo/salvi n.",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.493945,
      41.88964
    ]
  }
}
{
  "_id" : ObjectId("52275595f0a49d6b3b8bb553"),
  "stopID" : 90037,
  "name" : "Colosseo",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.4915001,
      41.8914366
    ]
  }
}
{
  "_id" : ObjectId("52275592f0a49d6b3b8b975c"),
  "stopID" : 70940,
  "name" : "Colosseo",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.494191,
      41.889942
    ]
  }
}
{
  "_id" : ObjectId("52275591f0a49d6b3b8b95fc"),
  "stopID" : 70479,
  "name" : "Colosseo",
  "gps" : {
    "type" : "Point",
    "coordinates" : [
      12.4906572,
      41.891276
    ]
  }
}

Plenty of hits. But what if we want diagnostic information, like which stop is closest and how far away it is? That’s where $geoNear comes in, part of the aggregation framework. Let’s rework the query.

One thing caught me off guard with $geoNear. The documentation shows an example like this:

db.places.aggregate([
  {
    $geoNear: {
      near: [40.724, -73.997],
      distanceField: 'dist.calculated',
      maxDistance: 0.008,
      query: { type: 'public' },
      includeLocs: 'dist.location',
      uniqueDocs: true,
      num: 5,
    },
  },
]);

So I put together a similar query for my collection:

db.stops.aggregate([
  {
    $geoNear: {
      near: [12.492269, 41.890169],
      distanceField: 'distance',
      limit: 3,
    },
  },
]);

It failed with this error:

Error: Printing Stack Trace
    at printStackTrace (src/mongo/shell/utils.js:37:15)
    at DBCollection.aggregate (src/mongo/shell/collection.js:897:9)
    at (shell):1:10
Thu Sep 12 18:57:15.582 aggregate failed: {
  "errmsg" : "exception: geoNear command failed: { ns: \"test.stops\", errmsg: \"exception: geoNear on 2dsphere index requires spherical\", code: 16683, ok: 0.0 }",
  "code" : 16604,
  "ok" : 0
} at src/mongo/shell/collection.js:898

The fix? Add spherical: true. Since we’re using a 2dsphere index, this is required (it defaults to false):

db.stops.aggregate([ { $geoNear: { near: [12.492269, 41.890169], distanceField: "distance", spherical: true, limit: 3 } } ]);

{
  "result" : [
    {
      "_id" : ObjectId("52275592f0a49d6b3b8b96cd"),
      "stopID" : 70744,
      "name" : "Celio Vibenna",
      "gps" : {
        "type" : "Point",
        "coordinates" : [
          12.4928042,
          41.8893081
        ]
      },
      "distance" : 0.000016556852802056673
    },
    {
      "_id" : ObjectId("52275592f0a49d6b3b8b9704"),
      "stopID" : 70816,
      "name" : "Celio Vibenna",
      "gps" : {
        "type" : "Point",
        "coordinates" : [
          12.4926191,
          41.8891265
        ]
      },
      "distance" : 0.000018754470809543685
    },
    {
      "_id" : ObjectId("52275591f0a49d6b3b8b9584"),
      "stopID" : 70339,
      "name" : "Colosseo/Salvi",
      "gps" : {
        "type" : "Point",
        "coordinates" : [
          12.4935037,
          41.8908301
        ]
      },
      "distance" : 0.00001976050947245558
    }
  ],
  "ok" : 1
}

The query returns the 3 closest stops, each with a distance value. The results come back sorted by distance automatically, closest first.

For more on MongoDB’s geo features, I’d recommend this blog post.