The search API, paging & sorting

This section documents the current Elasticsearch 9.x line (with 8.19 as the final 8.x release) as published at the Elasticsearch documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. Some capabilities (Kibana-only UIs, the ML/NLP model-management workflow, cross-cluster replication, and parts of the paid / serverless-only surface) are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Elasticsearch iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Every read that is not a get-by-id goes through the _search endpoint: you send a query, Elasticsearch fans it out to the relevant shards, merges the per-shard results, and returns a fixed response envelope. This page covers how to call _search, how to read what comes back, how to control which fields and how many hits you get, and how to page through large result sets.

The _search endpoint

Send a search as a request body (the normal form) or as a URI query string (handy for quick checks). Both hit the same endpoint; the request body exposes the full search API.

# Request-body search: the full Query DSL is available.
GET /books/_search
{
  "query": { "match": { "title": "elasticsearch" } }
}

# URI search: a compact query_string in the "q" parameter, no body.
GET /books/_search?q=title:elasticsearch&size=5

The response is always the same shape:

{
  "took": 7,
  "timed_out": false,
  "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 },
  "hits": {
    "total": { "value": 42, "relation": "eq" },
    "max_score": 3.14,
    "hits": [
      { "_index": "books", "_id": "9780", "_score": 3.14, "_source": { "title": "Elasticsearch" } }
    ]
  }
}
  • took — milliseconds Elasticsearch spent (not counting network); timed_out — whether the optional timeout was hit and partial results returned.

  • _shards — how many shards answered; a non-zero failed means the result is partial. skipped counts shards a pre-query filter (for example a date range vs. shard bounds) let Elasticsearch skip entirely.

  • hits.total.value with relation: "eq" is an exact count; relation: "gte" means "at least this many" — by default Elasticsearch stops counting at 10000 to save work. Set track_total_hits to true for an exact count regardless of cost, or to an integer to count accurately up to that bound.

  • hits.max_score — the highest _score in the whole result set (null when scoring is not computed, e.g. a pure filter or a sort other than _score). Each entry in hits.hits carries its own _score.

GET /books/_search
{
  "track_total_hits": true,
  "query": { "match_all": {} }
}

For just the number, call _count — it returns \{ "count": N } and no hits:

GET /books/_count
{
  "query": { "term": { "status": "published" } }
}

terminate_after caps how many matching documents each shard collects before stopping; the response then carries "terminated_early": true. Use it to bound worst-case cost when an approximate answer is acceptable.

GET /books/_search
{
  "terminate_after": 1000,
  "query": { "match": { "title": "guide" } }
}

Query vs. filter context

Every clause in the Query DSL runs in one of two contexts, and choosing the right one is the single most important search-performance decision.

  • Query context answers "how well does this document match?" and computes a relevance _score. Use it for full-text clauses where ranking matters.

  • Filter context answers a yes/no question, computes no score, and its result is cached in the node query cache for reuse. Use it for exact, binary conditions — status, dates, ranges, terms.

A bool query puts must / should in query context and filter / must_not in filter context:

GET /books/_search
{
  "query": {
    "bool": {
      "must":   [ { "match": { "title": "elasticsearch" } } ],
      "filter": [
        { "term":  { "status": "published" } },
        { "range": { "published_at": { "gte": "2020-01-01" } } }
      ]
    }
  }
}

See Query and filter context. For how scores combine across clauses, constant_score, dis_max and function_score, see Compound queries & relevance.

Choosing which fields come back

By default each hit carries the full original document in _source. Trim or reshape what is returned rather than shipping whole documents you do not need.

_source filtering

Set _source to false to omit it entirely, or to an object with includes / excludes (both accept wildcards). This filters the stored JSON on the way out; it does not save disk.

GET /books/_search
{
  "_source": { "includes": [ "title", "author.*" ], "excludes": [ "author.internal_notes" ] },
  "query": { "match_all": {} }
}

# Shorthand as a URI parameter:
GET /books/_search?_source=false

fields

The fields parameter is the recommended retrieval API. Unlike raw _source, it returns values after mapping: dates are formatted, keyword sub-fields resolve, aliases work, and runtime fields (computed at search time) can be requested the same way. Values always come back as arrays.

GET /books/_search
{
  "query": { "match_all": {} },
  "fields": [ "title", { "field": "published_at", "format": "yyyy-MM-dd" } ],
  "runtime_mappings": {
    "title_length": { "type": "long", "script": { "source": "emit(doc['title.keyword'].value.length())" } }
  },
  "_source": false
}

docvalue_fields and stored_fields

docvalue_fields reads values from columnar doc values — cheap for keyword, numeric and date fields, but not available for analysed text. stored_fields returns fields that were individually marked "store": true in the mapping (rare; _source usually makes this unnecessary). See Retrieve selected fields.

GET /books/_search
{
  "query": { "match_all": {} },
  "docvalue_fields": [ "status", { "field": "published_at", "format": "epoch_millis" } ],
  "stored_fields": [ "title" ]
}

Sorting

Add a sort array to order hits by field instead of by _score. _score and _doc (index order, the cheapest) are also valid sort keys. Sorting on a field turns scoring off unless you also ask for _score.

GET /books/_search
{
  "query": { "match": { "title": "guide" } },
  "sort": [
    { "published_at": { "order": "desc" } },
    { "rating":       { "order": "desc", "missing": "_last" } },
    { "_score":       { "order": "desc" } },
    "_doc"
  ]
}
  • Multi-level: keys are applied in order, each breaking ties of the previous one.

  • missing places documents that lack the field (_last or _first, default _last).

  • unmapped_type lets a sort span indices where some do not map the field — without it the search fails: \{ "rating": \{ "unmapped_type": "float" } }.

  • Sorting on an analysed text field fails; sort on its keyword sub-field. Sorting inside nested objects needs a nested sort clause — see Joins & relationships.

Pagination

from / size and the result-window ceiling

from (default 0) and size (default 10) take a slice of the ranked list. This is the analogue of OFFSET / LIMIT in SQL Queries — but with a hard difference: every shard must build and return from + size hits for the coordinator to merge, so cost grows with the offset. Elasticsearch refuses from + size beyond index.max_result_window (default 10000).

GET /books/_search
{
  "from": 20,
  "size": 10,
  "query": { "match_all": {} },
  "sort": [ { "published_at": "desc" } ]
}

Raising the window works but multiplies memory use on every shard; past a few pages, switch method.

search_after + point-in-time

For deep or unbounded paging, sort on a unique, total order and pass the previous page’s sort values as search_after. Pair it with a point-in-time (PIT) so every page sees the same frozen view of the index even as writes continue.

# 1. Open a PIT; it returns an "id".
POST /books/_pit?keep_alive=2m

# 2. First page: query against the PIT, sort with a tiebreaker for a total order.
GET /_search
{
  "size": 100,
  "query": { "match": { "title": "guide" } },
  "pit": { "id": "<pit_id>", "keep_alive": "2m" },
  "sort": [ { "published_at": "desc" }, { "_shard_doc": "asc" } ]
}

# 3. Next page: reuse the "sort" array of the last hit from the previous response.
GET /_search
{
  "size": 100,
  "query": { "match": { "title": "guide" } },
  "pit": { "id": "<pit_id>", "keep_alive": "2m" },
  "sort": [ { "published_at": "desc" }, { "_shard_doc": "asc" } ],
  "search_after": [ 1609459200000, 42 ]
}

# 4. When done, release it.
DELETE /_pit
{ "id": "<pit_id>" }

search_after has no window limit and each page costs the same as the first. There is no cursor state on the server beyond the PIT, so it also scales to many concurrent pagers. Details and tiebreaker guidance are in Paginate search results. See Pagination: Offset vs. Keyset for how this same keyset pattern is expressed in SQL, MongoDB, Couchbase, Solr, GraphQL and Spring Data.

scroll

The older scroll API takes a one-time snapshot and returns a _scroll_id you feed back to pull the next batch. It holds per-search state and segment files open on every shard for the whole scroll, which is expensive under concurrency. PIT
search_after gives the same consistent deep-paging without that cost and is the recommended choice; reserve scroll for a single-threaded full export of an entire index.

preference and routing

preference pins a search to the same set of shard copies across requests (for example preference=<user-session-id>), so consecutive pages stay consistent even without a PIT and benefit from warm caches. routing sends the search to only the shard(s) a routing value maps to, cutting fan-out when documents were indexed with that same routing. See Search shard routing.

GET /books/_search?preference=session_9a3f&routing=author_42
{
  "query": { "term": { "author_id": "author_42" } }
}

For the broader picture of running searches against your data, see Search your data. Continue with Full-text queries and Term-level queries.