07d — Performance anti-patterns and templates
Most Elasticsearch performance problems fall into a small set of recognizable patterns — each with a clear fix. And most schema drift problems are preventable with composable index templates. This part covers both: the anti-patterns to eliminate first, and the template system that keeps new indices consistent.
Goals for this part
- Recognize and fix the eight most common performance anti-patterns
- Build component templates and index templates using
composed_of - Simulate template resolution before creating real indices with
_simulate_index - Understand why templates matter for rollover-based index lifecycles
7.6 Common performance anti-patterns
Each entry: symptom → root cause → fix.
No time filter on large time-series indices
Symptom: A query on logs-* or
events-* takes 30+ seconds; CPU spikes across all data
nodes.
Cause: Every shard of every index in the pattern is searched. On 30 days of data with 10 shards/day, that is 300 shards touched for a query that only needed the last 15 minutes.
Fix: Always scope time-series queries with a
@timestamp range filter. Elasticsearch uses shard-level
min/max timestamp metadata to skip entire shards (shard pruning), but
only if you provide the filter.
{ "range": { "@timestamp": { "gte": "now-15m" } } }
High-cardinality
terms aggregations
Symptom: A terms agg with
size: 10000 on user_id or
request_id is slow and uses a lot of heap.
Cause: Elasticsearch must collect and merge up to
size × number_of_shards buckets in the coordinating
node.
Fix: For true pagination over all buckets, use
composite aggregation with a cursor:
GET logs-read/_search
{
"size": 0,
"aggs": {
"by_user": {
"composite": {
"size": 1000,
"sources": [
{ "user_id": { "terms": { "field": "user_id" } } }
],
"after": { "user_id": "last-seen-user-id" }
}
}
}
}
Deep from/size
pagination
Symptom: Paginating to page 500 with
from: 4990, size: 10 is slow and grows slower with each
page.
Cause: Elasticsearch must collect and rank
from + size documents on every shard, then merge. At
from: 10000, that is 10000 docs pulled from each shard
before discarding all but 10.
Fix: Use search_after with a Point in
Time (PIT) for deep pagination:
POST shop-orders/_pit?keep_alive=1m
Then paginate:
GET shop-orders/_search
{
"size": 10,
"sort": [{ "created_at": "desc" }, { "_id": "asc" }],
"pit": { "id": "<pit-id>", "keep_alive": "1m" },
"search_after": ["2024-03-15T12:00:00Z", "order-abc"]
}
Wildcard and leading-wildcard queries on large fields
Symptom: A wildcard query like
"status": "*omplete*" or a regexp is slow even
with modest result counts.
Cause: Wildcard and regex queries with a leading wildcard cannot use the inverted index efficiently. They scan the term dictionary for matching terms.
Fixes: - Use prefix queries (no leading
wildcard) — they are optimised. - For suffix/infix matching, consider
edge_ngram or ngram tokenizers at index time.
- Normalise data so you can query exact keyword values instead. - Never
use wildcard/regexp on text
fields in a hot path.
Aggregating or sorting on
text fields
Symptom: Aggregation fails with
"Fielddata is disabled on text fields by default", or if
fielddata is enabled, heap usage climbs.
Cause: text fields are analyzed into
tokens. Aggregation requires the original (uninverted) value, which text
fields do not store in doc_values.
Fix: Add a keyword subfield. Aggregate
on field.keyword.
PUT shop-orders-demo/_mapping
{
"properties": {
"customer_name": {
"type": "text",
"fields": { "keyword": { "type": "keyword" } }
}
}
}
Scripts in hot paths
Symptom: A script_score or
script aggregation is slow; CPU is high on data nodes.
Cause: Scripts run interpreted (Painless compiles to Java bytecode, but still slower than native aggregation). Applied per document, per shard, per query.
Fixes: - Precompute the value at index time and
store it as a numeric field. - Use function_score with
built-in decay functions instead of custom scripts where possible.
Mapping/field explosion from dynamic mapping
Symptom: Cluster state updates are slow;
GET _cluster/state returns a very large response; heap
pressure is unexplained.
Cause: Dynamic mapping is enabled by default. If your documents have unpredictable keys, Elasticsearch creates a new field mapping entry for every unique key it sees. Thousands of unique field names → large cluster state → heap pressure on master nodes.
Fixes: - Set "dynamic": "strict" and
define fields explicitly. - Set "dynamic": "false" on
sub-objects where you want values stored but not indexed. - Use the
flattened field type for metadata blobs you might want to
search but don’t need per-field analysis on.
Returning huge _source
Symptom: Fetching 100 documents takes much longer than expected; network throughput is high.
Cause: _source stores the original
document JSON. If each document is 50KB and you return 1000 of them,
that is 50MB of payload per query.
Fixes: - Use
_source: ["field1", "field2"] filtering to return only what
you need. - Or use "fields": ["field1", "field2"] to read
from doc_values (bypasses _source decompression for
supported types).
GET shop-orders-demo/_search
{
"_source": ["order_id", "status", "total_amount"],
"query": { "term": { "status": "pending" } }
}
“Just add heap” and “just restart” myths
“Add more heap”: Elasticsearch recommends no more than 30–32 GB heap (above that, JVM pointers expand and you lose compressed oops). More importantly, heap you give to Elasticsearch is heap you take away from the OS filesystem cache — which Lucene depends on heavily. Adding heap past 30 GB often makes performance worse.
“Just restart”: A restart clears caches and resets some in-memory state. It feels faster immediately because warm-up queries benefit from cache. The root cause is unchanged and the problem returns within minutes.
7.7 Index templates and component templates
Why templates matter to developers
Every time you roll over to a new index generation (via ILM or
manually), a fresh index is created. Without a template, that index gets
Elasticsearch defaults: dynamic mapping on, no lifecycle policy, default
shard count. Within a few weeks you have mapping drift —
status is text in some generations and
keyword in others; @timestamp is missing on
one; queries break across generations.
Templates are the contract that every new index in a pattern will have the same mappings, settings, and aliases.
Composable templates: component templates + index templates
Component template
(_component_template): a reusable building block. Defines a
fragment of mappings, settings, or aliases. Multiple component templates
can be shared across many index templates.
Index template (_index_template):
applies to indices matching index_patterns. Lists which
component templates to compose via composed_of. Sets
priority to resolve conflicts when multiple templates match
the same index name.
Example: shared timestamp + metadata component template
PUT _component_template/common-timestamp
{
"template": {
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"env": { "type": "keyword" },
"service": { "type": "keyword" },
"version": { "type": "keyword" }
}
}
}
}
A second component template for log-specific fields:
PUT _component_template/log-fields
{
"template": {
"mappings": {
"dynamic": "strict",
"properties": {
"level": { "type": "keyword" },
"message": {
"type": "text",
"fields": { "raw": { "type": "keyword" } }
},
"trace_id": { "type": "keyword", "index": false }
}
}
}
}
Compose them into an index template:
PUT _index_template/logs-composable-template
{
"index_patterns": ["logs-*"],
"composed_of": ["common-timestamp", "log-fields"],
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1,
"index.lifecycle.name": "logs-hot-delete",
"index.lifecycle.rollover_alias": "logs-write"
}
},
"priority": 200,
"data_stream": {}
}
prioritydetermines which template wins when multiple templates match the same index name. Higher priority wins. Built-in templates use priority 0–100; use 200+ for your custom templates.
Verifying template application
Check the template definition:
GET _index_template/logs-composable-template
Simulate what mappings a new index would get without actually creating it:
POST _index_template/_simulate_index/logs-2024-test
The response shows the fully merged mappings, settings, and aliases
that would apply — invaluable for validating that
composed_of is resolving correctly before you create any
real indices.
Check which templates would match a given index name:
POST _index_template/_simulate/logs-composable-template
Pitfall: The most common mistake is forgetting to set
priority. If multiple templates matchlogs-*and you don’t set priority, Elasticsearch picks one arbitrarily. Always set explicit priorities and simulate before creating indices in production.
When to use component templates vs inline template settings
- Use component templates for anything shared across multiple index templates: common field definitions (timestamp, env, tenant id), standard settings.
- Put index-specific settings (shard count, ILM
policy, rollover alias) directly in the index template’s
templateblock. - Avoid duplicating the same mapping in multiple index templates — extract it into a component template and compose it.
Lab — Anti-patterns and composable templates
Part A: Spot and fix performance anti-patterns
Goal: Identify slow query patterns and apply the correct fix.
Step 1 — Deep pagination with
from/size. Run this and note the response time:
GET /shop-orders-demo/_search
{
"from": 1000,
"size": 10,
"sort": [{ "created_at": "desc" }, { "_id": "asc" }],
"query": { "match_all": {} }
}
Now rewrite it using a PIT + search_after:
POST /shop-orders-demo/_pit?keep_alive=1m
Take the id from the response, then run:
GET /shop-orders-demo/_search
{
"size": 10,
"sort": [{ "created_at": "desc" }, { "_id": "asc" }],
"pit": { "id": "<pit-id-here>", "keep_alive": "1m" }
}
To get the next page, use the sort values from the last
hit:
GET /shop-orders-demo/_search
{
"size": 10,
"sort": [{ "created_at": "desc" }, { "_id": "asc" }],
"pit": { "id": "<pit-id-here>", "keep_alive": "1m" },
"search_after": ["<last-created_at>", "<last-_id>"]
}
Delete the PIT when done:
DELETE /_pit
{ "id": "<pit-id-here>" }
Step 2 — Oversized _source. Profile and
compare returning all fields vs a subset:
GET /shop-orders-demo/_search
{
"profile": true,
"query": { "term": { "order_status": "completed" } },
"size": 20
}
Then restrict _source:
GET /shop-orders-demo/_search
{
"profile": true,
"_source": ["order_id", "order_status", "total_amount", "customer.name"],
"query": { "term": { "order_status": "completed" } },
"size": 20
}
Compare the fetch phase time in the profile. Is there a
difference?
Step 3 — Dynamic mapping field explosion. Create an index with dynamic mapping enabled and index documents with random field names:
PUT /dynamic-demo
{
"settings": { "number_of_replicas": 0 }
}
POST /dynamic-demo/_doc
{
"user_field_a": "value",
"user_field_b": "value",
"user_field_c": 123
}
GET /dynamic-demo/_mapping
Now create a second index with "dynamic": "false" on a
metadata sub-object and observe the difference:
PUT /dynamic-strict-demo
{
"settings": { "number_of_replicas": 0 },
"mappings": {
"properties": {
"order_id": { "type": "keyword" },
"metadata": {
"type": "object",
"dynamic": false
}
}
}
}
POST /dynamic-strict-demo/_doc
{
"order_id": "ord-001",
"metadata": {
"arbitrary_key_1": "value",
"arbitrary_key_2": 42
}
}
GET /dynamic-strict-demo/_mapping
Is metadata.arbitrary_key_1 added to the mapping? Can
you search on it?
DELETE /dynamic-demo
DELETE /dynamic-strict-demo
Part B: Component templates and simulation
Goal: Build a composable template system and verify it with simulation.
Step 4 — Create a common-timestamp
component template:
PUT _component_template/lab-common-timestamp
{
"template": {
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"env": { "type": "keyword" },
"service": { "type": "keyword" },
"version": { "type": "keyword" }
}
}
}
}
Step 5 — Create a log-fields component
template:
PUT _component_template/lab-log-fields
{
"template": {
"mappings": {
"dynamic": "strict",
"properties": {
"level": { "type": "keyword" },
"message": { "type": "text", "fields": { "raw": { "type": "keyword" } } },
"trace_id": { "type": "keyword", "index": false }
}
}
}
}
Step 6 — Compose them into an index template:
PUT _index_template/lab-logs-composable
{
"index_patterns": ["lab-logs-*"],
"composed_of": ["lab-common-timestamp", "lab-log-fields"],
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
}
},
"priority": 200
}
Step 7 — Simulate before creating any real index:
POST _index_template/_simulate_index/lab-logs-2024-test
Questions from the simulation output: 1. Are all the expected fields
present in the merged mappings? 2. Is dynamic: strict
applied (meaning unexpected fields will be rejected)? 3. What settings
are applied?
Step 8 — Create the index and verify the mapping matches the simulation:
PUT /lab-logs-2024-test
GET /lab-logs-2024-test/_mapping
Step 9 — Change a field in
lab-log-fields component template (e.g., add a
duration_ms field):
PUT _component_template/lab-log-fields
{
"template": {
"mappings": {
"dynamic": "strict",
"properties": {
"level": { "type": "keyword" },
"message": { "type": "text", "fields": { "raw": { "type": "keyword" } } },
"trace_id": { "type": "keyword", "index": false },
"duration_ms": { "type": "integer" }
}
}
}
}
Re-simulate. Does the simulation show duration_ms? Check
the existing index mapping — is it affected?
POST _index_template/_simulate_index/lab-logs-2024-test
GET /lab-logs-2024-test/_mapping
Step 10 — Clean up:
DELETE /lab-logs-2024-test
DELETE _index_template/lab-logs-composable
DELETE _component_template/lab-common-timestamp
DELETE _component_template/lab-log-fields
Takeaway: Component templates extract shared schema
into reusable building blocks. _simulate_index is the dry
run — use it every time before creating real indices in production.
Existing indices are unaffected by template changes; only new indices
pick up the update. Set explicit priority values; if two
templates match the same pattern without priorities, the winner is
non-deterministic.