"Places near here" is one of the few things Elasticsearch does that has no reasonable equivalent in a plain relational query, and it is one of the easiest features to turn on. It also has a trap in the very first step that puts your data on the wrong continent without any error at all.
Mapping a geo_point
"latitude": {"type": "double"},
"longitude": {"type": "double"},
"location": {"type": "geo_point"},StayHub stores the coordinates twice, and that is deliberate rather than wasteful. The two
double fields are what a result card renders. location is a
geo_point, which is an entirely different structure — a BKD tree over encoded
coordinates — and only it can answer "within 10km".
You cannot query two numeric fields as a location. Distance is not expressible as two range filters; a box is, and a box is not a circle — and even the box gets the wrong answer near the poles or across the 180th meridian, where longitude wraps and a naive range comparison does not.
Five formats, and the one that will get you
A geo_point accepts several input shapes, all meaning San Francisco:
{ "loc": { "lat": 37.7749, "lon": -122.4194 } } // object
{ "loc": "37.7749,-122.4194" } // string: "lat,lon"
{ "loc": [ -122.4194, 37.7749 ] } // array: [lon, lat] <-- note the order
{ "loc": "POINT (-122.4194 37.7749)" } // WKT: (lon lat)
{ "loc": "9q8yy" } // geohashRead those two middle lines again. The string form is "lat,lon". The array
form is [lon, lat]. Opposite orders, in the same API, for the same field.
The array follows GeoJSON, which specifies longitude first. The string follows the convention everyone says out loud. Both are defensible and together they are a trap.
Sometimes it fails loudly. Writing San Francisco as [37.7749, -122.4194]:
document_parsing_exception
illegal latitude value [-122.4194] for locRejected, because -122 is not a valid latitude. That is the lucky case.
The unlucky case is any location where both numbers are within both valid ranges —
anywhere within 90 degrees of the equator and 90 degrees of Greenwich, which is most of
Europe, Africa and the Middle East. Write [10, 20] meaning latitude 10, longitude 20:
created
distance from lat=20, lon=10: 0.0 kmAccepted, stored as longitude 10 and latitude 20, and it is exactly zero kilometres from the wrong place. A dataset of European addresses loaded this way is silently mirrored across the diagonal, every distance query returns plausible-looking wrong answers, and nothing anywhere logs a complaint.
The defence is to use the explicit object form in application code, where the field names make the order impossible to get wrong:
# geo_point only when both halves exist — ES rejects a half-populated one, and that rejection
# would fail the whole bulk request, not just this document.
if lat is not None and lon is not None:
doc["location"] = {"lat": lat, "lon": lon}Note the second trap in that comment. A half-populated point is refused, and in a bulk request that failure is not isolated to the document — it can take the request with it. Build the field only when both halves are present.
geo_distance
groups["geo"] = [{"geo_distance": {"distance": f"{req.radius_km}km", "location": origin}}]Everything within a radius. Distances accept units — km, mi,
m, yd, ft, nmi — and a bare number means
metres, which is a good way to search a 10-metre radius by accident.
It is a filter, and the reason is worth stating:
# `geo_distance` is a filter, not a query: "within 10km" is a yes/no, and how far inside
# the circle a listing sits should not change its ranking. If you want nearer to rank
# higher, that is `sort=distance`, below — or a `function_score` decay, which is post 11's
# subject rather than this one's.Lesson 10's rule applied to geography. "Within 10km" is not a matter of degree, so it belongs in
filter, where it costs no scoring and can be cached.
Distance is not exact, and that is a setting
distance_type defaults to arc, which computes great-circle distance on a
sphere. plane uses a flat approximation: much faster, and increasingly wrong as the
radius grows or the latitude gets extreme.
For a city-scale radius the difference is under a metre and plane is a free win. For
a country-scale one it is kilometres. If you are unsure, leave it on arc — the
cost is small and a wrong distance is a wrong result.
geo_bounding_box, for a map
A map viewport is a rectangle, not a circle, so the matching query is a rectangle:
{ "geo_bounding_box": { "location": {
"top_left": { "lat": 38.0, "lon": -123.0 },
"bottom_right": { "lat": 37.0, "lon": -122.0 }
} } }Cheaper than geo_distance — two range comparisons rather than a distance
calculation — and exactly what a "search as I move the map" feature wants, because the corners
come straight out of the map library.
The one edge case: a box crossing the 180th meridian, where the left longitude is greater than
the right. Elasticsearch handles it correctly. Code that validates
left < right before sending does not.
There is also geo_polygon for an arbitrary shape, which is what a "draw your own
search area" tool needs. It is deprecated in favour of geo_shape, below.
Sorting by distance
sort.append(
{
"_geo_distance": {
"location": origin,
"order": "asc",
"unit": "km",
# A listing with no coordinates sorts last instead of failing the query.
"ignore_unmapped": True,
}
}
)ignore_unmapped is the small detail that stops one listing without coordinates from
turning a whole search into a 400.
The interesting part is that the sort gives you the distance for free. Every hit carries its sort values, so the number Elasticsearch computed in order to rank is already in the response:
geo, within 300km of San Francisco, sorted by distance:
1.72 km San Francisco Sunlit Loft in the Mission
254.60 km Lake Tahoe Lakefront A-FrameThat is why StayHub appends a _geo_distance entry whenever coordinates are supplied,
even when the user asked to sort by relevance — the distance is wanted as a number on every
result card regardless of the ordering. With an explicit "_score" in front so the
ranking is untouched:
q=cabin, coordinates supplied, sort=relevance:
score=4.439 dist=631.94 Big Bear Lake Cedar Cabin with Mountain ViewsReading it back is a matter of knowing where in the sort array it landed:
distance = None
if geo_index is not None:
values = hit.get("sort") or []
if geo_index < len(values):
value = values[geo_index]
# ES sorts unmapped/missing coordinates to the end with Infinity, which is not a
# number any client should try to render as "1.8e308 km away".
if isinstance(value, (int, float)) and value != float("inf"):
distance = round(float(value), 2)The infinity check is not paranoia. A document without a location sorts last with
Double.MAX_VALUE, and serialising that into JSON produces a number your frontend will
happily render.
Half a coordinate is not a coordinate
The API takes latitude and longitude as separate optional parameters, which raises a question with only one right answer:
@property
def geo_point(self) -> dict[str, float] | None:
"""The origin for geo filtering and distance sorting, or None if geo is not in play.
⚠️ Half a coordinate is not a coordinate. A `lat` with no `lon` has to mean "no geo" rather
than "assume the prime meridian" — Elasticsearch would happily accept `lon: 0` and return
listings in the Gulf of Guinea for a search of Austin.
"""
if self.lat is None or self.lon is None:
return None
return {"lat": self.lat, "lon": self.lon}Defaulting a missing coordinate to zero is the kind of decision that looks like defensive programming and produces results from the middle of the Atlantic. One place decides whether geo is active, and it requires both halves.
The radius is separate again: coordinates without one produce distances but filter nothing, which is a genuinely useful combination for "sort by nearest, but show me everything".
Wiring it to a map
The API surface a map needs is small, and it is worth seeing all of it together because the parameters interact:
# Geo — the map viewport. All three are optional, and `lat`/`lon` are only honoured together;
# see SearchRequest.geo_point for why half a coordinate has to mean "no geo".
lat: float | None = Query(default=None, ge=-90, le=90),
lon: float | None = Query(default=None, ge=-180, le=180),
radius_km: float | None = Query(default=None, gt=0, le=500, alias="radiusKm"),Three things in there are load-bearing.
The ranges are validated at the boundary. A latitude outside ±90 is a 422 from the API rather than a parse error from the cluster, which is both a better message and one fewer way for a malformed request to reach Elasticsearch.
The radius is capped. An uncapped radius is a slower
match_all — it filters nothing and pays for the distance calculation anyway. 500km
is roughly "a region", and anything larger is a request that should not have geo on it.
The radius is optional even when coordinates are present, which gives three distinct behaviours from three parameters: no geo at all; distances on every hit with no filtering; and a genuine radius filter. A map that shows distances while the user browses nationally, and narrows when they zoom, is those three states.
The one that catches people: pan versus search
A map has two events that look alike and should not behave alike. Panning or zooming re-queries the visible area — a bounding box, no facets, small page size, fired often. Submitting a search is the full request with facets and ranking.
Sending the expensive one on every pan is the standard way to make a map feel slow and to
multiply search load by ten. This is exactly what the facets parameter from lesson 13
exists for, and a pan handler is its main customer.
Aggregating on geography
Two bucket aggregations are geo-specific, and both solve the same real problem: a map with ten thousand pins on it is not a map.
geohash_grid buckets points into a grid whose cell size is set by
precision:
{ "size": 0, "aggs": { "g": { "geohash_grid": { "field": "location", "precision": 5 } } } }
# [('9q8yy', 4)]Four listings in one cell. Raise the precision as the user zooms in and the clusters split apart
— which is how every map with numbered circles on it works. geotile_grid is the
same idea aligned to standard map tiles, which is usually easier to place on a slippy map because
the cells line up with the tiles you are already fetching.
geo_distance also exists as an aggregation, bucketing by distance band, which is
where "within 1km (12) · within 5km (48)" comes from.
And geo_centroid returns the average position of the documents in a bucket, which is
where to put the cluster marker so it sits over the listings rather than in the middle of the
cell.
Where the coordinates come from
Everything above assumes a latitude and longitude already exist on the record. Getting them there is its own problem and it is worth two paragraphs, because it is where the data quality that all of this depends on is decided.
Geocoding — turning "742 Evergreen Terrace, Springfield" into a point — is an external service, it costs money per lookup, and it is wrong often enough to matter. Do it once, at write time, and store the result. Doing it at query time makes every search depend on a third party's availability, which is a bad trade for a feature that is meant to make search faster.
Store what the geocoder told you about its own confidence, if it offers it. A rooftop-accurate match and a "we found the city, roughly" match are both a point, and treating them identically is how a listing ends up pinned to the centre of a metropolitan area. A precision field lets you exclude the vague ones from a tight-radius search rather than showing them and being wrong.
And validate before indexing. Latitude and longitude of exactly 0, 0 is Null Island
— a point in the Gulf of Guinea that is the single most populated location in careless
geographic datasets, because it is what a failed parse produces. A check for it, and for points
outside the countries you actually operate in, catches an entire class of import bug on the way in
rather than in a support ticket.
When a point is not enough
A geo_point is one location. geo_shape holds polygons, lines,
multi-polygons and collections — a neighbourhood boundary, a delivery zone, a river.
{ "boundary": { "type": "geo_shape" } }
{ "geo_shape": {
"boundary": {
"shape": { "type": "point", "coordinates": [-122.4194, 37.7749] },
"relation": "contains"
} } }The relation is the interesting parameter: intersects,
disjoint, within, contains. "Which delivery zones contain
this address" and "which listings fall inside this drawn area" are the same query with the shape and
the point swapped round.
The costs are real. Shapes are far larger to index and slower to query than points, and a
polygon with self-intersections or the wrong winding order is rejected with an error that takes some
reading. Use geo_point unless you genuinely need an area, and note that the coordinates
inside a shape are GeoJSON, so they are [lon, lat] — the same order as the array
format, and the same trap.
What geo costs
Worth knowing, because the intuition is usually that it is expensive and it mostly is not.
A geo_point is indexed into a BKD tree, the same structure numeric ranges use, so a
bounding box is roughly as cheap as two numeric range filters — which is very cheap.
geo_distance costs a little more: it uses a bounding box internally to reject most
candidates, then computes actual distances only for what survives. Both are filters, so both cache.
What is genuinely expensive is sorting by distance over a large result set, because the distance is computed for every match rather than for the ones that survive a filter. Pairing a distance sort with a radius filter is not just a product decision — the filter is what keeps the sort affordable.
And geo_shape is a different order of magnitude from geo_point in both
index size and query time. A complex polygon is decomposed into many indexed triangles, so a
country boundary is thousands of entries. When a shape field is slow, simplifying the geometry
usually helps more than any query change.
Getting it right
Four habits cover nearly every geo bug.
Use the object form on the way in. {"lat": ..., "lon": ...} cannot
be reversed by a refactor.
Assert on a known distance in a test. One document at a known location, one query from another, and an assertion that the distance is what a map says it is. It fails immediately if the order is wrong, and nothing else will:
def test_geo_distance_filters_and_sorts(self, index):
"""From San Francisco: the Mission loft is next door, Big Bear is ~600km, Málaga is a
different continent."""
result = run(index, lat=37.7749, lon=-122.4194, radius_km=50, sort="distance")
assert [h.city for h in result.hits] == ["San Francisco"]
assert result.hits[0].distance_km < 5Index the point only when both halves exist, and keep the display coordinates as plain numbers separately.
Filter, do not score. If nearer should rank higher, that is a decay function
from lesson 11, not a distance in must.
Two of those four are mapping and indexing decisions, which puts them back in lesson 3's
territory: a geo_point cannot be added to documents that already exist without a
reindex, and neither can the precision field. If there is any chance a location will ever be
searched geographically, index it as a point from the start — it costs almost nothing and it
is the difference between shipping the feature and scheduling a migration.