Repository navigation
Description
What do you want to do?
- Request a change to existing documentation
- Add new documentation
- Report a technical problem with the documentation
- Other
Tell us about your request.
OpenSearch 3.10 adds an experimental Terms Enum API, GET and POST /{index}/_terms_enum. It returns up to size distinct indexed terms of one keyword, constant_keyword or _index field that start with a prefix, merged across shards in UTF-8 byte order. Each shard reads its term dictionary instead of running an aggregation, so the cost depends on size rather than on how many documents match. The exception is a shard with deleted documents (with soft deletes, these include earlier versions of updated documents) or documents hidden from the caller, for example by document-level security: there, each candidate term's postings are read until a visible document is found, and that extra work is bounded by timeout. The intended use is autocomplete and filter-value suggestions, which OpenSearch Dashboards builds today with a terms aggregation bounded by terminate_after.
Must appear on the page
- The API is experimental and is not behind a feature flag.
- A response with
complete: falsecan be missing terms anywhere in its range, not only at the end. Do not page withsearch_afterfrom it. index_filteronly skips whole shards. On a shard it does not skip, terms come from all visible documents, not only from documents that match the filter.- The
readaction group grants the API. Thesearchaction group does not. - The first request to a search-idle shard that has pending writes waits for that shard's refresh, and the wait counts against
timeout. At the default1s, that request can returncomplete: false, possibly with no terms. - Cross-cluster requests are not supported, and an index reached only through filtered aliases is rejected.
search.allow_expensive_queries: falsedoes not block it, even though it blocks aprefixquery on akeywordfield.
Proposed changes
- Add
_api-reference/search-apis/terms-enum.md(title "Terms enum", parent "Search APIs",nav_order: 86, between Search shards (85) and Validate query (87)). It is then served at https://docs.opensearch.org/latest/api-reference/search-apis/terms-enum/, the URL the core REST spec already links to. Use the "Introduced 3.10" label and the standard experimental{: .warning}that links [Feature Request] Add a_terms_enumAPI for fast prefix-based term suggestions on a field OpenSearch#23510. - In
_api-reference/search-apis/index.md, under "Core search APIs", add:- **[Terms enum]({{site.url}}{{site.baseurl}}/api-reference/search-apis/terms-enum/)**: List the distinct indexed terms of a field that start with a prefix, for autocomplete and filter suggestions. - In
_install-and-configure/configuring-opensearch/search-settings.md, add:- `search.terms_enum.max_size` (Dynamic, integer): The largest `size` that a Terms enum API request can ask for. Minimum is `1`. Default is `1000`. - In the "Index permissions" table of
_security/access-control/permissions.md, add this row betweenindices:data/read/search/templateandindices:data/read/tv, in alphabetical order:| `indices:data/read/terms_enum` | Permission to run the [Terms enum API]({{site.url}}{{site.baseurl}}/api-reference/search-apis/terms-enum/). |_security/access-control/default-action-groups.mdneeds no change: thereadgroup'sindices:data/read*already covers the action. - In
_search-plugins/searching-data/autocomplete.md, after the list of methods, add a pointer such as: "To suggest values that already exist in akeywordfield, such as filter values, use the Terms enum API."
Once the API specification PR is merged, the endpoint, path parameter and query parameter sections can use spec_insert. No page on main mentions this API yet.
Endpoints
GET /{index}/_terms_enum
POST /{index}/_terms_enumPath and query parameters
| Parameter | Type | Data type | Description |
|---|---|---|---|
index |
Path, required | List or String | Comma-separated indexes, data streams and aliases. Wildcards are supported. |
ignore_unavailable |
Query | Boolean | If true, missing or closed indexes are skipped. Default is false. |
allow_no_indices |
Query | Boolean | If false, a wildcard or _all that matches no index is an error. Default is true. |
expand_wildcards |
Query | List or String | Which indexes wildcards match: open, closed, hidden, none or all. Default is open. |
The common parameters also apply, including source with source_content_type for clients that cannot send a body with GET. ignore_throttled is also accepted, with a default of false (search defaults it to true). There is no timeout query parameter. The time limit is set in the body.
Request body fields
| Field | Required | Data type | Default | Description |
|---|---|---|---|---|
field |
Required | String | The field to list terms of. See "Supported fields". | |
string |
Optional | String | "" |
The prefix every returned term starts with. It is matched literally: *, ? and . are not wildcards. At most 1,000 UTF-16 code units, checked before normalization (a character outside the Basic Multilingual Plane, such as most emoji, counts as two). On a keyword field with a normalizer, the prefix is normalized first, as in a prefix query. |
size |
Optional | Integer | 10 |
The maximum number of terms to return. At least 1 and at most search.terms_enum.max_size, a dynamic cluster setting whose default is 1000. |
case_insensitive |
Optional | Boolean | false |
Match the prefix ignoring case. Only ASCII letters are folded; for example é does not match É. |
timeout |
Optional | String (time value) | 1s |
The time limit for the whole request, such as 500ms or 2s. At least 1ms. See "Timeouts and partial results". |
index_filter |
Optional | Object | A query used only to skip whole shards, with the same can-match check search uses (for example, a range on a date field outside a shard's values). On a shard that is not skipped, terms of all visible documents are returned, not only of documents that match the filter. |
|
search_after |
Optional | String | Return only terms that sort strictly after this term. It is compared as given (no normalizer, no case folding), so pass the last term of the previous page. |
Supported fields
keyword(indexed),constant_keywordand_index, each linked to its page (_mappings/supported-field-types/keyword.md,constant-keyword.md,_mappings/metadata-fields/index-metadata.md). Field aliases and multi-fields enumerate the concrete field. On a data stream,_indexreturns backing index names.- Other field types,
keywordfields withindex: falseand derived fields are unsupported (see "Unsupported fields, missing fields and failed shards"). - Only indexed terms are returned, so values longer than
ignore_abovenever appear. - Terms held only by deleted documents (including earlier versions of updated documents) are not returned.
- Terms are in UTF-8 byte order, so ASCII uppercase sorts before lowercase, and terms that differ only in case are distinct. No document counts or frequencies are returned.
Example
Setup, from the YAML REST tests. The index names can change; if they do, update the _index example (its string prefix and its result) to match. Keep the values so the tested results stay true:
PUT /test
{
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
"mappings": { "properties": { "tag": { "type": "keyword" } } }
}
PUT /test-2
{
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
"mappings": { "properties": { "tag": { "type": "keyword" } } }
}
POST /_bulk?refresh=true
{"index": {"_index": "test", "_id": "1"}}
{"tag": "web-01"}
{"index": {"_index": "test", "_id": "2"}}
{"tag": "web-02"}
{"index": {"_index": "test", "_id": "3"}}
{"tag": "api-01"}
{"index": {"_index": "test", "_id": "4"}}
{"tag": "Web-03"}
{"index": {"_index": "test-2", "_id": "1"}}
{"tag": "web-02"}
{"index": {"_index": "test-2", "_id": "2"}}
{"tag": "web-04"}POST /test,test-2/_terms_enum
{
"field": "tag",
"string": "web-"
}{
"_shards": { "total": 2, "successful": 2, "skipped": 0, "failed": 0 },
"terms": ["web-01", "web-02", "web-04"],
"complete": true
}More results from the same tests (on index test unless noted):
{"field": "tag", "size": 2}returns["Web-03", "api-01"](byte order).{"field": "tag", "string": "WEB-"}returns[]. With"case_insensitive": trueit returns["Web-03", "web-01", "web-02"].{"field": "tag", "string": "web-", "search_after": "web-01"}returns["web-02"]. With"search_after": "a"(before the prefix) it returns["web-01", "web-02"].- On
test,test-2,{"field": "_index", "string": "test"}returns["test", "test-2"]. index_filter, on two other one-shard indexes, each mapped withtagaskeywordand@timestampasdate:old(tagsold-aandold-b, 2015@timestampvalues) andnew(tagsnew-aandnew-b, 2026 values).{"field": "tag", "index_filter": {"range": {"@timestamp": {"gte": "2026-01-01"}}}}onold,newreturns["new-a", "new-b"]with"_shards": {"total": 2, "successful": 2, "skipped": 1, "failed": 0}.
Response body fields
| Field | Data type | Description |
|---|---|---|
terms |
Array of strings | The distinct terms, in UTF-8 byte order, at most size. |
complete |
Boolean | true when every target shard answered in full. See "Paging and complete". |
_shards.total |
Integer | The number of target shards. |
_shards.successful |
Integer | Shards that answered, including skipped shards, shards where the field is absent, and shards that stopped at their own time limit. |
_shards.skipped |
Integer | Shards skipped by index_filter. |
_shards.failed |
Integer | Shards that failed, did not answer in time, or found the field unsupported. |
_shards.failures |
Array | Present only when at least one shard failed. One entry per index and reason (shard, index, status, reason). Shards of one index that failed for the same reason share an entry, as in search, so there can be fewer entries than failed. |
Paging and complete
completeisfalsewhen any target shard timed out, failed or could not be reached. Such a page can be missing terms anywhere in its range, not only at the end. Do not use it forsearch_afterpaging.complete: trueonly means every shard answered in full. It does not mean there are no more matching terms aftersize.- To page, pass the last term as
search_after. A page withcomplete: trueand fewer thansizeterms is the last one. - If some target indexes map the field with an unsupported type, every successful response has
complete: false(unlessindex_filterskips those shards). If the field is absent from every other target index (orindex_filterskips them), the request fails with a 400 instead. Narrow the target to indexes where the field is supported.
Timeouts and partial results
timeoutcovers the whole request. Each shard gets the remaining time minus a small reserve, starting when it receives the request. A shard that reaches its limit returns the terms it has so far and counts as successful, socomplete: falsecan come withfailed: 0.- A shard that has not answered by the deadline is reported in
failureswith statusGATEWAY_TIMEOUT(reason:shard did not respond within [<timeout>]). A late answer is dropped. - A failed shard request is retried on another copy of the shard while time remains, unless its shard task was cancelled.
- If no shard answers in time, the response is a 200 with no terms and
complete: false. - Search-idle shards: as in search, the first request to a search-idle shard with pending writes waits for its refresh, so recently indexed terms are returned. That wait counts against
timeout. The first request after a quiet period can therefore returncomplete: false(possibly with no terms) within the default 1s. A retry or a longertimeoutavoids it. - The request is cancelled when the client disconnects and can be cancelled with the Tasks API.
A partial response looks like this:
{
"_shards": {
"total": 3,
"successful": 2,
"skipped": 0,
"failed": 1,
"failures": [
{
"shard": 0,
"index": "logs-legacy",
"status": "BAD_REQUEST",
"reason": {
"type": "illegal_argument_exception",
"reason": "Field [tag] of type [text] does not support terms enumeration"
}
}
]
},
"terms": ["web-01", "web-02"],
"complete": false
}Unsupported fields, missing fields and failed shards
The coordinator checks these rules in order, and the first that matches decides the response:
| Shard outcomes | Response |
|---|---|
No target shards, or every shard either lacks the field or was skipped by index_filter |
200, no terms, complete: true. A skipped shard does not check the field at all, so when index_filter skips every shard, an unsupported field is not reported. |
| At least one shard found the field unsupported, and every other shard lacks it or was skipped | 400 illegal_argument_exception with the first such shard's reason, for example Field [count] of type [long] does not support terms enumeration. |
| At least one shard failed for a reason other than not answering in time, and every other shard also failed for such a reason or found the field unsupported (if any shard did not answer in time, the next row applies) | The standard all-shards-failed error (search_phase_execution_exception). Its status comes from the failed shards, not from the unsupported ones. |
| Any other mix that includes a shard that found the field unsupported, failed or did not answer in time | 200 with complete: false and the terms from the shards that answered, so a logs-* pattern whose mappings drifted still gets suggestions. The shards that found the field unsupported, failed or did not answer in time are listed in _shards.failures with the reason; shards that lack the field or were skipped are not. Shards of one index that failed for the same reason share one entry. |
Errors before any shard is queried (400 unless noted)
- A malformed request: an unknown query parameter or body field, or a
timeoutorindex_filterthat cannot be parsed. - Missing
field,sizebelow 1 or oversearch.terms_enum.max_size,stringover 1,000 UTF-16 code units,timeoutbelow1ms. - More target shards than
action.search.shard_count.limit. - An index reached only through aliases that have a filter (terms would come from the whole index, not only from the documents the filter selects). If the same request also reaches that index by name, through
_allor a wildcard that matches the index name, or through an alias without a filter, it is accepted and returns terms from the whole index, as in search. - An index expression that targets a remote cluster: cross-cluster requests are not supported.
- Every target shard only on nodes older than 3.10.
- The usual index errors, for example a 404
index_not_found_exceptionfor a missing index and a 403cluster_block_exceptionfor an index withindex.blocks.read: true.
Indexes, aliases and routing
- Closed indexes are handled as in search: a closed target fails the request with a 400
index_closed_exception, or is skipped withignore_unavailable=true. A wildcard skips closed indexes by default; withexpand_wildcards=open,closedit fails withindex_closed_exception. - Data streams are supported.
- An alias with search routing targets the same shards as
_search. - Every target shard is queried at once. Unlike search, there is no limit on concurrent shard requests per node.
Security
- Action name:
indices:data/read/terms_enum(shard actionindices:data/read/terms_enum[s]). Thereadaction group (indices:data/read*) covers it. Thesearchaction group (indices:data/read/search*) does not. - Shards read terms only through the shard's searcher, so reader wrappers such as Lucene-level document-level security apply. A term held only by documents the wrapper hides is not returned.
- A field hidden by the node's field filter (the hook field-level security uses for
_field_caps) is treated as absent, with no terms and no error, whatever its type. The same applies to an alias that points to it. Metadata fields such as_indexare exempt, as in_field_caps. - The core PR tests this with test plugins that stand in for the Security plugin. The Security plugin's privilege mapping, its document-level security modes and field masking (a masked
keywordfield is expected to return no terms, while a maskedconstant_keywordfield still returns its mapped value) should be confirmed with its maintainers before the page goes further than these points.
Workload management and admission control
- Each shard request runs as a search shard task on the
searchthread pool, so search backpressure applies to the shard tasks. The coordinating task is not tracked by search backpressure. A request with aworkloadGroupIdheader counts toward that workload group, and each shard request can be rejected when the group is over its rejection threshold, as for search. A rejected shard request is retried on another copy while time remains; if no copy accepts it, the shard counts as failed. Search backpressure and workload management can also cancel a running shard task; that shard counts as failed and is not retried. - In 3.10 the following do not apply, and the page should say so:
- the workload group check a search goes through on the coordinating node, so a rejection shows up per shard instead of rejecting the whole request;
- workload group throttling (the
throttlinglimits of a workload group, new in 3.10): a_terms_enumrequest does not take a throttle permit, so it is not limited or rejected by those limits; - workload group settings such as
search.default_search_timeout, which apply to search requests only; - rule-based auto-tagging, which handles only search and scroll requests (send the
workloadGroupIdheader instead); - search admission control (
admission_control.search.*); search.allow_expensive_queries: a_terms_enumrequest runs when this setting isfalse, even though aprefixquery on akeywordfield is rejected. Its work is bounded bysizeandtimeout.
- Shard work for system indexes and search-throttled indexes also runs on the
searchthread pool.
Mixed-version clusters and clients
- Only shard copies on 3.10 or later nodes are queried. A shard with no such copy is reported as failed, so a 200 response has
complete: false. If every target shard has copies, but only on nodes older than 3.10, the request fails with a 400. Send requests to a 3.10 node; older nodes do not have the endpoint. - The deprecated Java high-level REST client has no method for it. Until the language clients add it from the API specification, send it as a generic request.
When to use it instead of a terms aggregation
Use _terms_enum to suggest values as a user types: it returns the first matching terms in byte order, it reads the term dictionary instead of sampling the first documents of each shard (so rare values are not missed), and its cost depends on size rather than on the number of matching documents (on shards with deleted or hidden documents it also reads postings until it finds a visible document, bounded by timeout). Use a terms aggregation when you need document counts, the most frequent values first, or values from only the documents that match a query (index_filter only skips whole shards). Results also differ in order: Dashboards today shows the top terms by count within the first 100,000 documents per shard, while _terms_enum shows the first terms in byte order.
Version
3.10 (main is 3.10.0-SNAPSHOT at the time of writing; adjust if the change ships in a later release).
What other resources are available?
- Core issue: [Feature Request] Add a
_terms_enumAPI for fast prefix-based term suggestions on a field OpenSearch#23510 - Core PR: Add a terms_enum API for fast value suggestions OpenSearch#23539 (open when this was written; please check the final behavior against the merged code, the review may change details)
- The PR description lists the behavior above with the unit, integration and YAML tests that cover it. The YAML tests are in
rest-api-spec/src/main/resources/rest-api-spec/test/terms_enum/. - API specification PR: Add the terms_enum API to the spec opensearch-api-specification#1320 (adds
terms_enum; the Search API pages can usespec_insertfor the endpoint and parameters once it merges).
Background for the writer (not page content):
- The example setup and results come from
10_basic.ymlthrough40_index_filter.yml. For the first request, the test assertsterms,completeand_shards.failed; the other_shardsvalues follow from the two one-shard indexes. The partial response under "Timeouts and partial results" is illustrative, not from a test. - The core PR includes a single-node laptop benchmark (5M documents, 1M distinct values) in which
_terms_enumanswered in about 0.3 ms at p50 on a force-merged index, against about 20 ms for thetermsaggregation Dashboards sends today; multiple nodes and concurrent load were not measured. Most of that gap comes from theexecution_hint: mapthat Dashboards sets: atermsaggregation with the default execution hint and the sameterminate_after: 100000took about 0.6 ms (on the index with 10% of documents deleted: 0.38 ms for_terms_enum, 48 ms for the Dashboards request and 2.3 ms for that aggregation), but it missed suggestions for rare prefixes. Do not present these numbers as a general speedup over thetermsaggregation.
Activity
mehulvani commentedon Nov 10, 2026
@kolchfa-aws, the feature PR, opensearch-project/OpenSearch#23539, is still open and missed the 3.10 branch cut, so this will ship in 3.11.0 at the earliest. Could the label be changed from
v3.10.0tov3.11.0? This issue is currently listed as blocking on the 3.10.0 release issue, opensearch-project/opensearch-build#6484. I'll update the version mentions in the description when the PR is retargeted to 3.11.kolchfa-aws commentedon Nov 10, 2026
Thanks, @mehulvani. Applying v-TBD label. When the code PR is merged, please open the doc PR and have the code PR reviewers review it.