Database Management

Elasticsearch Stuck at 10,000 Results? Fix track_total_hits

Asep Alazhari

Elasticsearch pagination stuck at 10,000 is not a bug, it is the track_total_hits default. Here is how to read hits.total and pick the right fix for your UI.

Elasticsearch Stuck at 10,000 Results? Fix track_total_hits

A log dashboard I maintain runs on Elasticsearch, and it has a pagination footer at the bottom of every table. Page 1 of 500, that kind of thing. One afternoon someone filed a ticket saying the numbers were wrong. The index had grown a lot that month, but the footer still said the same thing it said weeks ago. Total results, 10,000. Always exactly 10,000. Never 10,001, never 47,382.

My first instinct was that I had a bug. Maybe a hardcoded limit somewhere, maybe a slice that got left in during development. I spent a good hour reading through the query builder and the table component before I finally looked at the raw response from Elasticsearch and saw a field I had been ignoring for years. The count was not wrong. Elasticsearch had simply stopped counting on purpose, and it had been telling me so the whole time.

Key Takeaways

  • Elasticsearch stops counting matches at 10,000 by default. This is the track_total_hits setting, and it has behaved this way since Elasticsearch 7.0.
  • The response tells you when the count is truncated. The field hits.total.relation returns gte instead of eq, and most application code ignores it.
  • Set track_total_hits to true for an exact count, to a number like 100000 for a capped count, or to false for the fastest queries with no total at all.
  • There is a second, separate 10,000 limit called index.max_result_window that blocks deep from and size pagination. Changing track_total_hits does not lift it.
  • For paging past 10,000 rows, use search_after with a Point in Time instead of raising limits.

Why Does Elasticsearch Stop Counting at 10,000?

Elasticsearch stops counting at 10,000 because counting every match is expensive and most users never need the exact number. Since version 7.0, the track_total_hits parameter defaults to 10000. Once the engine has confirmed that at least 10,000 documents match your query, it stops tallying and moves on to collecting the top hits you actually asked for.

This is a deliberate performance trade. Counting all matches means the engine cannot take shortcuts. It has to visit every matching document, even the ones ranked far below anything you will ever display. Skipping that work lets Elasticsearch use optimizations that terminate early, which is why the default exists in the first place.

The important part is that the truncation is not silent. Elasticsearch reports it in the response. Your code just has to read the right field.

Also Read: Kubernetes Logging Done Right: Fluent Bit to Elasticsearch

How Do You Read hits.total Correctly?

The hits.total field is an object with two properties, value and relation, and you have to read both. The relation property tells you whether value is exact or a floor.

When the count is exact, the response looks like this.

{
    "took": 4,
    "timed_out": false,
    "hits": {
        "total": {
            "value": 3271,
            "relation": "eq"
        },
        "hits": []
    }
}

When the count has been truncated, you get this instead.

{
    "took": 6,
    "timed_out": false,
    "hits": {
        "total": {
            "value": 10000,
            "relation": "gte"
        },
        "hits": []
    }
}

A relation of eq means equals, so the value is the real total. A relation of gte means greater than or equal, so the real total is 10,000 or more and Elasticsearch is not going to tell you which. That is the entire mystery.

Here is the bug almost everyone ships. Application code reads hits.total.value, divides it by page size, and renders a page count. It never looks at relation. So a query matching two million documents renders the same footer as a query matching exactly ten thousand, and nobody notices until the data grows past the threshold.

The minimum fix costs three lines.

const { value, relation } = response.hits.total;
const isExact = relation === "eq";
const label = isExact ? `${value} results` : `${value}+ results`;

Even if you decide the truncation is acceptable, rendering a plus sign is honest. It tells the user the number is a floor rather than a fact.

Is track_total_hits True Slow?

Yes, track_total_hits set to true is slower, and the slowdown scales with how many documents match rather than how many you display. Setting it to true forces Elasticsearch to enumerate the full matching set on every shard before returning your ten rows.

For a query matching a few thousand documents, the difference is usually noise. For a broad query against a large time series index, it can be the difference between a fast response and a request that makes users stare at a spinner. The cost tracks the match count, so a filter that narrows results well keeps the exact count cheap.

That is why the honest answer is not just turn it on. The answer is to pick the setting that matches what your interface actually needs.

Which track_total_hits Value Should You Pick?

Pick the value based on your pagination pattern, not on how much you like precise numbers. There are three options and each one buys something different.

SettingWhat you getCostBest for
trueExact count, relation is always eqLatency grows with total matchesAudit logs, compliance exports, any “showing X of Y” the user must trust
A number, like 100000Exact up to the cap, gte beyond itBounded worst caseNumbered pagination where honest totals matter but you need a ceiling
falseNo hits.total field at allFastest, enables early terminationInfinite scroll, load more buttons, cursor navigation

Setting it to true is a one word change in the request body.

{
    "track_total_hits": true,
    "size": 20,
    "query": {
        "bool": {
            "filter": [{ "term": { "status.keyword": "failed" } }]
        }
    }
}

The capped version replaces the boolean with an integer. Elasticsearch counts exactly up to that number and reports gte past it, so a pathological query cannot drag the whole cluster down.

{
    "track_total_hits": 100000,
    "size": 20,
    "query": { "match_all": {} }
}

One detail that trips people up. When you set track_total_hits to false, Elasticsearch does not return hits.total at all. It is not zero, it is absent. Code that does response.hits.total.value will throw rather than render a wrong number, so guard for it.

Also Read: Handling 429 Rate Limits in Bulk API Requests

What Is the Difference Between track_total_hits and max_result_window?

They are two different limits that both default to 10,000, which is exactly why people confuse them. The track_total_hits parameter controls how far Elasticsearch counts. The index.max_result_window setting controls how deep you are allowed to page.

Setting track_total_hits to true gives you an accurate page count. It does not let you visit those pages. The moment your from plus size crosses max_result_window, the request fails outright with an error about the result window being too large.

Result window is too large, from + size must be less than or equal to: [10000]

So a user who now sees an honest 84,120 results and clicks through to page 600 gets an error instead of data. You fixed the label and exposed a worse problem behind it. That is a genuinely awkward bug to ship, and it is why these two settings should be reasoned about together.

You can raise max_result_window per index, but the official Elasticsearch documentation advises against it because deep offset paging forces every shard to build and sort a result set the size of your offset. Raising the ceiling raises the memory cost with it.

How Do You Paginate Past 10,000 Results?

Use search_after together with a Point in Time, which is the approach the Elasticsearch documentation recommends for deep pagination. A Point in Time, usually shortened to PIT, is a lightweight frozen view of your index that keeps results consistent while you page through them.

First, open the PIT and keep the id it returns.

POST /my-log-index-*/_pit?keep_alive=1m

Then run your first search against that PIT with a sort that includes a tiebreaker.

{
    "size": 1000,
    "query": { "match": { "level": "error" } },
    "pit": {
        "id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAA==",
        "keep_alive": "1m"
    },
    "sort": [{ "@timestamp": { "order": "asc" } }, { "_shard_doc": "asc" }],
    "track_total_hits": false
}

Every hit comes back with a sort array. Take the sort array from the last hit of the page and pass it as search_after on the next request, along with the PIT id from the previous response.

{
    "size": 1000,
    "query": { "match": { "level": "error" } },
    "pit": {
        "id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAA==",
        "keep_alive": "1m"
    },
    "sort": [{ "@timestamp": { "order": "asc" } }, { "_shard_doc": "asc" }],
    "search_after": ["2026-09-01T05:30:04.832Z", 4294967298],
    "track_total_hits": false
}

Three things matter here. The sort needs a tiebreaker so documents with identical timestamps still have a stable order, and _shard_doc is available for free when you use a PIT. The keep_alive extends every time you use the PIT, so it does not have to cover your entire crawl. And track_total_hits set to false is the normal pairing, because cursor style paging has no page numbers to compute anyway.

The catch is that search_after gives you next and previous, not jump to page 600. If your product genuinely requires numbered deep pagination over millions of rows, the fix is usually a product conversation about better filters, not a bigger result window.

How Do You Stop This Bug Coming Back?

Set track_total_hits deliberately in one shared query builder instead of leaving it to each call site. The reason this class of bug survives so long is that it is invisible in code review. A query with no track_total_hits looks identical to a query that meant to use the default, so nobody can tell whether the omission was a decision or an oversight.

Centralizing it means one place governs the behavior for every query in the codebase, and a reviewer changing that file knows exactly what they are trading.

Then make your tests assert on both fields, not just the number.

it("returns an exact total when tracking is enabled", async () => {
    const result = await searchLogs({ level: "error" });

    expect(result.total.value).toBe(3271);
    expect(result.total.relation).toBe("eq");
});

A test that only checks value will pass happily against a truncated count of exactly 10,000, which is the one case you actually care about catching. Asserting on relation is what makes the test meaningful.

Frequently Asked Questions

Does track_total_hits affect aggregations? No. Aggregations are always computed across all matching documents regardless of track_total_hits, so a terms or date histogram aggregation stays accurate even when hits.total is truncated. If you need an exact count and nothing else, running the aggregation or the dedicated count API is often cheaper than setting track_total_hits to true.

Does this apply to OpenSearch too? Yes. OpenSearch forked from Elasticsearch 7.10 and inherited the same 10,000 default for track_total_hits, the same hits.total object with value and relation, and the same max_result_window behavior. Everything in this article works on both.

Can I change the track_total_hits default cluster wide? No. There is no cluster setting for it, so it has to be sent per request. That is precisely why putting it in a shared query builder is worth the small effort. You can, however, change index.max_result_window per index, since that one is an index level setting.

Why does the response say gte instead of just giving me the number? Because gte is Elasticsearch being honest rather than guessing. It stopped counting at the threshold, so the only truthful statement it can make is that at least that many documents matched. Returning 10,000 with a relation of eq would be a lie.

Is the count API affected by the same limit? No. The dedicated _count API returns an exact total with no 10,000 cap. If your interface only needs a number and not the rows, calling _count separately is a clean option, though it does mean a second request per page load.

The fix itself was one line in a shared query builder. The part that took real time was the hour I spent hunting for a bug in my own code before checking what the search engine was actually telling me. Elasticsearch was not hiding anything. There was a field named relation sitting in every response I had ever logged, and I had never once read it. If your dashboard is showing a suspiciously round number today, start there.

Back to Blog

Related Posts

View All Posts »