Elasticsearch’s query performance tuning is less about magic incantations and more about understanding how its internal machinery gets bogged down, and then gently nudging it in the right direction.

Let’s watch a query unfold. Imagine we’re searching for all documents where user_id is 123 and status is active, sorted by timestamp descending.

GET /my_index/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "user_id": 123 } },
        { "term": { "status": "active" } }
      ]
    }
  },
  "sort": [
    { "timestamp": "desc" }
  ],
  "profile": true
}

When this hits Elasticsearch, it doesn’t just scan everything. It first consults its caches. If the user_id and status combination has been seen recently, and the underlying data hasn’t changed, a cached result might be served up almost instantly. If not, it moves to the query execution phase. The filter clause is special: it’s designed to be highly cacheable. Elasticsearch will leverage segment-level caches for these filters. Then, it needs to sort the results. This is where things can get expensive, especially if the dataset is large and the sort order is not aligned with how data is physically stored.

The core problem Elasticsearch solves is making massive amounts of unstructured or semi-structured data searchable, fast. It does this by indexing data into segments, which are essentially sorted lists of terms and document pointers. When you query, Elasticsearch traverses these segments. Filters are applied first because they’re usually cheaper and can prune large portions of the search space. Sorting happens last.

The profile: true flag is your best friend here. It returns a detailed breakdown of how long each part of the query took.

{
  // ... other response parts ...
  "took": 55, // Total time in ms
  "timed_out": false,
  "_shards": { ... },
  "_clusters": { ... },
  "hits": { ... },
  "profile": {
    "shards": [
      {
        "id": 0,
        "searches": [
          {
            "query": [
              {
                "type": "BooleanQuery",
                "description": "user_id:123 AND status:active",
                "time_in_ms": 10,
                "breakdown": {
                  "filter_time": 8,
                  "must_time": 0,
                  "should_time": 0,
                  "must_not_time": 0
                },
                "children": [
                  // ... details for term queries ...
                ]
              }
            ],
            "aggregation": [],
            "metadata_highlights": []
          }
        ],
        "groups": [ // Breakdown by segment
          {
            "name": "my_index:0:search",
            "time_in_ms": 15,
            "query": [
              {
                "type": "BooleanQuery",
                "description": "user_id:123 AND status:active",
                "time_in_ms": 10,
                "breakdown": { ... },
                "children": [ ... ]
              }
            ],
            "rewrite_time": 0,
            "searcher_time": 15,
            "total_time": 15
          }
        ]
      }
    ]
  }
}

Notice the filter_time within the breakdown. This tells you how much time was spent evaluating your filters. If this is high, it’s usually a sign that your filter fields aren’t optimally mapped or indexed.

The profile output for sorting is particularly insightful. It shows sort_time and search_time. If sort_time is dominant, it means Elasticsearch had to collect many documents and then rearrange them in memory.

One of the most surprising aspects of Elasticsearch performance is how aggressively it caches filter results. When you use a filter clause (as opposed to a must clause in a bool query), Elasticsearch treats it as a yes/no question. The results of these filter queries are cached at the segment level. This means if the same filter is applied multiple times across different queries, or even within the same query against different segments, Elasticsearch can reuse the computed bitsets. This is why using filter for exact matches or range queries on numeric/date fields is vastly more performant than using must clauses for the same conditions. The cache key is essentially the combination of the filter query and the segment ID.

Common Performance Bottlenecks and Fixes:

  1. Unfiltered Queries on Large Datasets:

    • Diagnosis: profile: true shows high query_time or total_time with no significant filter_time.
    • Cause: You’re asking Elasticsearch to score every document when you only care about existence.
    • Fix: Move all boolean must clauses that are used for filtering (i.e., they don’t affect scoring) into the filter array of a bool query.
      GET /my_index/_search
      {
        "query": {
          "bool": {
            "filter": [
              { "term": { "user_id": 123 } },
              { "term": { "status": "active" } }
            ],
            "must": [ // Only if you need scoring based on these
              { "match": { "description": "urgent" } }
            ]
          }
        }
      }
      
    • Why it works: Filters are executed in a non-scoring context and their results are cached, drastically reducing the amount of work for subsequent operations.
  2. Sorting on Non-Indexed Fields or Inefficiently Mapped Fields:

    • Diagnosis: profile: true shows a high sort_time (often a large percentage of total_time) and search_time is low.
    • Cause: Elasticsearch has to load many documents into memory to perform the sort if the field isn’t optimized for it.
    • Fix: Ensure fields used for sorting are mapped as keyword or date, long, double, etc., and that doc_values are enabled (which they are by default for most types). If you’re sorting on a text field, you’re likely doing it wrong; sort on a keyword sub-field instead.
      PUT /my_index
      {
        "mappings": {
          "properties": {
            "my_sortable_field": {
              "type": "keyword", // or long, double, date etc.
              "doc_values": true // enabled by default
            }
          }
        }
      }
      
    • Why it works: doc_values store data columnarly on disk, allowing Elasticsearch to efficiently retrieve and sort values without loading entire documents.
  3. Inefficient term or terms queries on High-Cardinality Fields:

    • Diagnosis: profile: true shows high filter_time for specific term queries, or the query is slow even with filters.
    • Cause: term queries on text fields perform an exact match on the analyzed terms, which is rarely what you want. For high-cardinality fields (like IDs, usernames, product codes), you’re likely trying to match exact values.
    • Fix: Use term queries only on keyword fields or other non-analyzed types. If you need to query a text field for an exact phrase, use match_phrase. For multiple exact values, use terms on a keyword field.
      GET /my_index/_search
      {
        "query": {
          "bool": {
            "filter": [
              { "term": { "product_code.keyword": "XYZ123" } }, // Assuming product_code is text, and you have a .keyword subfield
              { "terms": { "user_id": [101, 102, 103] } }
            ]
          }
        }
      }
      
    • Why it works: keyword fields store the exact string value, making term and terms queries efficient lookups.
  4. Aggregations on High-Cardinality Fields:

    • Diagnosis: Queries with aggregations are slow, especially terms aggregations. profile: true shows high aggregation_time.
    • Cause: terms aggregations by default try to collect all unique terms in a field. If the cardinality is very high, this can exhaust memory and CPU.
    • Fix:
      • Use composite aggregations for paginating through high-cardinality terms.
      • Limit the number of terms returned with size.
      • Consider using cardinality aggregation if you only need the count of unique terms.
      • If possible, aggregate on a keyword field.
      GET /my_index/_search
      {
        "size": 0, // We only care about aggregations
        "aggs": {
          "top_users": {
            "terms": {
              "field": "user_id", // Assume user_id is mapped as long or keyword
              "size": 100 // Limit to top 100
            }
          }
        }
      }
      
    • Why it works: Limiting size reduces the amount of data Elasticsearch needs to process and store for the aggregation. composite provides a scalable way to iterate.
  5. Large size Parameter without Pagination:

    • Diagnosis: Queries return a large number of hits, and took is high.
    • Cause: You’re requesting thousands or millions of documents in a single request. Elasticsearch has to collect and serialize all these hits.
    • Fix: Use pagination. For deep pagination, use search_after or scroll API. For simple pagination, use from and size.
      GET /my_index/_search
      {
        "query": { ... },
        "sort": [ ... ],
        "size": 100,
        "from": 0 // First page
      }
      
      // For next page:
      GET /my_index/_search
      {
        "query": { ... },
        "sort": [ ... ],
        "size": 100,
        "from": 100 // Second page
      }
      
    • Why it works: Reduces the amount of data transferred and processed per request. search_after is more efficient than from/size for very deep pagination as it avoids the overhead of skipping documents.
  6. Too Many Shards or Too Few Shards:

    • Diagnosis: Cluster-wide slowness, high CPU on nodes, _cat/shards shows many unassigned shards or very large shards.
    • Cause: Too many small shards create overhead for the master node and increase inter-node communication. Too few large shards can lead to single-node bottlenecks and slow recovery.
    • Fix: Aim for shard sizes between 10GB and 50GB. Adjust your index lifecycle management (ILM) policies or manually reindex data into indices with an optimal number of shards.
      // Example: Creating an index with 3 primary shards
      PUT /my_new_index
      {
        "settings": {
          "index": {
            "number_of_shards": 3,
            "number_of_replicas": 1
          }
        }
      }
      
    • Why it works: Optimizing shard count and size balances the load across nodes and reduces the overhead of managing many small indices or the burden on single nodes with massive indices.

After fixing these, you’ll likely encounter issues with shard allocation or mapping conflicts in newly created indices.

Want structured learning?

Take the full Elasticsearch course →