When do you need an Elasticsearch composite aggregation instead of a plain terms aggregation?
answer
- Top-N tools have no page two
- You need a cursor over buckets
- Sources array behaves like multi-column GROUP BY
- The response hands back a continuation key
- Ordering is by key, never by count
basics
~20 sWhen you must walk every bucket rather than the top few. A terms aggregation returns only its top size buckets and offers no next page; composite streams all buckets in composite-key order and pages through them with after_key.
solid answer
~50 s`terms` is a top-N tool: it returns `size` buckets and there is no way to ask for the following `size`. When the requirement is exhaustive — export every customer's total, sync every key into another store, iterate a full group-by — you need `composite`. It takes an ordered array of `sources` (each a `terms`, `histogram`, `date_histogram` or `geotile_grid` value source), producing one bucket per distinct combination, like a multi-column `GROUP BY`. Each response ends with `after_key`, which you pass back as `after` to get the next page, until a page comes back empty. The price is that composite orders buckets by their source values, never by `doc_count`, so it cannot give you top-N-by-count; and each page is a fresh pass over the index, so a live index can shift under you unless you pin the view with a point-in-time.
code
json · 14 lines{
"size": 0,
"aggs": {
"by_user_and_day": {
"composite": {
"size": 1000,
"sources": [
{ "user": { "terms": { "field": "user_id", "missing_bucket": true } } },
{ "day": { "date_histogram": { "field": "@timestamp", "calendar_interval": "1d" } } }
]
}
}
}
}go deeper
Know that a terms aggregation cannot be paged and that a different aggregation exists for walking every bucket.
Explain the sources array, the composite key it produces, and the after_key round trip that fetches the next page.
Argue the tradeoffs in a real export: no count ordering, page-by-page inconsistency without a point-in-time, missing_bucket behaviour, and choosing a page size that balances round trips against per-page memory.
Decide when exhaustive scanning should not happen against the search cluster at all — a transform or scheduled rollup into a purpose-built index, versus paging millions of buckets out of a live serving tier.
## The gap composite fills A `terms` aggregation has no pagination. `size` is a top-N cutoff, not a window: there is no `from`, no cursor, no way to say "the next 100 terms after those". That is a deliberate consequence of how it works — each shard nominates only its own top candidates, so the notion of "page 2" is not even well defined. If your requirement is exhaustive enumeration of groups, `terms` is structurally the wrong tool no matter how high you push `size`, and pushing `size` high enough to cover a high-cardinality field is how you trip a circuit breaker. The `composite` aggregation exists for exactly this case. It produces buckets in a defined, stable order and hands you a cursor to continue from. ## Sources and the composite key `composite` takes an ordered array of `sources`. Each source names an output key and wraps a value source: `terms` (a field's values), `histogram` (fixed numeric buckets), `date_histogram` (time buckets) or `geotile_grid`. The aggregation emits one bucket per distinct *combination* of the source values, and each bucket's `key` is an object with one entry per source: ``` { "key": { "user": "u-914", "day": 1690848000000 }, "doc_count": 12 } ``` This is a multi-column `GROUP BY user_id, day` — and note that it is genuinely different from nesting a `terms` inside a `terms`, which produces a tree with per-level truncation at every node. Composite produces a flat, complete cross-product of the combinations that actually occur. Each source has its own `order` (`asc` or `desc`), and the buckets come back sorted by the composite key using those per-source directions. `missing_bucket: true` on a source makes documents lacking that field appear in a bucket with `null` for it rather than being dropped — without it, a document missing any one source value is excluded from the aggregation entirely, which is a common silent data-loss bug in export jobs. ## Paging with after_key `size` on a composite aggregation is the page size. The response includes `after_key`, the composite key of the last bucket on the page. Send the next request identically but with `"after": <that key>` inside the composite object, and you receive the buckets that sort strictly after it. Repeat until a response comes back with no buckets — that empty page, not an `after_key` being absent, is the reliable termination condition. Because the ordering is by composite key rather than by an internal cursor, the mechanism is the aggregation analogue of `search_after`: it is stateless on the server, so there is no scroll context to expire and no server-side memory held between pages. ## The two costs **No ordering by count.** Composite can only sort by its source values. You cannot ask it for "the 10 categories with the most documents" — it has no idea which those are until it has emitted them all. If you want top-N by count, use `terms` (and accept its approximation), or page everything out of composite and sort client-side, which only makes sense if the total bucket count is modest. **No snapshot across pages.** Each page is an independent search. On an index that is being written to, documents indexed between page 3 and page 4 can add buckets you have already passed, or change counts you have already recorded. For an export that must be internally consistent, open a point-in-time and issue every page against it, so all pages see the same view of the index. Sub-aggregations inside a composite are allowed and useful (a `sum` per group, for instance), but remember they are computed per page against whatever the index looked like at that moment. There is also a plain throughput cost: `composite` streams every bucket, so an export over a very high-cardinality field is many round trips. Choose the page `size` deliberately — large enough that you are not paying request overhead per handful of buckets, small enough that a page's buckets and sub-aggregation state fit comfortably. ## When you should not reach for it If the answer is a dashboard's top 10, use `terms`. If you need the same exhaustive rollup repeatedly and cheaply, materialise it once — a transform or a scheduled job that writes per-key documents into their own index turns a multi-page scan into a single lookup, and gives you exact counts as a side effect. Composite is the right tool for a one-directional walk of the whole key space; it is a poor substitute for a well-chosen top-N or for pre-aggregation.
- Why can a composite aggregation not return the top 10 buckets by doc_count?Composite emits buckets in composite-key order and streams them; it never has a global view of counts before emitting, so there is nothing to sort by frequency. Ordering is per source, ascending or descending on the value, not on `doc_count`. Top-N by count is the `terms` aggregation's job, with the approximation that implies.
- How do you keep a multi-page composite export internally consistent on a live index?Open a point-in-time and run every page against that PIT id. Each page is otherwise an independent search, so concurrent indexing can add buckets behind your cursor or change counts you already exported. The PIT pins one view of the segments for the whole walk; release it when the export finishes so the held segments can be merged away.
- What happens to documents that lack a value for one of the composite sources?By default they are excluded from the aggregation entirely — a document missing any single source value produces no bucket at all, which quietly drops rows from an export. Set `"missing_bucket": true` on that source to include them in a bucket whose key entry is `null`. Decide this per source rather than assuming the default is safe.
saying these in an interview costs you the question
- Expecting composite to sort buckets by doc_count
- Thinking terms supports a from-style page offset
- Assuming composite pages are a consistent snapshot
- Not setting missing_bucket and silently dropping documents
- Stopping paging on a missing after_key instead of an empty page