For AI agents: this page is also available as Markdown at /docs/geo-aggregations.md. Every docs page is: add .md to its URL. llms.txt lists them all.
Geo aggregations
Geo aggregations answer "how many, and where": how many cafes are within 1, 5 and 10 km, how matches spread across a map, or where to center and zoom a map so that it shows them all. They run over every match, in the same request as the hits, like any other facet or metric. The examples use the places collection from the Geo search quickstart.
| Aggregation | /v1 | Elasticsearch |
|---|---|---|
| Grid cells | facet type: geohash_grid, geotile_grid, geohex_grid | the aggregations of the same names |
| Distance rings | facet type: geo_distance | geo_distance |
| Bounds and centroid | metrics geo_bounds, geo_centroid | geo_bounds, geo_centroid |
| Query buckets | facet type: query with geo queries | filters with geo queries |
The geo facets and metrics work with any query language. They also work on geo_shape fields, except geohex_grid and geo_distance.
#Grid aggregations
A grid aggregation counts the matches in each cell of a grid, the building block of heat maps and clustered map markers. Each bucket can run metrics and nested facets:
{
"language": "lucene",
"query": "*:*",
"limit": 0,
"facets": {
"cells": {
"type": "geohash_grid", "field": "location", "precision": 5,
"metrics": {"avg_rating": "avg(rating)"}
}
}
}
"facets": {
"cells": {
"type": "geohash_grid",
"field": "location",
"buckets": [
{"value": "dr5ru", "count": 2, "metrics": {"avg_rating": 4.35}},
{"value": "dr5rg", "count": 1, "metrics": {"avg_rating": 4.5}},
{"value": "dr5rs", "count": 1, "metrics": {"avg_rating": 4.4}},
{"value": "dr5rv", "count": 1, "metrics": {"avg_rating": 4.7}}
]
}
}
{
"size": 0,
"aggs": {
"cells": {
"geohash_grid": {"field": "location", "precision": 5},
"aggs": {"avg_rating": {"avg": {"field": "rating"}}}
}
}
}
"aggregations": {
"cells": {
"buckets": [
{"key": "dr5ru", "doc_count": 2, "avg_rating": {"value": 4.35}},
{"key": "dr5rg", "doc_count": 1, "avg_rating": {"value": 4.5}},
{"key": "dr5rs", "doc_count": 1, "avg_rating": {"value": 4.4}},
{"key": "dr5rv", "doc_count": 1, "avg_rating": {"value": 4.7}}
]
}
}
Harbor Coffee and Midtown Market share the Midtown cell dr5ru; the other places each have a cell of their own.
| Grid | precision | Default | Bucket key |
|---|---|---|---|
geohash_grid | Geohash length, 1 to 12 | 5 | The geohash, such as "dr5ru" |
geotile_grid | Map zoom level, 0 to 29 | 7 | The tile, "zoom/x/y" |
geohex_grid | H3 resolution, 0 to 15 | 6 | The H3 index in hex |
| Parameter | Meaning |
|---|---|
field | The geo_point (or, except for geohex_grid, geo_shape) field. |
precision | The grid level, as above. A distance such as "1km" is not supported. |
bounds | Optional. Keep only the cells that meet a box: top_left and bottom_right, top_right and bottom_left, top, left, bottom and right, or {"wkt": "BBOX (...)"}. A left edge east of the right one crosses the date line. Each kept cell still counts all of its points. |
size | How many cells to return, the largest counts first, then by key. Default 10000, or your plan's maximum facet size if lower; more answers 422 limit_exceeded. Elasticsearch's shard_size is accepted. |
- A document counts once in each distinct cell its points fall in.
- A grid counts at most 65536 distinct cells. A finer
precisionover a wide area can pass that and fail the search; narrow the query or setbounds. - Pick the precision from the map's zoom:
geotile_gridwith the zoom level of the map, or a geohash length that gives a few hundred cells on screen. - To fetch the documents of one cell, use a
geo_gridquery with the bucket's key. missingandscriptare not supported.
#Distance rings
A geo_distance aggregation counts the matches in distance bands from an origin, like Elasticsearch's geo_distance aggregation:
{
"language": "lucene",
"query": "*:*",
"limit": 0,
"facets": {
"rings": {
"type": "geo_distance", "field": "location", "origin": "40.7580,-73.9855", "unit": "km",
"ranges": [{"to": 1}, {"from": 1, "to": 5}, {"from": 5}],
"facets": {"by_category": {"type": "terms", "field": "category"}}
}
}
}
The buckets are *-1.0 (count 2: Harbor Coffee and Midtown Market), 1.0-5.0 (count 1: Hudson Espresso) and 5.0-* (count 2: Astoria Bakery and Bridge Roasters). Each /v1 bucket has the key as value, its count, its from and to, and its nested facets.
{
"size": 0,
"aggs": {
"rings": {
"geo_distance": {
"field": "location", "origin": "40.7580,-73.9855", "unit": "km",
"ranges": [{"to": 1}, {"from": 1, "to": 5}, {"from": 5}]
},
"aggs": {"by_category": {"terms": {"field": "category"}}}
}
}
}
Buckets come back as {"key": "*-1.0", "to": 1.0, "doc_count": 2, ...}, in request order.
| Parameter | Meaning |
|---|---|
field | The geo_point field. |
origin | The point, in any query point form. |
unit | m (default), km, mi, yd, ft, in, cm, mm or nmi, for from and to. |
ranges | A list of {"from", "to", "key"}. from is included and to is not; either can be left open. At most your plan's maximum facet size. |
keyed | true returns the buckets as an object by key instead of a list. |
- Ranges may overlap, and a document counts once in each range that holds one of its points.
- Keys default to Elasticsearch's
from-totext, with*for an open end. Setkeyto name a range. - Elasticsearch's
distance_type: planeis accepted with a warning.
#Bounds and centroid
geo_bounds returns the box around the matching points, and geo_centroid their average location and how many there are. Together they fit a map to the results:
{
"language": "lucene",
"query": "*:*",
"limit": 0,
"metrics": {
"box": {"func": "geo_bounds", "field": "location"},
"middle": "geo_centroid(location)"
}
}
"metrics": {
"box": {"bounds": {"top_left": {"lat": 40.7644, "lon": -74.0324},
"bottom_right": {"lat": 40.7033, "lon": -73.9235}}},
"middle": {"location": {"lat": 40.7447, "lon": -73.9816}, "count": 5}
}
{
"size": 0,
"aggs": {
"box": {"geo_bounds": {"field": "location"}},
"middle": {"geo_centroid": {"field": "location"}}
}
}
The results have the same shapes as the /v1 metrics, under aggregations.
(Coordinates rounded here.)
geo_boundstakeswrap_longitude(defaulttrue): when the matches straddle the date line, it returns the narrower box across it, withtop_left.longreater thanbottom_right.lon.- With no matching points,
geo_boundshas nobounds, andgeo_centroidhascount0 and nolocation. countis the number of points, so a multi-valued field counts each of its points.- Both work nested in any facet bucket, such as one centroid per grid cell for map markers placed where the documents are rather than at the cell's center.
- Elasticsearch's
geo_lineis not supported.
#Query buckets
A query facet with one geo query per bucket counts the matches in each circle or box you name. It is available today, and suits a handful of fixed areas or bands:
{
"language": "es",
"query": {"match": {"description": "coffee"}},
"limit": 0,
"facets": {
"distance": {
"type": "query",
"buckets": {
"within_1km": {"geo_distance": {"distance": "1km", "location": "40.7580,-73.9855"}},
"within_5km": {"geo_distance": {"distance": "5km", "location": "40.7580,-73.9855"}},
"within_10km": {"geo_distance": {"distance": "10km", "location": "40.7580,-73.9855"}}
},
"metrics": {"avg_rating": "avg(rating)"}
}
}
}
With "language": "es", the bucket queries are Query DSL objects. Each bucket in the response has the bucket name as its value, a count, and its metrics.
A filters aggregation with a geo filter per bucket:
{
"query": {"match": {"description": "coffee"}},
"size": 0,
"aggs": {
"distance": {
"filters": {"filters": {
"within_1km": {"geo_distance": {"distance": "1km", "location": "40.7580,-73.9855"}},
"within_5km": {"geo_distance": {"distance": "5km", "location": "40.7580,-73.9855"}},
"within_10km": {"geo_distance": {"distance": "10km", "location": "40.7580,-73.9855"}}
}},
"aggs": {"avg_rating": {"avg": {"field": "rating"}}}
}
}
}
These circles overlap: a place 0.5 km away counts in all three. For rings that do not overlap, subtract the inner circle with must_not, or use distance rings once they are live:
"from_1_to_5km": {"bool": {
"filter": [{"geo_distance": {"distance": "5km", "location": "40.7580,-73.9855"}}],
"must_not": [{"geo_distance": {"distance": "1km", "location": "40.7580,-73.9855"}}]
}}
The same pattern counts named areas, with one geo_bounding_box (or, once live, geo_polygon or geo_shape) per bucket.