Nearby Search in Postgres: Lat/Lng Math Before You Install PostGIS

Most 'near me' features are a distance sort over a few thousand points. How to build it with plain lat/lng columns, a bounding box, and one index, and the point where PostGIS starts earning its install.

Nearby Search in Postgres: Lat/Lng Math Before You Install PostGIS

The request comes up in half of the client projects we build: list the stores, branches, couriers, or clinics, sort by distance from the user, hide anything beyond a radius. In SQL terms that is two lat/lng pairs per row and an ORDER BY. It does not need PostGIS, a spatial index, or a third-party geo service, at least not yet, and reaching for them first is how simple features acquire a learning curve they never paid back.

Store coordinates once, as numbers

Geocode each address one time, when the record is saved, and store the result in columns: lat decimal(9,6), lng decimal(9,6), plus the geocoder name and its returned identifier, so the record can be re-geocoded later when the provider's data improves.

Two details that save real time later:

  • Cache the failed geocode too. A row with no coordinates should not trigger an API call on every page view. A boolean or a stored "no result" marker turns a rate-limit headache into a background job you run once.
  • Six decimal places is overkill for a store location, but it costs nothing, and one scale across tables avoids the recurring review question about how many digits the columns actually have.

A bounding box first, exact math second

The classic mistake is filtering the whole table by the haversine formula and ordering on it. Postgres evaluates that expression on every row and uses no index. The fix is two steps: use a cheap rectangular filter that the index understands, then compute the exact distance only for the handful of rows that survived.

One degree of latitude is about 111 km regardless of where you are. A degree of longitude shrinks toward the poles by the cosine of the latitude, at roughly 74 km per degree at Riga's latitude. So:

$latDelta = $radius / 111.0;
$lngDelta = $radius / (111.0 * max(cos(deg2rad($lat)), 0.01));

$distance = '6371 * acos(least(1,
    sin(radians(?)) * sin(radians(lat))
    + cos(radians(?)) * cos(radians(lat)) * cos(radians(lng - ?))))';

$bindings = [$lat, $lat, $lng];

$query->whereBetween('lat', [$lat - $latDelta, $lat + $latDelta])
    ->whereBetween('lng', [$lng - $lngDelta, $lng + $lngDelta])
    ->whereRaw("$distance <= ?", [...$bindings, $radius])
    ->orderByRaw($distance, $bindings);

In practice the radius check is easiest as a second whereRaw wrapping the same expression. One composite btree index on (lat, lng) carries the whereBetween pair. For a few hundred thousand rows the whole thing stays in single-digit milliseconds, because the exact haversine only runs on rows inside the box, which by construction is a tiny fraction of the table.

The corners you should still know about

  • The dateline. The bounding box math quietly breaks for anything near longitude ±180, which in practice means Fiji and a few ocean datasets. If your customers are in Latvia or the continental US, you will never see it; still worth a comment in the code so nobody applies the query blindly to global data later.
  • Sort order is approximate when points cluster. Two locations 40 meters apart may swap in the ordering depending on float rounding. If the UI displays "the nearest one first," round distances before display, or two adjacent pins will hop around the list between page loads.
  • Distances are great-circle ("as the crow flies"). A store a kilometer across the river reads as closer than one 2 km along the road. Users care about the road. If that gap ever becomes a support theme, that is the point where a routing API call on the top 3 candidates beats making the underlying math fancier.

Where PostGIS actually earns the install

PostGIS becomes the right answer when your questions stop being "distance between two points": service areas drawn as polygons, geofences that fire when a courier crosses a boundary, "find all records whose area contains this point," spatial joins between two tables of geometries, or tables in the millions where you want geometric indexing and k-nearest-neighbor ordering rather than hand-written math. It is an extension on an existing Postgres database, not an architectural migration, but pulling it in for a distance sort is buying a truck to carry a backpack.

The parts teams underestimate live outside the database anyway: geocoding user input is where accuracy is lost, through typos, ambiguous city names, and rate limits. Cache aggressively, batch where you can, and show distances the way humans use them. A pin the user can drag is usually more honest than GPS coordinates reported to six decimal places.