Elasticsearch Engineering
A study guide for the Elastic Certified Engineer exam (Elasticsearch 9.3), organised by the official exam objectives.
How to read this guide. Each objective gets (1) a mental model, often an everyday analogy, (2) the APIs you'll actually type, (3) the traps graders like, and (4) links to the exact Elastic documentation pages. The Elastic documentation is the only resource available during the exam, so the 📖 links double as a map of where to go on exam day. Exercises with solution notes close each domain. The companion page, Elasticsearch Engineering – Digest, holds the glossary and the must-remember facts.
0. The exam and your preparation
0.1 Format
| Item | Detail |
|---|---|
| Version | Elasticsearch 9.3 (the 8.x exam was retired on 1 Sep 2026) |
| Type | Hands-on, performance-based, on live Elasticsearch and Kibana clusters, remote-proctored |
| Duration | About 3 hours |
| Tasks | About 10–12 practical tasks, often combining several objectives, with partial credit |
| Allowed resources | Only the Elastic Documentation |
| Grading | The final state of your clusters: indices, templates, pipelines, roles, and so on |
📖 Exam page · Certification FAQ
Analogy. It's a practical driving test, not a written theory test. Nobody asks you to recite the highway code. The examiner checks whether the car ends up correctly parked. The docs are your satnav: allowed, but only useful if you already know roughly where you're going.
0.2 The six domains
| # | Domain | Objectives |
|---|---|---|
| 1 | Elasticsearch Architecture | Stack architecture; shard sizing and allocation strategies |
| 2 | Data Management | Index definition; dynamic templates; ILM for time series; index template that creates a data stream; aliases |
| 3 | Data Ingestion & Processing | Ingest methods; mappings; field data types; Reindex and Update By Query; ingest pipelines; Streams |
| 4 | Searching Data | Term and phrase queries; bool combinations; metric and bucket aggregations; ES|QL; semantic search; cross-cluster search |
| 5 | Cluster Management | Monitor and maintain cluster health; cross-cluster operations |
| 6 | Security & Access Control | RBAC; securing a cluster for production |
0.3 Build a lab first
You can't prepare for a hands-on exam by reading. Use 9.3.x so the APIs and UI match the exam.
Option A: single node in one command (good for Domains 2–4)
curl -fsSL https://elastic.co/start-local | sh📖 Local installation quickstart
Option B: three-node Docker Compose (needed for allocation, awareness, snapshots, and Domain 5)
📖 Multi-node cluster with Docker Compose
Settings to vary per node (as elasticsearch.yml or environment variables):
cluster.name: lab
node.name: es01 # es02, es03
node.roles: [ master, data_hot, data_content, ingest, remote_cluster_client ]
node.attr.zone: zone-a # zone-b, zone-c → awareness exercises
discovery.seed_hosts: [ es01, es02, es03 ]
cluster.initial_master_nodes: [ es01, es02, es03 ] # first bootstrap ONLY, then remove
path.repo: [ /usr/share/elasticsearch/snapshots ] # shared FS repo for snapshotsFor Domain 5, add a second cluster (e.g. remote) so you can practise remote clusters, cross-cluster search (CCS) and cross-cluster replication (CCR).
Enable a trial license. CCR, searchable snapshots, some security features and ML-based semantic search need it:
POST _license/start_trial?acknowledge=trueLoad sample data. In Kibana: Home → Add data → Sample data (eCommerce, Flights, Web logs). Many exam tasks look exactly like work on these datasets.
0.4 Exam-day habits that earn points
- Read each task twice and copy names (indices, fields, pipelines, roles) straight from the task text. A typo scores zero.
- Verify everything.
GETwhat you created. Compare_countbefore and after a reindex._simulatea pipeline before using it. - Don't destroy state the task didn't mention. Graders inspect the final state.
- Prototype on scratch indices (
tmp-*), then clean up. - Time-box. Roughly 15 minutes per task. At 20 minutes, move on and come back later.
- Know the "immutable" things. You can't change shard count, field types or a field's analyzer in place. The answer is a new index plus
_reindex, or_split/_shrinkfor shards. - Use Console shortcuts.
Ctrl/Cmd+Enterruns a request,Ctrl/Cmd+/opens the docs for the API under the cursor, andCtrl/Cmd+Iauto-indents.
1. Elasticsearch Architecture
1.1 Describe the Elastic Stack architecture
The big picture
Analogy: a postal system. Collectors (Elastic Agent, Beats, OpenTelemetry collectors, Logstash) pick up letters everywhere. Sorting centres (Logstash, ingest pipelines, Streams) open, stamp and classify them. The warehouse (Elasticsearch) stores every letter so it can be found in milliseconds. The front desk (Kibana) is where people ask questions and see dashboards. Fleet is the dispatch office that tells every collector what to pick up.
| Layer | Components | Role |
|---|---|---|
| Collect | Elastic Agent (+ Fleet Server), Beats (Filebeat, Metricbeat…), EDOT / OpenTelemetry Collector, APM agents, language clients, connectors | Ship logs, metrics, traces and documents |
| Process | Logstash, Elasticsearch ingest pipelines, Streams, integrations' built-in pipelines | Parse, enrich, route, transform |
| Store / search / analyse | Elasticsearch | Distributed document store, search engine, analytics and vector database built on Apache Lucene |
| Explore / manage | Kibana (Discover, Dashboards, Dev Tools, Stack Management, Fleet UI, Streams UI) | Visualise, query, administer |
| Solutions | Search, Observability, Security | Opinionated apps on top of the same data |
Deployment options: Elastic Cloud Hosted (ECH), Elastic Cloud Serverless (no nodes or shards to manage), Elastic Cloud Enterprise (ECE), Elastic Cloud on Kubernetes (ECK), and self-managed. The exam uses self-managed-style clusters, so everything below assumes you can touch nodes and settings.
📖 Elastic fundamentals · Deployment comparison · Ingest tools overview
Inside Elasticsearch: cluster → node → index → shard → segment
Analogy: a library chain. The cluster is the whole chain. Each node is a branch building. An index is a book collection (for example "novels"). Because the collection is too big for one shelf, it's cut into volumes: the primary shards. Each volume has photocopies (replica shards), always kept in a different branch. Inside each volume, pages are glued in batches (segments) that are never edited, only replaced when batches are merged.
| Concept | What to know |
|---|---|
| Cluster | One or more nodes sharing a cluster.name. One elected master manages the cluster state (metadata: indices, mappings, settings, shard routing table). |
| Node | One Elasticsearch process. Its roles decide what it does (table below). |
| Index | A logical namespace of JSON documents with one mapping and one set of settings. |
| Shard | A Lucene index. Primary shards hold the data; replicas are copies that give high availability and extra read throughput. A primary and its replica are never on the same node. |
| Segment | An immutable, self-contained piece of a shard. Deletes are marked; merges physically drop them. |
| Document routing | shard = hash(_routing) % number_of_routing_shards → mapped to primaries. _routing defaults to _id. This is why the primary count is fixed at creation. |
Node roles (node.roles in elasticsearch.yml):
| Role | Purpose |
|---|---|
master | Eligible to be elected master. Run 3 dedicated master-eligible nodes in production. |
voting_only | Votes in elections but never becomes master (a tie-breaker). |
data | Generic data role (holds all tiers). |
data_content | Non-time-series content (product catalogues and so on). |
data_hot / data_warm / data_cold / data_frozen | Data tiers for time-series data, from fastest and most expensive to cheapest. |
ingest | Runs ingest pipelines. |
ml | Runs machine-learning jobs and trained models (e.g. ELSER). |
remote_cluster_client | Can connect to remote clusters (needed for CCS and CCR). |
transform | Runs transforms. |
[] (empty) | Coordinating-only node: routes requests and merges results. |
Every node is also a coordinating node. Whichever node receives the request fans it out and gathers the results.
Write path: client → coordinating node → routes to the primary shard → primary indexes (in-memory buffer + translog) → forwards to replicas in parallel → acknowledges once the required copies succeed (wait_for_active_shards, default 1 = primary only).
Near real-time search: a refresh (default every 1s, only on indices that have been searched recently) turns the buffer into a searchable segment. A flush makes segments durable on disk and trims the translog. Merges combine small segments in the background.
Read path (query then fetch): the coordinating node sends the query to one copy of each shard. Each shard returns its top IDs and scores (query phase). The coordinator merges them, then fetches the actual documents (fetch phase).
Cluster formation: master-eligible nodes elect a master by quorum (majority of the voting configuration). Three master-eligible nodes tolerate one failure. cluster.initial_master_nodes is used only for the very first bootstrap; remove it afterwards.
📖 Clusters, nodes and shards · Node roles · Reading and writing documents · Near real-time search · Discovery and cluster formation · Quorum-based decisions
Data tiers
Analogy: a wardrobe. Hot is the chair where today's clothes lie: instant access, most expensive space. Warm is the wardrobe. Cold is the suitcase under the bed. Frozen is the storage unit across town: you can still get anything, but it takes a trip. Content is your bookshelf. It's not "time-based", it's just always there.
- New indices go to
data_contentby default; data stream backing indices go todata_hot. - Tier placement uses
index.routing.allocation.include._tier_preference(e.g.data_warm,data_hot), which ILM'smigrateaction sets automatically. - Frozen uses partially mounted searchable snapshots: a local cache plus the object store. Cold can use fully mounted searchable snapshots, which lets it drop replicas.
📖 Data tiers · Searchable snapshots
1.2 Describe shard sizing and allocation strategies
Shard sizing: the rules of thumb
Analogy: moving boxes. If boxes are too big, nobody can lift them: slow recovery, slow rebalancing. If there are too many tiny boxes, the truck spends its time on paperwork: every shard costs heap, file handles and cluster-state overhead. Aim for boxes one person can carry.
| Guideline | Value |
|---|---|
| Target shard size | 10 GB – 50 GB (time-series often aims for about 50 GB via rollover max_primary_shard_size) |
| Documents per shard | Keep under 200 million |
| Max shards per node (non-frozen) | cluster.max_shards_per_node = 1000 by default. It's a soft limit that blocks index creation. |
| Heap overhead | Mostly driven by mapped fields and segments. Avoid mapping explosions and thousands of tiny indices. |
| Searches | Each search runs one thread per shard copy. Many small shards means lots of queuing. |
| Time series | Use data streams + rollover instead of daily indices to control size, not calendar. |
| Too many tiny shards? | _shrink, _forcemerge, reindex into fewer indices, raise rollover thresholds. |
| Shard too big? | _split, or lower rollover thresholds for new data. |
GET _cat/shards?v&s=store:desc&h=index,shard,prirep,state,docs,store,node
GET _cat/indices?v&s=pri.store.size:desc
GET _cluster/stats?filter_path=indices.shards,indices.count📖 Size your shards · Scaling considerations
How shard allocation works
The master's allocator decides where every shard copy lives, filtered by deciders. Each decider can say YES, NO or THROTTLE. The common ones:
| Decider | Rule |
|---|---|
| Same shard | Never two copies of the same shard on one node |
| Disk threshold | Watermarks: low 85% (no new shards to this node), high 90% (move shards away), flood stage 95% (indices on it become read-only: index.blocks.read_only_allow_delete) |
| Awareness | Spread copies across attribute values (zone or rack) |
| Filtering | include / exclude / require rules at index or cluster level |
| Data tier | _tier_preference must match a node's role |
| Max retries | After 5 failed allocations (index.allocation.max_retries) the shard gives up until retried |
| Enable | cluster.routing.allocation.enable: all / primaries / new_primaries / none |
| Total shards per node | index.routing.allocation.total_shards_per_node hard limit |
The single best diagnostic API:
GET _cluster/allocation/explain
{ "index": "orders", "shard": 0, "primary": false }Strategy 1: allocation awareness (survive losing a zone or rack)
Analogy. Don't keep your passport and its photocopy in the same bag.
# elasticsearch.yml on each node
node.attr.zone: zone-a # zone-b on othersPUT _cluster/settings
{
"persistent": {
"cluster.routing.allocation.awareness.attributes": "zone",
"cluster.routing.allocation.awareness.force.zone.values": "zone-a,zone-b"
}
}- Awareness (first setting): Elasticsearch spreads copies across zones where it can.
- Forced awareness (second setting): if
zone-bdisappears, Elasticsearch won't squeeze all replicas intozone-a(which could overload it). The replicas stay unassigned instead. That's intended behaviour, so a yellow cluster can be the correct answer.
Strategy 2: allocation filtering (pin or evict shards)
// Index-level: keep "hot-orders" only on nodes tagged box_type=ssd
PUT hot-orders/_settings
{ "index.routing.allocation.require.box_type": "ssd" }
// Index-level by node name (e.g. to prepare a shrink)
PUT legacy/_settings
{ "index.routing.allocation.require._name": "es01" }
// Cluster-level: drain a node before decommissioning it
PUT _cluster/settings
{ "persistent": { "cluster.routing.allocation.exclude._name": "es03" } }include means at least one of the values, exclude means none of them, and require means all of them. Built-in attributes: _name, _host_ip, _publish_ip, _ip, _host, _id, _tier.
📖 Index-level shard allocation · Shard allocation, relocation and recovery
Strategy 3: delayed allocation and rolling restarts
When a node leaves, its replicas are reallocated elsewhere only after index.unassigned.node_left.delayed_timeout (default 1m), which avoids expensive reshuffles when a node is just rebooting. For planned maintenance:
PUT _cluster/settings
{ "persistent": { "cluster.routing.allocation.enable": "primaries" } }
POST _flush
// …restart the node, wait for it to join…
PUT _cluster/settings
{ "persistent": { "cluster.routing.allocation.enable": null } }📖 Delaying allocation when a node leaves · Rolling restart
Strategy 4: resilience design
- Small clusters: at least 3 master-eligible nodes, and 1 replica for every index you can't afford to lose. A 2-node cluster isn't resilient: losing the wrong node leaves no voting majority. Add a third node (it can be a small
voting_only, master-eligible tie-breaker). - Larger clusters: dedicated masters, spread across 3 zones, with awareness enabled.
📖 Resilience in small clusters · Resilience in larger clusters
1.3 Exercises: Architecture
- On your three-node lab, give the nodes attributes
zone: a,zone: a,zone: b. Configure forced awareness onzonewith valuesa,b. Create indexawarewith 1 shard and 1 replica. Stop thezone: bnode and explain the resulting health. - Create index
ssd-onlywhose shards may only live on nodes with attributedisk: ssd(set it on one node). Prove it with_cat/shards. - Decommission node
es03: move all its shards off without stopping it. - Explain why an index created with 1 primary shard can't be given 3 primaries in place, and name the two ways to get there.
Solution notes
- Settings as in Strategy 1. With
zone bgone, the replica stays unassigned because forced awareness forbids placing both copies in zone a, so health is yellow._cluster/allocation/explainnames the awareness decider. node.attr.disk: ssdon one node, thenPUT ssd-only {"settings":{"index.routing.allocation.require.disk":"ssd","number_of_replicas":0}}. With 1 replica, the replica can't allocate because only one node qualifies.PUT _cluster/settings {"persistent":{"cluster.routing.allocation.exclude._name":"es03"}}, then watch_cat/shardsuntil nothing remains on es03.- Routing is
hash(_routing) % shards, so changing the count would misplace every document. Use_split(by multiples of the routing shards; needs a write block) or_reindexinto a new index.
2. Data Management
Analogy for the whole domain: a filing cabinet factory. An index is a filing cabinet. Its settings are the hardware: how many drawers (primary shards) and how many photocopied backups (replicas). Its mapping is the labelling scheme for folders. Templates are the factory spec sheet, so every new cabinet named
logs-*ships pre-configured. Aliases are nicknames on the door ("current-orders"), so people don't need the serial number. ILM is the records-retention policy: new files on the desk, older ones in the basement, ancient ones in the shredder.
2.1 Define an index that satisfies a given set of requirements
An index creation request has three optional parts: settings, mappings and aliases.
PUT products
{
"settings": {
"number_of_shards": 2,
"number_of_replicas": 1,
"refresh_interval": "30s",
"analysis": {
"analyzer": {
"folded": { "type": "custom", "tokenizer": "standard",
"filter": ["lowercase", "asciifolding"] }
}
}
},
"mappings": {
"dynamic": "strict",
"properties": {
"sku": { "type": "keyword" },
"name": { "type": "text", "analyzer": "folded",
"fields": { "raw": { "type": "keyword", "ignore_above": 256 } } },
"price": { "type": "scaled_float", "scaling_factor": 100 },
"in_stock": { "type": "boolean" },
"created": { "type": "date", "format": "yyyy-MM-dd||epoch_millis" },
"tags": { "type": "keyword" }
}
},
"aliases": { "catalog": {} }
}Static vs dynamic settings. Know which is which:
| Static (at creation, or on a closed index) | Dynamic (PUT <index>/_settings any time) |
|---|---|
index.number_of_shards | index.number_of_replicas |
index.codec (e.g. best_compression) | index.refresh_interval (-1 disables refresh) |
index.mode (standard, logsdb, time_series, lookup) | index.routing.allocation.* |
index.sort.* | index.blocks.write / read_only / read |
| analysis definitions (close → update → open) | index.max_result_window, index.default_pipeline, index.final_pipeline |
PUT products/_settings
{ "index": { "number_of_replicas": 0, "refresh_interval": "-1" } } // bulk-load modeAdding an analyzer to an existing index: POST products/_close → PUT products/_settings with the analysis block → POST products/_open. You still can't change the analyzer of an existing field. Add a new field or multi-field, or reindex.
Changing the shard count:
_split: more shards, by a multiple. Needsindex.blocks.write: true._shrink: fewer shards, to a factor. Needs a write block and a copy of every shard on one node (index.routing.allocation.require._name).- Otherwise
_reindex.
PUT products/_settings { "index.blocks.write": true }
POST products/_split/products-4sh
{ "settings": { "index.number_of_shards": 4, "index.blocks.write": null } }dynamic mapping modes, a frequent grader trap:
| Value | Unknown field in a document… |
|---|---|
true (default) | Added to the mapping automatically |
runtime | Added as a runtime field (not indexed, computed at query time) |
false | Accepted and kept in _source, but not indexed or searchable |
strict | Document rejected with an error |
Task phrasing to watch for: "unknown fields must be rejected" means strict; "must be stored but not searchable" means false.
Index modes (9.x). logsdb is the storage-optimised mode for logs, with synthetic _source and index sorting. It's the default for logs-*-* data streams in 9.x. time_series (TSDS) is for metrics. lookup is required for the right-hand index of an ES|QL LOOKUP JOIN.
📖 Create index API · Index settings reference · Index fundamentals · Dynamic mapping · Logs data streams (logsdb)
2.2 Index templates and component templates
Composable index templates (_index_template), built from reusable component templates (_component_template), are the standard. Legacy _template templates have been deprecated since 7.8; composable ones always win over them.
Analogy: Lego. Component templates are bricks: a "1 shard" brick, an "HTTP fields" brick. The index template is the instruction sheet saying which bricks to snap together for anything named
web-*, plus a few extra pieces of its own.
PUT _component_template/ct-settings-small
{ "template": { "settings": { "number_of_shards": 1, "number_of_replicas": 1 } } }
PUT _component_template/ct-mappings-http
{
"template": {
"mappings": {
"properties": {
"http.status": { "type": "short" },
"url": { "type": "keyword" }
}
}
}
}
PUT _index_template/web-template
{
"index_patterns": ["web-*"],
"priority": 200,
"composed_of": ["ct-settings-small", "ct-mappings-http"],
"template": {
"settings": { "number_of_replicas": 2 },
"aliases": { "web-all": {} }
},
"_meta": { "owner": "exam" }
}Precedence, from lowest to highest: component templates in composed_of order (later overrides earlier), then the index template's own template block, then settings in the actual create-index request. In the example, replicas end up at 2.
Priority rules:
- Only one index template applies to a new index: the matching one with the highest
priority. - Two templates with equal priority and overlapping patterns are rejected when you create the second.
- Built-in templates use priority 100 for
logs-*-*,metrics-*-*,synthetics-*-*,profiling-*,security_solution-*-*, and the Streams patternslogs.otel*andlogs.ecs*. Fleet integrations go up to 200. To deliberately override a built-in pattern, the docs recommend priority > 500. Otherwise choose non-overlapping patterns.
Verify before you commit:
POST _index_template/_simulate_index/web-2026.09.28 // what would this index get?
POST _index_template/_simulate/web-template // resolved template
GET _index_template/web-templateTemplates apply only when an index is created. Changing a template never alters existing indices. For data streams, roll over to pick up changes.
📖 Templates · Simulate multi-component templates · Put index template API · Put component template API
2.3 Define and use a dynamic template that satisfies a given set of requirements
Dynamic templates control how newly detected fields get mapped. You match on the JSON-detected type (match_mapping_type), the field name (match / unmatch, wildcards or "match_pattern": "regex"), or the dotted path (path_match / path_unmatch).
Analogy. A mail-room rulebook: "anything that looks like a parcel and whose label ends in
_idgoes to shelf K; every other parcel goes to shelf T". The first rule that fits wins.
PUT _index_template/sensor-template
{
"index_patterns": ["sensor-*"],
"template": {
"mappings": {
"dynamic_templates": [
{ "ids_as_keyword": {
"match_mapping_type": "string",
"match": "*_id",
"mapping": { "type": "keyword" } } },
{ "desc_english": {
"match_mapping_type": "string",
"match": "desc_*",
"mapping": { "type": "text", "analyzer": "english" } } },
{ "counts_as_integer": {
"match_mapping_type": "long",
"match": "*_count",
"mapping": { "type": "integer" } } },
{ "raw_not_indexed": {
"path_match": "raw.*",
"mapping": { "type": "keyword", "index": false, "doc_values": false } } },
{ "strings_default": {
"match_mapping_type": "string",
"mapping": { "type": "text",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } } }
]
}
}
}Rules to remember:
- Order matters: first match wins. Put specific rules first and the catch-all (
strings_default) last. A catch-all placed first silently shadows the others, which is a classic hidden bug in exam tasks. match_mapping_typevalues are JSON-detected types:string,long,double,boolean,date,object,binary, or*. There's nointegerorfloatdetection.- Placeholders:
{name}(field name) and{dynamic_type}(detected type), e.g."analyzer": "{name}". - Use
"runtime": { "type": "keyword" }instead of"mapping"to create runtime fields dynamically. path_matchmatches the full dotted path (raw.a.b);matchmatches only the leaf name.- Dynamic templates apply to new fields only. Fields already mapped are untouched.
- Test it: index one sample document, then
GET sensor-1/_mapping.
📖 Dynamic templates · Dynamic field mapping
2.4 Define an index template that creates a new data stream
A data stream is one name (e.g. logs-app-prod) in front of a series of hidden backing indices (.ds-logs-app-prod-2026.09.28-000001, …). It's for append-only, time-stamped data and requires an @timestamp field (date or date_nanos).
Analogy: a notebook with detachable pages. You always write on the current page (the write index). When a page fills up (rollover), a new page is added. You can read all pages at once, and you tear out and throw away old pages individually (ILM delete).
Only a matching index template with "data_stream": {} creates a data stream. Without it, writing to logs-app-prod creates an ordinary index.
PUT _index_template/logs-app-template
{
"index_patterns": ["logs-app-*"],
"data_stream": { },
"priority": 501,
"composed_of": [],
"template": {
"settings": { "index.lifecycle.name": "logs-app-policy" },
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"message": { "type": "text" },
"level": { "type": "keyword" }
}
},
"data_stream_options": { "failure_store": { "enabled": true } }
}
}
// Created implicitly on first write…
POST logs-app-prod/_doc
{ "@timestamp": "2026-09-28T10:00:00Z", "message": "started", "level": "INFO" }
// …or explicitly
PUT _data_stream/logs-app-prodOperations and constraints:
| Want to… | Do |
|---|---|
| Add documents | POST <ds>/_doc, or _bulk with the create action. Data streams are append-only; index only works with op_type=create. |
| Update or delete documents | _update_by_query / _delete_by_query on the stream, or single-doc ops on the backing index with if_seq_no / if_primary_term |
| Roll over now | POST logs-app-prod/_rollover |
| Inspect | GET _data_stream/logs-app-prod, GET _data_stream/logs-app-prod/_stats |
| Delete | DELETE _data_stream/logs-app-prod (deletes its backing indices too) |
| Change mappings or settings | Update the template, then roll over. Some changes can also be applied to the stream via the update mapping API. |
| Reindex into a stream | "dest": { "index": "logs-app-prod", "op_type": "create" } |
| Convert an alias with a write index into a stream | POST _data_stream/_migrate/<alias> |
| Capture rejected documents | Failure store (data_stream_options.failure_store.enabled). Query it with FROM logs-app-prod::failures or GET logs-app-prod::failures/_search. |
Naming scheme. Elastic's convention is type-dataset-namespace (e.g. logs-nginx.access-prod). Any name works for your own templates, but anything matching logs-*-* hits the built-in template (priority 100) unless yours has higher priority.
📖 Data streams · Set up a data stream · Use a data stream · Modify a data stream · Failure store · Time series data streams
2.5 Define an Index Lifecycle Management policy for a time-series index
ILM moves each index through phases: hot → warm → cold → frozen → delete. Each phase has a min_age (measured from rollover, or from creation if the index never rolled over) and a set of actions.
Analogy: a restaurant kitchen's food rotation. Fresh produce is on the counter (hot). After a few days it moves to the walk-in fridge (warm), then the freezer (cold or frozen), and finally the bin (delete). The chef doesn't move things by hand; the rotation rules do.
PUT _ilm/policy/logs-app-policy
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": { "max_primary_shard_size": "50gb", "max_age": "1d" },
"set_priority": { "priority": 100 }
}
},
"warm": {
"min_age": "7d",
"actions": {
"shrink": { "number_of_shards": 1 },
"forcemerge": { "max_num_segments": 1 },
"allocate": { "number_of_replicas": 1 },
"set_priority": { "priority": 50 }
}
},
"cold": {
"min_age": "30d",
"actions": { "searchable_snapshot": { "snapshot_repository": "my-repo" } }
},
"delete": {
"min_age": "90d",
"actions": { "delete": {} }
}
}
}
}Where each action is allowed:
| Action | Hot | Warm | Cold | Frozen | Delete |
|---|---|---|---|---|---|
rollover | ✓ | ||||
set_priority | ✓ | ✓ | ✓ | ||
unfollow (CCR) | ✓ | ✓ | ✓ | ✓ | |
readonly | ✓* | ✓ | ✓ | ||
shrink, forcemerge | ✓* | ✓ | |||
downsample (TSDS) | ✓* | ✓ | ✓ | ||
allocate, migrate | ✓ | ✓ | |||
searchable_snapshot | ✓* | ✓ | ✓ (required) | ||
wait_for_snapshot, delete | ✓ |
* In hot, only when rollover is also configured.
Key facts:
- Rollover conditions are OR-ed (
max_age,max_docs,max_size,max_primary_shard_size,max_primary_shard_docs).min_*conditions are AND-ed floors. Elasticsearch never rolls over an empty index. - Rollover needs a data stream or a write alias:
index.lifecycle.rollover_aliasplus a bootstrap indexname-000001with"is_write_index": true. migrateis implicit in warm and cold. It sets_tier_preferenceto the phase's tier. With no nodes of that tier, the shards stay where they are, which is a common "why isn't it moving?" moment. Disable it with"migrate": {"enabled": false}when using attribute-basedallocate.- ILM checks every
indices.lifecycle.poll_interval(10m default). In a lab, set it to10sto see transitions quickly. - Attach a policy to new indices via the template (
index.lifecycle.name). For existing indices, usePUT <index>/_settings {"index.lifecycle.name": "…"}. - Changing a policy: an index already in a phase keeps the cached phase definition and picks up the new one at the next phase.
Bootstrap pattern without data streams (a classic task):
PUT _index_template/orders-tpl
{
"index_patterns": ["orders-*"],
"template": { "settings": {
"index.lifecycle.name": "orders-policy",
"index.lifecycle.rollover_alias": "orders" } }
}
PUT orders-000001
{ "aliases": { "orders": { "is_write_index": true } } }Debugging ILM:
GET logs-app-prod/_ilm/explain // phase, action, step, failed_step, step_info
POST logs-app-prod/_ilm/retry // after fixing the cause of an ERROR step
POST _ilm/move/<index> // force a step (rare)
GET _ilm/status POST _ilm/start POST _ilm/stop
PUT _cluster/settings { "persistent": { "indices.lifecycle.poll_interval": "10s" } }Data stream lifecycle (DSL): the lighter alternative
For data streams you can skip ILM and just declare retention (plus optional downsampling). Elasticsearch handles rollover and deletion itself.
PUT _index_template/metrics-app-template
{
"index_patterns": ["metrics-app-*"],
"data_stream": {},
"template": { "lifecycle": { "data_retention": "30d" } }
}
PUT _data_stream/metrics-app-prod/_lifecycle { "data_retention": "7d" }
GET _data_stream/metrics-app-prod/_lifecycle/explainIf both are configured, ILM wins (index.lifecycle.prefer_ilm defaults to true). Choose ILM when you need tiers (warm, cold, frozen), shrink, forcemerge or searchable snapshots. Choose DSL when retention is all that matters.
📖 Index lifecycle management · ILM phases and actions · Rollover · Create an ILM policy · Tutorial: time series with data streams · Tutorial: time series without data streams · Check ILM status · Data stream lifecycle
2.6 Define and use index aliases
An alias is a secondary name for one or more indices or data streams.
Analogy: a phone's contact name. You call "Mum", not the number. When Mum changes numbers, you edit the contact once and nobody else has to change anything. Apps query
products, and you swap which physical index sits behind it.
Core uses:
- Zero-downtime reindex: build
products-v2, then atomically move the alias. - Filtered views: an alias that only shows
region: EUdocuments. - Rollover write target:
is_write_index: true. - Search many indices under one name:
logs-all→logs-2026.09.*.
// Atomic swap. All actions succeed or none do.
POST _aliases
{
"actions": [
{ "remove": { "index": "products-v1", "alias": "products" } },
{ "add": { "index": "products-v2", "alias": "products", "is_write_index": true } }
]
}
// Filtered alias with routing
POST _aliases
{
"actions": [
{ "add": {
"index": "orders",
"alias": "orders-eu",
"filter": { "term": { "region": "EU" } },
"routing": "eu"
} }
]
}
// Remove an index entirely and point the alias elsewhere, atomically
POST _aliases
{ "actions": [
{ "remove_index": { "index": "products-old" } },
{ "add": { "index": "products-v3", "alias": "products" } } ] }
GET _alias/products GET products-v2/_alias GET _cat/aliases?vRules:
- An alias pointing to several indices is read-only unless exactly one of them has
is_write_index: true. - Aliases can point to data streams, but you can't mix data streams and indices in one alias.
- Filtered aliases apply the filter to searches, counts and delete-by-query run through the alias.
- Aliases can be defined in index templates (
template.aliases) so every new index joins automatically. - An alias name can't equal an existing index name.
📖 Aliases · Aliases API · Privileges for data streams and aliases
2.7 Exercises: Data Management
- Create component templates
ct-1shard(1 shard, 0 replicas) andct-app-mappings(@timestampdate,servicekeyword,msgtext). Create index templateapp-logsforapp-logs-*that creates a data stream, uses both components, and has priority 300. Index a document intoapp-logs-apiand confirm the backing index has 1 shard. - Add to (1) a dynamic template: new strings ending in
_idbecomekeyword; all other new strings becometextwith akeywordsub-field namedraw. Prove it with one document (remember to roll over). - Create ILM policy
app-policy: roll over at 1 GB primary shard size or 1 day; delete 3 days after rollover. Attach it via the template, roll over manually and check_ilm/explain. - Index
legacyhas 5 shards. Producelegacy-1with 1 shard without reindexing. - Indices
movies-v1andmovies-v2exist. Aliasmoviespoints to v1. Atomically repoint it to v2 and make v2 the write index. Then create filtered aliasmovies-recentshowing onlyyear >= 2020.
Solution notes
composed_of: ["ct-1shard","ct-app-mappings"],data_stream: {},priority: 300. Check withGET _data_stream/app-logs-api, thenGET .ds-app-logs-api-*/_settings?filter_path=**.number_of_shards.- Put the
*_idrule before the catch-all string rule. The catch-all uses"mapping": {"type":"text","fields":{"raw":{"type":"keyword"}}}. Existing backing indices don't change, soPOST app-logs-api/_rolloverbefore testing. - Hot:
rollover: {max_primary_shard_size: "1gb", max_age: "1d"}; delete:min_age: "3d". Addindex.lifecycle.name: app-policyto the template's settings. Existing backing indices needPUT .ds-app-logs-api-*/_settings, or a rollover. PUT legacy/_settings {"index.blocks.write": true, "index.routing.allocation.require._name": "es01"}, wait for relocation, thenPOST legacy/_shrink/legacy-1 {"settings":{"index.number_of_shards":1,"index.routing.allocation.require._name":null,"index.blocks.write":null}}.- One
_aliasescall withremove+add(is_write_index: true), thenaddwithfilter: {range: {year: {gte: 2020}}}.
3. Data Ingestion & Processing
Analogy for the whole domain: a restaurant's back office. Deliveries arrive by different routes (ingest methods). Each crate is checked against the inventory sheet that says what goes on which shelf (mapping and field types). Some produce is washed, chopped and labelled before storage (ingest pipelines / Streams). When the shelving layout changes, the stock is moved to new shelves (Reindex) or relabelled in place (Update By Query).
3.1 Select and configure appropriate ingest methods
| Method | Best for | Processing | Notes |
|---|---|---|---|
| Elastic Agent + Fleet (integrations) | Logs, metrics and security data from hosts and cloud services; centrally managed | Integration's own ingest pipelines + @custom hooks | One agent, many inputs; policies managed in Kibana. Writes to type-dataset-namespace data streams. |
| Standalone Elastic Agent | Same, without Fleet | Same | Configured by YAML |
| Beats (Filebeat, Metricbeat…) | Lightweight single-purpose shippers | Beat processors + ingest pipelines | Legacy but still supported |
| EDOT / OpenTelemetry Collector, OTLP endpoint | OpenTelemetry-native logs, metrics and traces | Collector processors, Streams | Elastic's direction of travel for observability |
| Logstash | Heavy transformation, many inputs and outputs, buffering (persistent queue), routing to several clusters | Logstash filters (grok, mutate, translate…) | Extra infrastructure to run |
Language clients + _bulk API | Application data, product catalogues, custom ETL | Optional pipeline= parameter | Most control; you manage batching and retries |
| Kibana file upload | One-off CSV, NDJSON or log files | Auto-generated pipeline and mapping | Size-limited; good for quick labs |
| Connectors / crawler | Content sources (SharePoint, databases, websites) | Connector sync rules + pipelines | Search use cases |
| Streams (Kibana) | Logs, especially unstructured ones, managed from one UI | Streams processors (see 3.6) | 9.x feature |
_reindex from remote | Migrating data between clusters | Optional pipeline | Needs reindex.remote.whitelist |
How to choose (exam-style reasoning):
- "Collect system logs from 200 hosts, centrally managed" → Elastic Agent with Fleet and the System integration.
- "Enrich events from a Kafka topic and send them to two clusters" → Logstash.
- "Index product data from our application" → client +
_bulk, with an ingest pipeline for light cleanup. - "Parse free-text logs quickly without writing grok by hand" → Streams.
- "Transform data already in Elasticsearch" → ingest pipeline +
_reindexor_update_by_query.
The Bulk API is the workhorse behind almost every method:
POST _bulk?pipeline=clean-products
{ "index": { "_index": "products", "_id": "1" } }
{ "sku": "A-1", "name": "Café crème", "price": 3.5 }
{ "create": { "_index": "logs-app-prod" } }
{ "@timestamp": "2026-09-28T10:00:00Z", "message": "hello" }
{ "update": { "_index": "products", "_id": "1" } }
{ "doc": { "price": 3.9 } }
{ "delete": { "_index": "products", "_id": "2" } }The format is NDJSON: every line ends with a newline, including the last one. The response has a top-level errors: true/false, and each item carries its own status, so always check errors. Aim for batches of about 5–15 MB.
📖 Ingest overview · Ingest tools overview · Ingest architectures · Bulk API · Upload data files · OTLP endpoint · Tune for indexing speed
3.2 Define a mapping that satisfies a given set of requirements
A mapping is the schema: which fields exist, their types, and how each is indexed and analysed.
Analogy: a customs form. Each box says what the item is ("date", "exact code", "free text"). Customs (Lucene) stores it in the right way. A date written in the "free text" box can't be range-searched later, and you can't change the form once the parcel is shipped.
Explicit vs dynamic. Dynamic mapping guesses, for example "2026-09-28" becomes date and "hello" becomes text + .keyword. The exam almost always wants explicit mappings.
PUT articles
{
"mappings": {
"dynamic": "strict",
"_source": { "excludes": ["internal_notes"] },
"properties": {
"title": { "type": "text", "analyzer": "english",
"fields": { "raw": { "type": "keyword" },
"suggest": { "type": "search_as_you_type" } } },
"author": { "type": "keyword", "normalizer": "lowercase" },
"body": { "type": "text", "copy_to": "all_text" },
"summary": { "type": "text", "copy_to": "all_text" },
"all_text": { "type": "text" },
"views": { "type": "integer", "null_value": 0 },
"rating": { "type": "half_float", "ignore_malformed": true },
"published":{ "type": "date", "format": "strict_date_optional_time||epoch_millis" },
"status": { "type": "keyword", "doc_values": true, "index": true },
"raw_html": { "type": "text", "index": false },
"comments": { "type": "nested",
"properties": { "user": { "type": "keyword" },
"text": { "type": "text" },
"stars": { "type": "byte" } } },
"labels": { "type": "flattened" },
"internal_notes": { "type": "text" }
}
}
}Mapping parameters you must know:
| Parameter | Meaning | Typical task wording |
|---|---|---|
index: false | Not searchable (still in _source; aggregatable if it has doc values) | "stored but not searchable" |
doc_values: false | Can't sort or aggregate; saves disk | "never used for aggregations" |
enabled: false (object only) | Whole object kept in _source, not parsed at all | "ignore the contents of payload" |
store: true | Stored separately from _source (retrieve with stored_fields) | rare |
copy_to | Copies the value into another field (a custom "all fields" field) | "search title and body as one field" |
fields | Multi-fields: same value indexed several ways | "full-text and exact-match / sortable" |
analyzer / search_analyzer | Index-time vs query-time analysis | "autocomplete", "synonyms at search time" |
normalizer | Analyzer for keyword (lowercase, asciifolding) | "case-insensitive exact match" |
ignore_above | Don't index keyword strings longer than N | "skip values longer than 256" |
null_value | Index a replacement for explicit null | "treat null as 0 / NONE" |
ignore_malformed | Skip bad values instead of rejecting the document | "don't fail on bad numbers" |
coerce | Convert "5" to 5 (default true) | "reject strings in numeric fields" → coerce: false |
format | Date formats (yyyy-MM-dd, epoch_second…) | "dates like 28/09/2026" → dd/MM/yyyy |
dynamic (per object) | true/false/strict/runtime at any level | "strict except in meta" |
What you can and can't change on an existing index:
✅ Allowed via PUT <index>/_mapping | ❌ Needs a new index + reindex |
|---|---|
| Add new fields | Change a field's type |
Add a multi-field to an existing field, then run _update_by_query to populate it | Change a field's analyzer |
Change ignore_above, search_analyzer (with care), mapping _meta | Change index, doc_values or nested vs object |
| Add runtime fields | Rename a field (use a field alias or reindex) |
PUT articles/_mapping
{ "properties": { "author": { "type": "keyword", "fields": { "text": { "type": "text" } } } } }
POST articles/_update_by_query?conflicts=proceed // populates author.text for existing docsText analysis: custom analyzers
Analogy: a kitchen processing line. Character filters wash the vegetables (strip HTML, map characters). The tokenizer chops them into pieces (words). Token filters season each piece (lowercase, remove stop words, stem, add synonyms). The same line must be used for indexing and searching, or the pieces won't match.
PUT recipes
{
"settings": {
"analysis": {
"char_filter": {
"amp_to_and": { "type": "mapping", "mappings": ["& => and"] }
},
"filter": {
"my_stop": { "type": "stop", "stopwords": "_english_" },
"my_syn": { "type": "synonym_graph", "synonyms": ["courgette, zucchini", "aubergine, eggplant"] },
"edge_3_10":{ "type": "edge_ngram", "min_gram": 3, "max_gram": 10 }
},
"analyzer": {
"recipe_text": {
"type": "custom",
"char_filter": ["html_strip", "amp_to_and"],
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "my_stop", "porter_stem"]
},
"recipe_search": {
"type": "custom", "tokenizer": "standard",
"filter": ["lowercase", "asciifolding", "my_syn", "porter_stem"]
},
"autocomplete": {
"type": "custom", "tokenizer": "standard",
"filter": ["lowercase", "edge_3_10"]
}
},
"normalizer": {
"lower_fold": { "type": "custom", "filter": ["lowercase", "asciifolding"] }
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text", "analyzer": "recipe_text", "search_analyzer": "recipe_search",
"fields": {
"ac": { "type": "text", "analyzer": "autocomplete", "search_analyzer": "standard" },
"raw": { "type": "keyword", "normalizer": "lower_fold" }
}
}
}
}
}
POST recipes/_analyze
{ "analyzer": "recipe_text", "text": "<b>Courgette & Crème</b> brûlée recipes" }
POST recipes/_analyze
{ "field": "name.ac", "text": "Courgette" }Analysis facts to remember:
- The default analyzer is
standard(standard tokenizer + lowercase). Built-in analyzers includesimple,whitespace,keyword,stop,patternand language analyzers (english,french…). - Synonyms at search time use
synonym_graphin thesearch_analyzer. To update synonyms without reindexing, use the Synonyms API (PUT _synonyms/my-set) with"synonyms_set": "my-set"and"updateable": trueon a search-time filter. - Edge n-grams go at index time only; search with the
standardanalyzer, otherwise the query is n-grammed too. keywordfields are not analysed. Use a normalizer for case-insensitivity.- Always test with
_analyzebefore creating the real index.
📖 Mapping · Explicit mapping · Update mapping examples · Mapping parameters · Text analysis · Anatomy of an analyzer · Create a custom analyzer · Test an analyzer · Index and search analysis · Synonyms
3.3 Define and use appropriate field data types for indexing, search and aggregation
Analogy. Pick the container for what you'll do with the contents. Soup goes in a bowl (text, for searching within), coins in a counting tray (numeric, for maths and ranges), keys on labelled hooks (keyword, for exact lookup, sorting and grouping).
| Requirement | Type | Why |
|---|---|---|
| Full-text search (words, relevance) | text | Analysed into tokens |
| Exact match, sorting, terms aggregation (IDs, status, tags, emails) | keyword | Stored as-is, with doc values |
| Both of the above | text + fields.keyword (multi-field) | Default for dynamic strings |
| Same value in every document of an index | constant_keyword | Nearly free, great for data streams |
Grep-like *foo* on huge high-cardinality strings | wildcard | Fast wildcard and regexp |
| Log messages, scoring not needed | match_only_text | Much smaller than text |
| Integers | byte / short / integer / long / unsigned_long | Smallest type that fits |
| Decimals | float / half_float / double / scaled_float (prices: scaling_factor: 100) | |
| Dates and times | date (ms) / date_nanos | Range queries, date histograms |
| True/false | boolean | |
| IPv4/IPv6 and CIDR queries | ip | term: {"ip": "10.0.0.0/8"} works |
| Lat/lon, distance, bounding box | geo_point | Geo queries and aggs |
| Polygons, shapes | geo_shape | |
| Numeric or date intervals | integer_range, date_range, ip_range… | "Is 42 inside the doc's range?" |
| Arrays of objects queried together | nested | Keeps each object's fields bound together |
| Simple JSON objects | object (default) | Flattened into dotted fields |
| Unknown or unbounded keys (labels, tags maps) | flattened | One field, avoids mapping explosion |
| Parent/child in the same index | join | Costly; use sparingly |
| Semantic search (managed embeddings) | semantic_text | Inference handled for you (see 4.5) |
| Your own embeddings | dense_vector (kNN) / sparse_vector | |
| Autocomplete as you type | search_as_you_type / completion | |
Software versions (1.10 > 1.9) | version | Correct semantic ordering |
| Pre-aggregated metrics | histogram, aggregate_metric_double | |
| Another name for an existing field | alias | "Rename" without reindex (for search) |
Arrays. There's no array type. Any field can hold several values of the same type.
object vs nested: the most tested pitfall
// Document:
{ "comments": [ { "user": "alice", "stars": 5 }, { "user": "bob", "stars": 1 } ] }With object, Lucene stores comments.user: [alice, bob] and comments.stars: [5, 1], so the pairing is lost. A search for "alice AND stars 1" wrongly matches.
With nested, each comment is a hidden sub-document. You must use the nested query and the nested aggregation:
GET articles/_search
{
"query": {
"nested": {
"path": "comments",
"query": { "bool": { "must": [
{ "term": { "comments.user": "alice" } },
{ "range": { "comments.stars": { "gte": 4 } } } ] } },
"inner_hits": {}
}
}
}The limits are index.mapping.nested_fields.limit (50) and index.mapping.nested_objects.limit (10,000 per document). Other mapping explosion guards: index.mapping.total_fields.limit (1000) and index.mapping.depth.limit (20).
Runtime fields: schema on read
A field computed at query time from _source or other fields. There's nothing to reindex, but it's slower.
PUT articles/_mapping
{
"runtime": {
"day_of_week": {
"type": "keyword",
"script": { "source": "emit(doc['published'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ENGLISH))" }
}
}
}
// Or only for one search:
GET articles/_search
{
"runtime_mappings": {
"views_k": { "type": "double", "script": "emit(doc['views'].value / 1000.0)" }
},
"fields": ["views_k"],
"aggs": { "by_day": { "terms": { "field": "day_of_week" } } }
}📖 Field data types · keyword · text · Numeric · date · nested · flattened · Multi-fields · Runtime fields · Mapping limit settings
3.4 Use the Reindex API and Update By Query API
Analogy. Reindex is moving house: you pack every item from the old flat and unpack it into a new one that has a different layout (new mapping, new analyzer, new shard count). Update By Query is redecorating in place: same flat, you walk from room to room and change what matches.
Reindex
POST _reindex?wait_for_completion=false&slices=auto
{
"source": {
"index": "articles",
"query": { "range": { "published": { "gte": "2025-01-01" } } },
"_source": ["title", "author", "published", "views"],
"size": 1000
},
"dest": {
"index": "articles-v2",
"pipeline": "normalize-articles",
"op_type": "create"
},
"script": {
"lang": "painless",
"source": "ctx._source.views = ctx._source.views == null ? 0 : ctx._source.views; ctx._source.remove('internal_notes')"
},
"conflicts": "proceed",
"max_docs": 100000
}| Option | Use |
|---|---|
source.query | Copy only matching documents |
source._source | Copy only some fields |
dest.pipeline | Transform with an ingest pipeline on the way in |
script | Painless on ctx._source (also ctx._id, ctx._index; set ctx.op = "noop" to skip, or "delete") |
dest.op_type: create | Only create missing documents; required when the destination is a data stream |
dest.version_type: external | Keep newer versions already in the destination |
conflicts: proceed | Count version conflicts instead of aborting |
max_docs | Limit the copy |
slices=auto | Parallelise |
requests_per_second | Throttle (change later with _reindex/<task>/_rethrottle) |
wait_for_completion=false | Returns a task ID. Poll GET _tasks/<id>, cancel with POST _tasks/<id>/_cancel. |
source.remote | Reindex from another cluster. The destination node needs reindex.remote.whitelist: "host:9200" in elasticsearch.yml. |
The destination is NOT created from the source's mapping. Create articles-v2 (or a template for it) first, otherwise dynamic mapping guesses the types.
Zero-downtime migration recipe: create v2 with the new mapping → _reindex → verify _count → atomic alias swap (2.6) → delete v1 if asked.
Update By Query
POST articles/_update_by_query?conflicts=proceed&wait_for_completion=false
{
"query": { "term": { "status": "draft" } },
"script": {
"lang": "painless",
"source": "ctx._source.status = params.s; ctx._source.tags = ctx._source.tags ?: []; ctx._source.tags.add('migrated')",
"params": { "s": "archived" }
}
}
// Re-run all docs through a pipeline (e.g. to populate a new field)
POST articles/_update_by_query?pipeline=add-reading-time
{ "query": { "bool": { "must_not": { "exists": { "field": "reading_time" } } } } }
// Pick up a newly added multi-field for all docs
POST articles/_update_by_query?refreshPOST <index>/_update/<id>withdoc(partial),script,upsertordoc_as_upsertPOST <index>/_delete_by_query?refreshto make results immediately visible
Painless essentials: ctx._source.field, params.x, ctx._source.remove('f'), ctx._source.containsKey('f'), null-safe ?. and elvis ?:, ctx.op = 'noop' | 'delete'. In ingest pipelines, fields are on ctx.field directly (no _source).
📖 Reindex API · Update by query API · Delete by query API · Update API · Migrate data using reindex · Painless · Update documents with scripts · Tasks API
3.5 Define and use an ingest pipeline that satisfies a given set of requirements
An ingest pipeline is an ordered list of processors that runs on ingest nodes before a document is indexed.
Analogy: an airport baggage belt. Each station (processor) scans, relabels or reroutes the bag. If a station jams, the bag is either sent to a lost-luggage office (
on_failure) or the whole belt stops (default: the document is rejected).
PUT _ingest/pipeline/web-logs
{
"description": "Parse Apache-style access logs",
"processors": [
{ "grok": {
"field": "message",
"patterns": ["%{IPORHOST:client.ip} %{USER:ident} %{USER:user.name} \\[%{HTTPDATE:ts}\\] \"%{WORD:http.method} %{DATA:url.original} HTTP/%{NUMBER:http.version}\" %{NUMBER:http.status:int} (?:%{NUMBER:bytes:long}|-)"]
} },
{ "date": { "field": "ts", "formats": ["dd/MMM/yyyy:HH:mm:ss Z"], "target_field": "@timestamp" } },
{ "remove": { "field": ["ts", "ident"], "ignore_missing": true } },
{ "set": { "field": "event.ingested", "value": "{{{_ingest.timestamp}}}" } },
{ "lowercase": { "field": "http.method" } },
{ "geoip": { "field": "client.ip", "target_field": "client.geo", "ignore_failure": true } },
{ "user_agent": { "field": "user_agent.original", "ignore_missing": true } },
{ "set": {
"if": "ctx.http?.status != null && ctx.http.status >= 500",
"field": "event.outcome", "value": "failure" } },
{ "script": {
"lang": "painless",
"source": "ctx.bytes_kb = ctx.bytes == null ? 0 : ctx.bytes / 1024.0"
} },
{ "drop": { "if": "ctx.url?.original == '/healthz'" } }
],
"on_failure": [
{ "set": { "field": "error.message", "value": "{{ _ingest.on_failure_message }}" } },
{ "set": { "field": "_index", "value": "failed-{{ _index }}" } }
]
}Frequently used processors:
| Processor | Purpose |
|---|---|
set, remove, rename, append, convert, lowercase/uppercase, trim, split, join, gsub | Field manipulation |
grok, dissect, kv, csv, json | Parse text into fields (dissect is faster, for fixed delimiters; grok uses regex) |
date, date_index_name | Parse dates; route to date-based index names |
geoip, user_agent, uri_parts, community_id, network_direction, registered_domain | Enrichment |
enrich | Lookup-join against another index |
inference | Run an ML model (e.g. embeddings) |
script | Painless for anything else |
pipeline | Call another pipeline (modular pipelines) |
reroute | Send a document to another data stream (dataset or namespace) |
foreach | Apply a processor to each array element |
drop, fail | Discard a document; raise a custom error |
fingerprint, redact | Deduplication IDs; mask PII |
Common processor options: if (Painless condition, null-safe with ?.), ignore_failure, ignore_missing, on_failure (per processor), tag, description.
Error handling, in order of scope:
- Processor-level
ignore_failure: trueskips silently. - Processor-level
on_failure: [...]handles the error locally. - Pipeline-level
on_failureis the catch-all. Metadata:_ingest.on_failure_message,_ingest.on_failure_processor_type,_ingest.on_failure_processor_tag. - Nothing catches it → the document is rejected. For data streams with a failure store, it goes to
::failuresinstead.
Testing, which you should always do:
POST _ingest/pipeline/web-logs/_simulate?verbose=true
{ "docs": [ { "_source": { "message": "1.2.3.4 - - [28/Sep/2026:10:00:00 +0000] \"GET /a HTTP/1.1\" 503 12" } } ] }
// Simulate with the pipelines that WOULD run for an index (defaults, final, templates)
POST _ingest/_simulate
{ "docs": [ { "_index": "web-2026", "_source": { "message": "…" } } ] }Ways to apply a pipeline:
| How | Scope |
|---|---|
?pipeline=name on index, bulk, reindex (dest.pipeline) or update-by-query | One request |
index.default_pipeline | Default for an index; overridden by ?pipeline= |
index.final_pipeline | Always runs last, even if a request pipeline was given |
| Index template setting | Every new index or backing index |
pipeline processor | Nested pipelines |
Integrations' @custom pipelines (e.g. logs-nginx.access@custom) | Customise Fleet integrations safely |
PUT web-2026/_settings { "index.default_pipeline": "web-logs" }Enrich processor: a lookup table at ingest time
PUT users-ref/_doc/1 { "email": "a@x.com", "dept": "finance", "manager": "zoe" }
PUT _enrich/policy/users-policy
{ "match": { "indices": "users-ref", "match_field": "email", "enrich_fields": ["dept", "manager"] } }
POST _enrich/policy/users-policy/_execute // builds the .enrich-* index; re-run after the source changes
PUT _ingest/pipeline/add-user-info
{ "processors": [ { "enrich": {
"policy_name": "users-policy", "field": "user.email",
"target_field": "user.info", "max_matches": 1, "ignore_missing": true } } ] }Policy types: match (exact term), range (value inside a range), geo_match (point in shape). An enrich policy can't be updated. Delete it and recreate it; you have to remove the processors that use it first.
📖 Ingest pipelines · Processor reference · Error handling · Example: parse logs · Data enrichment · Set up an enrich processor · Simulate pipeline API · Grok debugger · Painless in ingest
3.6 Ingest and process data using Streams
Streams is Kibana's UI and API layer for managing log data end to end: getting data in, parsing it, routing it, mapping fields, setting retention and watching data quality. Behind the scenes it creates and manages ordinary Elasticsearch objects (data streams, index templates, ingest pipelines, lifecycle settings).
Analogy: a smart sorting office with a control panel. Instead of wiring each conveyor belt (template, pipeline, ILM) yourself, you use one panel: "parse these letters like this, send the ones from Paris to that shelf, keep them for 30 days". The panel builds the belts for you. Don't rewire the belts by hand behind its back.
Two kinds of streams (9.2–9.3)
| Classic streams | Wired streams (tech preview) | |
|---|---|---|
| What | Your existing data streams (e.g. logs-nginx.access-default), adopted in place | A managed hierarchy rooted at a special endpoint |
| Migration | None; data keeps flowing as before | Send data to the root stream: logs in 9.2–9.3 (logs.otel / logs.ecs from 9.4) |
| Processing | ✓ (added without touching the underlying index template) | ✓ |
| Retention | ✓ (inherit from template, custom period, or ILM policy) | ✓ (inherit from parent, custom, or ILM) |
| Partitioning (routing into child streams) | ✗ | ✓, e.g. logs → logs.nginx → logs.nginx.errors |
| Field mapping (Schema) | ✓ (add mappings) | ✓ (children inherit from the parent) |
| Inheritance | n/a | Children inherit mappings, lifecycle and processing from their parent |
A third kind, query streams (virtual, defined by an ES|QL query), appears in newer versions.
Typical workflow in the Streams UI
- Get data in. For wired streams, turn on wired streams in Streams settings, then point Elastic Agent, Filebeat, the OTel Collector or Logstash at the
logsindex/stream. No template setup is needed. For classic streams, just open an existing data stream in Streams. - Partition (wired only). Define routing conditions such as
service.name == "nginx"→logs.nginx. Partition by logical groups (team, technology, environment), not by high-cardinality fields. Aim for tens of partitions, not hundreds. Each child is a real data stream. - Process. Build a processing pipeline in the Processing tab from processors that mirror ingest processors (grok, dissect, date, set, rename, convert, remove, append, drop, math, json extract, redact, user agent, URI parts, enrich…). Add conditions to steps. The Data preview simulates results on sample documents before you save. With a generative-AI connector configured, Suggest a pipeline can generate grok/dissect rules for you.
- Map fields (Schema tab). Promote extracted fields to proper mapped types (keyword, date, long…).
- Configure retention (Data lifecycle tab). Choose inherit, a custom retention period (data stream lifecycle), or an existing ILM policy.
- Monitor data quality (Data quality tab). Degraded documents are ones with ignored or malformed fields (
_ignored); failed documents are kept in the failure store so you can fix the processor using the real failing documents. - Advanced tab. Inspect the managed index templates, component templates and pipelines Streams created.
Rules to remember:
- Streams-managed components are managed. Change them through the Streams UI or API, not with raw Elasticsearch APIs.
- Streams is a Kibana feature: its API is under
kbn:/api/streams/...(Kibana API), not the Elasticsearch REST API. In Console, prefix withkbn:. - Required privileges to manage streams include cluster
manage_index_templates,manage_ingest_pipelines,manage_pipelineandread_pipeline, plus data-stream-levelread,write,create,manage,monitor,manage_data_stream_lifecycle,read_failure_store,manage_failure_storeandmanage_ilm. - Don't reroute or migrate existing data streams into the wired root; classic streams work in place.
- Because Streams is new and moving fast, exam tasks are most likely to use the UI flow: add a processor, verify in preview, set retention, check the failure store. Practise it on your lab with sample logs.
# Example Kibana API calls from Console (verify paths against the 9.3 Streams API reference)
GET kbn:/api/streams
GET kbn:/api/streams/logs📖 Streams overview · Get data into Streams · Wired streams · Partition (organise) data · Process documents · Configure data lifecycle · Map fields · Manage data quality · Advanced settings · Streamlang processors · Streams Kibana API
3.7 Exercises: Ingestion & Processing
- Create index
shopwith:name(full-text with the English analyzer, plus a case-insensitive exact sub-fieldname.exact),sku(exact),price(two decimals, cheap to store),tags(exact, not aggregatable),specs(arbitrary key/values, no mapping explosion),reviews(array of{user, stars}that must be queried together). Unknown fields must be rejected. - Create pipeline
shop-cleanthat trims and upper-casessku, convertspricefrom string to float, setsingested_at, drops docs whosestatusistest, and on any failure writes the error toingest_errorinstead of rejecting. Simulate it with 2 docs. - Reindex
shopintoshop-v2(identical mapping exceptnameuses a custom analyzer withasciifolding) throughshop-clean, only for docs withprice > 0. Then point aliasshop-liveatshop-v2. - Add a
name.acautocomplete multi-field toshop-v2and populate it for existing docs without reindexing. - Create an enrich policy from index
suppliers(sku→supplier,country) and add it toshop-clean. - In Streams, open the
logs-generic-defaultclassic stream (index some raw log lines first), add a dissect processor that extractslevelandmsg, maplevelas keyword, set retention to 14 days, and inspect the failure store.
Solution notes
dynamic: strict;name: text/english+fields.exact: keywordwith alowercasenormalizer;sku: keyword;price: scaled_float, scaling_factor: 100;tags: keyword, doc_values: false;specs: flattened;reviews: nested.- Processors:
trim+uppercaseonsku;convert {type: float}onprice;set ingested_at = {{{_ingest.timestamp}}};dropwithif: "ctx.status == 'test'". Pipeline-levelon_failuresetsingest_error = {{ _ingest.on_failure_message }}. Verify with_simulate?verbose. - Create
shop-v2first._reindexwithsource.query: range price gt 0anddest.pipeline: shop-clean. Compare_count, then use_aliasesadd. PUT shop-v2/_mappingaddingfields.acwith an edge-ngram analyzer. The analyzer must already exist in the index settings; if not, close → add analysis → open. ThenPOST shop-v2/_update_by_query.PUT _enrich/policy/...(match),_execute, then add anenrichprocessor to the pipeline. Re-PUTthe whole pipeline; the PUT replaces it.- The UI flow covered in 3.6. Check the result on the Advanced tab and in
GET logs-generic-default/_mapping.
4. Searching Data
Analogy for the whole domain: a library. Full-text queries are asking the librarian "books about sailing". She interprets the words and ranks the answers by relevance. Term-level queries are the card catalogue: exact codes, no interpretation. Bool combines requests: "must be about sailing, must be in French, should be recent, must not be a children's book". Aggregations are the statistics desk: "how many books per genre, average page count". ES|QL is a spreadsheet-style pipeline over the same shelves. Semantic search is a librarian who understands meaning ("boat trips" finds "sailing"). Cross-cluster search asks sister libraries in other cities at the same time.
4.1 Search for terms and/or phrases in one or more fields
Full-text queries (analysed: the query text goes through the field's analyzer)
// Single field, any word (OR by default)
GET articles/_search
{ "query": { "match": { "title": "elastic search guide" } } }
// All words required, or a minimum
{ "query": { "match": { "title": { "query": "elastic search guide", "operator": "and" } } } }
{ "query": { "match": { "title": { "query": "elastic search guide", "minimum_should_match": "2" } } } }
// Typo tolerance
{ "query": { "match": { "title": { "query": "elastik", "fuzziness": "AUTO" } } } }
// Exact phrase, with slop (words may be up to N positions apart)
{ "query": { "match_phrase": { "body": { "query": "shard allocation", "slop": 2 } } } }
// Phrase prefix, "search as you type" style
{ "query": { "match_phrase_prefix": { "title": "quick bro" } } }
// Several fields, with boosting
{ "query": { "multi_match": {
"query": "shard sizing",
"fields": ["title^3", "summary^2", "body"],
"type": "best_fields",
"tie_breaker": 0.3 } } }multi_match types:
| Type | Behaviour | Use when |
|---|---|---|
best_fields (default) | Score of the best-matching field (+ tie_breaker × others) | Words expected together in one field |
most_fields | Sum of all field scores | Same text indexed several ways (multi-fields) |
cross_fields | Treats the fields as one big field (term-centric) | "first_name + last_name" style data |
phrase / phrase_prefix | match_phrase on each field | Phrases across fields |
bool_prefix | For search_as_you_type fields | Autocomplete |
Also know combined_fields (like cross_fields for fields sharing an analyzer), query_string (Lucene syntax with AND/OR/NOT, wildcards and field names; throws on bad syntax) and simple_query_string (forgiving, safe for user input: + | - " * ( )).
Term-level queries (NOT analysed: exact values on keyword, numeric, date, ip, boolean)
{ "query": { "term": { "status": "published" } } }
{ "query": { "term": { "status": { "value": "Published", "case_insensitive": true } } } }
{ "query": { "terms": { "tags": ["elasticsearch", "lucene"] } } }
{ "query": { "range": { "published": { "gte": "now-30d/d", "lt": "now/d" } } } }
{ "query": { "range": { "price": { "gt": 10, "lte": 100 } } } }
{ "query": { "exists": { "field": "author" } } }
{ "query": { "prefix": { "sku": "ABC-" } } }
{ "query": { "wildcard": { "sku": { "value": "A*-9?" } } } }
{ "query": { "regexp": { "sku": "A[0-9]{3}" } } }
{ "query": { "fuzzy": { "author": { "value": "jonh", "fuzziness": 1 } } } }
{ "query": { "ids": { "values": ["1", "7"] } } }
{ "query": { "terms_set": { "skills": { "terms": ["java","go","rust"], "minimum_should_match_field": "required" } } } }The number-one trap: term on a text field. "Published" was indexed as published, so term: "Published" finds nothing. Use match on text, or term on the .keyword sub-field.
Date math: now-1d/d (rounded to the day), 2026-09-01||+1M, plus a time_zone parameter on range queries.
Shaping results: pagination, sorting, source filtering, highlighting
GET articles/_search
{
"from": 0, "size": 10,
"track_total_hits": true,
"_source": ["title", "published"],
"fields": ["title", "published", "day_of_week"],
"sort": [ { "published": "desc" }, { "title.raw": "asc" }, "_score" ],
"highlight": {
"pre_tags": ["<mark>"], "post_tags": ["</mark>"],
"fields": { "body": { "fragment_size": 150, "number_of_fragments": 3 } }
},
"query": { "match": { "body": "shard allocation" } }
}from + sizeis capped byindex.max_result_window(10,000). For deep paging usesearch_afterwith a point in time (POST <index>/_pit?keep_alive=1m). Scroll is legacy for this purpose.- You can't sort on
textfields. Sort on akeywordsub-field, a number or a date. track_total_hitsdefaults to accurate counting up to 10,000; settruefor an exact count.explain: trueshows the score calculation.profile: trueshows per-shard timings (or use Kibana's Search Profiler).
📖 Full-text queries · match · match_phrase · multi_match · query_string · Term-level queries · term · range · Paginate results · Sort results · Highlighting · The _search API
4.2 Boolean combinations of queries and filters
GET articles/_search
{
"query": {
"bool": {
"must": [
{ "match": { "body": "elasticsearch" } }
],
"should": [
{ "match_phrase": { "title": { "query": "certified engineer", "boost": 2 } } },
{ "term": { "tags": "exam" } }
],
"minimum_should_match": 1,
"filter": [
{ "term": { "status": "published" } },
{ "range": { "published": { "gte": "2025-01-01" } } }
],
"must_not": [
{ "term": { "author": "spam-bot" } }
]
}
}
}| Clause | Must match? | Affects score? | Cached? |
|---|---|---|---|
must | Yes | Yes | No |
filter | Yes | No (filter context) | Yes |
should | See below | Yes (adds to the score) | No |
must_not | Must not match | No (filter context) | Yes |
should semantics (subtle, and heavily tested):
- If the bool has no
mustorfilter, at least oneshouldmust match (minimum_should_matchdefaults to 1). - If there is a
mustorfilter,shouldclauses are optional and only boost the score, unless you setminimum_should_match.
Query vs filter context rule of thumb: "how well does it match?" (words, relevance) goes in must/should. "Does it match, yes or no?" (status, dates, ranges, IDs) goes in filter/must_not. Filters are faster and cached.
Other compound queries:
constant_score: wrap a filter and give every hit the same score.boosting:positivequery, with anegativequery that demotes (not excludes) matches.dis_max: best of several queries, plustie_breaker.function_score/script_score: modify scores by recency, popularity (field_value_factor), decay functions or scripts.- Named queries (
"_name": "q1") report which clauses matched each hit.
Nesting example: "(title contains X OR summary contains X) AND year ≥ 2020 AND NOT draft":
{ "query": { "bool": {
"must": [ { "bool": { "should": [
{ "match": { "title": "X" } }, { "match": { "summary": "X" } } ] } } ],
"filter": [ { "range": { "year": { "gte": 2020 } } } ],
"must_not": [ { "term": { "status": "draft" } } ] } } }post_filter filters the hits but not the aggregations. Use it for faceted navigation, where facet counts shouldn't shrink when a facet is selected.
📖 Query and filter context · bool query · Compound queries · function_score · Filter search results (post_filter)
4.3 Write and execute metric and bucket aggregations
Analogy: sorting sweets. Bucket aggregations tip the sweets into bowls by colour, by size range, or by the day they were bought. Metric aggregations count, weigh or average what's in each bowl. Pipeline aggregations compare the bowls ("which bowl is heaviest?", "running total"). Bowls can hold smaller bowls (sub-aggregations).
GET kibana_sample_data_ecommerce/_search
{
"size": 0,
"query": { "range": { "order_date": { "gte": "now-30d/d" } } },
"aggs": {
"per_day": {
"date_histogram": {
"field": "order_date",
"calendar_interval": "day",
"time_zone": "Europe/Paris",
"min_doc_count": 0,
"extended_bounds": { "min": "now-30d/d", "max": "now/d" }
},
"aggs": {
"revenue": { "sum": { "field": "taxful_total_price" } },
"unique_buyers": { "cardinality": { "field": "customer_id" } }
}
},
"top_categories": {
"terms": { "field": "category.keyword", "size": 5, "order": { "revenue": "desc" } },
"aggs": {
"revenue": { "sum": { "field": "taxful_total_price" } },
"price_stats": { "stats": { "field": "taxful_total_price" } },
"p95": { "percentiles": { "field": "taxful_total_price", "percents": [50, 95, 99] } },
"best_order": { "top_hits": { "size": 1, "sort": [{ "taxful_total_price": "desc" }],
"_source": ["order_id", "customer_full_name"] } }
}
},
"price_bands": {
"range": { "field": "taxful_total_price",
"ranges": [ { "to": 50 }, { "from": 50, "to": 150 }, { "from": 150 } ] }
},
"best_day": { "max_bucket": { "buckets_path": "per_day>revenue" } },
"avg_daily_revenue": { "avg_bucket": { "buckets_path": "per_day>revenue" } }
}
}Metric aggregations: avg, sum, min, max, value_count, stats, extended_stats (std deviation, variance), cardinality (approximate distinct count, HyperLogLog++, precision_threshold), percentiles, percentile_ranks, median_absolute_deviation, weighted_avg, top_hits, top_metrics, geo_bounds, geo_centroid, scripted_metric.
Bucket aggregations:
| Aggregation | Groups by | Key options |
|---|---|---|
terms | Distinct values (keyword, number, ip…) | size (default 10), order (_count, _key or a sub-agg), min_doc_count, missing, include/exclude, shard_size |
multi_terms | Combinations of several fields | terms: [{field}, {field}] |
rare_terms, significant_terms | Rare or unusually frequent terms | |
histogram | Numeric intervals | interval, min_doc_count, extended_bounds |
date_histogram | Time intervals | calendar_interval (minute, hour, day, week, month, quarter, year, or 1d, 1M) vs fixed_interval (30m, 7d; multiples allowed); time_zone, format, offset |
range / date_range / ip_range | Custom ranges (from inclusive, to exclusive) | keyed: true |
filter / filters | One or several query-defined buckets | other_bucket |
composite | Paginated combinations of sources | after key: iterate all buckets |
nested / reverse_nested | Enter or leave nested documents | path |
global | Ignores the query scope | |
missing | Documents without the field | |
geohash_grid, geotile_grid, auto_date_histogram, variable_width_histogram, sampler | Specialised |
Pipeline aggregations reference other aggregations by buckets_path (> for nesting, . for a metric's value, _count for doc count):
- Sibling (a new bucket next to the others):
avg_bucket,max_bucket,min_bucket,sum_bucket,stats_bucket,percentiles_bucket. - Parent (inside each histogram bucket):
derivative,cumulative_sum,moving_fn,bucket_script,bucket_selector(keeps only matching buckets, like HAVING),bucket_sort(sort or truncate buckets),serial_diff.
"aggs": {
"per_month": {
"date_histogram": { "field": "order_date", "calendar_interval": "month" },
"aggs": {
"revenue": { "sum": { "field": "taxful_total_price" } },
"cum_rev": { "cumulative_sum": { "buckets_path": "revenue" } },
"big_only": { "bucket_selector": {
"buckets_path": { "r": "revenue" }, "script": "params.r > 10000" } }
}
}
}Aggregation facts:
- Set
"size": 0when you only need aggregations. - You can't run
termson atextfield (it has no doc values). Use.keyword. termscounts are approximate across shards (doc_count_error_upper_bound). Increaseshard_sizefor accuracy. Usecompositeto list all buckets.- Aggregations run on the documents matched by
query. Useglobalto escape that scope, orpost_filterto filter hits only. - For
nestedfields, wrap sub-aggs in anestedaggregation.
📖 Aggregations · Aggregations tutorial (eCommerce) · Metric aggregations · Bucket aggregations · Pipeline aggregations · terms · date_histogram · composite
4.4 Write and execute ES|QL queries
ES|QL (Elasticsearch Query Language) is a piped language. Each command transforms a table and passes it to the next, like a Unix pipeline or a spreadsheet you keep refining.
Analogy: a kitchen assembly line.
FROMgrabs the ingredients,WHEREthrows away what you don't need,EVALchops new pieces,STATSweighs and groups,SORTarranges the plates,LIMITserves the first N.
POST _query?format=txt
{
"query": """
FROM kibana_sample_data_logs
| WHERE @timestamp >= NOW() - 7 days AND response.keyword != "200"
| EVAL kb = bytes / 1024.0, day = DATE_TRUNC(1 day, @timestamp)
| STATS errors = COUNT(*), avg_kb = AVG(kb), hosts = COUNT_DISTINCT(host.keyword) BY day, response.keyword
| SORT day DESC, errors DESC
| LIMIT 20
"""
}format can be json (default), txt, csv, tsv, yaml and more. For long queries use POST _query/async and poll with GET _query/async/<id>. In Kibana, use Discover → ES|QL mode.
Commands you must know:
| Type | Command | Example |
|---|---|---|
| Source | FROM | FROM logs-*, remote:logs-* METADATA _index, _id, _score |
| Source | ROW, SHOW INFO | ROW a = 1, b = "x" |
| Source | TS (time series, newer) | TS metrics-* | STATS … |
| Filter | WHERE | WHERE status IN ("500","503") AND bytes > 1000 |
| Compute | EVAL | EVAL mb = bytes / 1048576 |
| Aggregate | STATS … BY | STATS c = COUNT(*), p95 = PERCENTILE(latency, 95) BY service |
| Shape | KEEP, DROP, RENAME | KEEP @timestamp, host, message |
| Order | SORT, LIMIT | SORT c DESC NULLS LAST | LIMIT 10 |
| Parse | DISSECT, GROK | DISSECT message "%{ip} - %{user} [%{ts}]" |
| Enrich | ENRICH | ENRICH users-policy ON email WITH dept |
| Join | LOOKUP JOIN | LOOKUP JOIN hosts-lookup ON host.name (the right-hand index must use index.mode: lookup) |
| Arrays | MV_EXPAND | Turn a multi-value column into rows |
| Analytics | CHANGE_POINT, SAMPLE, FORK, RERANK, COMPLETION | Newer commands; know they exist |
Functions to know: aggregations COUNT, COUNT_DISTINCT, SUM, AVG, MIN, MAX, MEDIAN, PERCENTILE, VALUES, TOP; grouping BUCKET(@timestamp, 1 hour); date DATE_TRUNC, DATE_EXTRACT, DATE_DIFF, DATE_FORMAT, NOW(); string CONCAT, TO_LOWER, SUBSTRING, LENGTH, SPLIT, STARTS_WITH, LIKE, RLIKE; conditionals CASE(cond, a, cond2, b, default), COALESCE; conversion TO_INTEGER, TO_DATETIME, TO_IP, :: casts; multi-value MV_COUNT, MV_AVG; full-text MATCH(field, "text"), the : operator (message:"timeout"), QSTR("…"), KQL("…"), plus METADATA _score for relevance.
ES|QL facts:
- Default
LIMITis 1000 rows, with a maximum of 10,000 by default. Always add an explicitLIMIT. - Case matters for values, not for command names.
- Use parameters to avoid injection:
"query": "FROM x | WHERE a == ?v", "params": [{"v": 5}]. - Cross-cluster:
FROM cluster_a:logs-*, logs-*(requires remote clusters, see 5.2). - ES|QL doesn't return
_sourcedocuments like_search; it returns columns and rows. STATSwithoutBYreturns one row. Columns not inBYor not aggregated are dropped.
Query DSL → ES|QL translation practice:
| Query DSL | ES|QL |
|---|---|
bool.filter.term status: published | WHERE status == "published" |
range price gte 10 lt 100 | WHERE price >= 10 AND price < 100 |
terms aggregation on category | STATS c = COUNT(*) BY category | SORT c DESC | LIMIT 10 |
date_histogram 1d + sum | STATS s = SUM(x) BY d = BUCKET(@timestamp, 1 day) |
match body: "timeout" | WHERE MATCH(body, "timeout") or WHERE body:"timeout" |
📖 ES|QL overview · ES|QL reference · ES|QL source commands · ES|QL processing commands · ES|QL functions and operators · LOOKUP JOIN · ES|QL REST API · ES|QL for search · Using ES|QL in Discover
4.5 Implement semantic search
Semantic search matches meaning rather than exact words. Text is turned into embeddings (vectors) by an ML model, and queries are compared to documents in that vector space.
Analogy: a map of meaning. Each passage becomes a pin on a huge map where similar ideas sit close together. "How do I fix a flat tyre?" lands next to "puncture repair". Keyword search would miss that, because none of the words overlap.
Two kinds of embeddings:
| Sparse (e.g. ELSER, Elastic's learned sparse encoder) | Dense (e.g. E5, Jina, OpenAI, Cohere) | |
|---|---|---|
| Representation | Weighted tokens (expanded vocabulary) | Fixed-length float vector |
| Field type | sparse_vector | dense_vector |
| Query | sparse_vector query | knn (HNSW approximate nearest neighbour) |
| Strength | Great out-of-domain English, explainable | Multilingual, multimodal, many models |
The easy path: semantic_text (recommended and most exam-friendly)
semantic_text hides all the plumbing: chunking, calling the inference endpoint at ingest time, storing embeddings, and embedding the query at search time.
Step 1: an inference endpoint. There's a preconfigured default ELSER endpoint, so this step is optional. To create your own:
PUT _inference/sparse_embedding/my-elser
{
"service": "elasticsearch",
"service_settings": {
"model_id": ".elser_model_2",
"adaptive_allocations": { "enabled": true, "min_number_of_allocations": 0, "max_number_of_allocations": 4 },
"num_threads": 1
}
}
// Dense example with a built-in multilingual model
PUT _inference/text_embedding/my-e5
{ "service": "elasticsearch",
"service_settings": { "model_id": ".multilingual-e5-small", "num_allocations": 1, "num_threads": 1 } }
GET _inference/_all
POST _inference/sparse_embedding/my-elser { "input": "test sentence" }The cluster needs an ML node (or Elastic Inference Service on Cloud) and a license that allows ML models, so use the trial on your lab.
Step 2: a mapping with semantic_text:
PUT docs-semantic
{
"mappings": {
"properties": {
"title": { "type": "text" },
"content": { "type": "semantic_text", "inference_id": "my-elser" },
"body": { "type": "text", "copy_to": "content" }
}
}
}Omit inference_id to use the default endpoint. copy_to into a semantic_text field lets you keep a normal text field for BM25 alongside.
Step 3: index documents normally. Embeddings are generated automatically. Bulk-loading many documents is slow on small ML nodes, so be patient or use a small batch.
Step 4: query.
// Simplest: the match query works on semantic_text fields
GET docs-semantic/_search
{ "query": { "match": { "content": "how do I keep my cluster available when a zone fails?" } } }
// Dedicated semantic query
GET docs-semantic/_search
{ "query": { "semantic": { "field": "content", "query": "zone failure resilience" } },
"highlight": { "fields": { "content": { "type": "semantic", "number_of_fragments": 2 } } } }Hybrid search: BM25 + semantic, fused with RRF
GET docs-semantic/_search
{
"retriever": {
"rrf": {
"retrievers": [
{ "standard": { "query": { "match": { "title": "zone failure" } } } },
{ "standard": { "query": { "semantic": { "field": "content", "query": "zone failure" } } } }
],
"rank_window_size": 50,
"rank_constant": 20
}
}
}Retrievers are the composable search abstraction: standard, knn, rrf (Reciprocal Rank Fusion), linear (weighted score combination), text_similarity_reranker (semantic reranking), rule.
The manual path (know it exists)
dense_vectormapping (dims,similarity: cosine | dot_product | l2_norm | max_inner_product,index_optionssuch asint8_hnsw/bbq_hnswquantisation) +knnsearch (k,num_candidates,filter,query_vectororquery_vector_builder).- An
inferenceprocessor in an ingest pipeline to generate embeddings, or bring your own vectors. sparse_vectorfield +sparse_vectorquery withinference_id.
GET my-vectors/_search
{
"knn": {
"field": "embedding",
"query_vector_builder": { "text_embedding": { "model_id": "my-e5", "model_text": "zone failure" } },
"k": 10, "num_candidates": 100,
"filter": { "term": { "lang": "en" } }
}
}📖 Semantic search overview · Semantic search with semantic_text · semantic_text field · semantic query · Semantic search with the inference API · Inference API: default endpoints · ELSER · Hybrid search with semantic_text · Retrievers · kNN search · Dense vector · Sparse vector · Semantic reranking
4.6 Write and execute a query that searches across multiple clusters
Cross-cluster search (CCS) runs one request from the local cluster against indices on one or more remote clusters. Setting up the remote connection is covered in 5.2; here is the querying side.
Analogy: interlibrary loan. You ask your local branch; it forwards your request to the branches in Lyon and Berlin and hands you one merged answer.
// Syntax: <remote_alias>:<index_pattern>
GET cluster_b:logs-*/_search
{ "query": { "match": { "message": "timeout" } } }
// Local + several remotes in one request
GET logs-*,cluster_b:logs-*,cluster_c:logs-*/_search
{
"size": 20,
"query": { "bool": { "filter": [ { "range": { "@timestamp": { "gte": "now-1h" } } } ] } },
"aggs": { "by_cluster": { "terms": { "field": "_index" } } }
}
// All remotes: wildcard on the alias; exclude with a minus sign
GET *:logs-*,-cluster_c:*/_search
// ES|QL across clusters
POST _query?format=txt
{ "query": "FROM logs-*, cluster_b:logs-* METADATA _index | STATS c = COUNT(*) BY _index | LIMIT 50" }
// Async CCS for long searches
POST cluster_b:logs-*/_async_search?wait_for_completion_timeout=2s
{ "query": { "match_all": {} } }
// Check reachability and matching indices before searching
GET _resolve/cluster/cluster_b:logs-*,cluster_c:logs-*What to know:
- Hits show the origin in
_indexascluster_b:logs-2026…. The response's_clusterssection reportstotal,successful,skipped,partialandfailed. skip_unavailable(per remote cluster setting, defaulttrue): an unreachable remote is skipped instead of failing the whole search.ccs_minimize_roundtrips(defaulttrue): each remote reduces its own results, which means fewer network round trips.- Security: the user needs read privileges on the remote indices. With the API-key model, that's the cross-cluster API key's
access.searchplus the local user's role (remote_indices). With the certificate model, it's a role of the same name on the remote cluster. - Kibana data views can use
cluster_b:logs-*patterns too. - CCS works across versions within the compatibility window (see the CCS version compatibility table in the docs).
📖 Cross-cluster search · Resolve cluster before CCS · ES|QL cross-cluster · Async search
4.7 Exercises: Searching Data
Use kibana_sample_data_ecommerce, kibana_sample_data_flights and kibana_sample_data_logs. Check field names and types first with GET <index>/_mapping, since some sample fields are text + .keyword and others are plain keyword.
- Flights: find flights whose
OriginCityNameorDestCityNamecontains "New York" (both fields, boost the destination ×2), that are not cancelled, withFlightDelayMinbetween 30 and 120 and a timestamp in the last 30 days. Return onlyFlightNum,Carrier,FlightDelayMin, sorted by delay descending. - eCommerce: find orders whose
products.product_namecontains the exact phrase "leather jacket" (slop 1). Highlight the matches. - eCommerce: revenue per week (
taxful_total_price) for the last 8 weeks, with the week-over-week difference and a running total; keep only weeks above 5,000. - eCommerce: the top 5
manufacturer.keywordvalues by number of orders, each with average basket value and the 3 most frequentcategory.keywordvalues. - Logs, in ES|QL: per host, the count of 4xx and 5xx responses in the last 24 hours, the 95th-percentile
bytes, sorted by error count, top 10. - Create a
semantic_textindexfaq, index 5 short Q&A documents, and run a hybrid RRF query combining BM25 onquestionwith semantic search onanswer. - Across clusters: count documents per index on
labandremoteforlogs-*in one request, and make the search succeed even ifremoteis down.
Solution notes
bool.shouldwithmatch_phraseon both city fields (boost: 2on Dest) andminimum_should_match: 1.filter:term Cancelled: false,range FlightDelayMin gte 30 lte 120,range timestamp gte now-30d/d. Add_sourceandsort.match_phrasewithslop: 1onproducts.product_name, plushighlight.fields.date_histogramwithcalendar_interval: weekandextended_bounds, thensum→derivative→cumulative_sum→bucket_selector (params.r > 5000). The query usesrange order_date gte now-8w/w.terms manufacturer.keyword size 5→avg taxful_total_price+terms category.keyword size 3.FROM kibana_sample_data_logs | WHERE @timestamp > NOW() - 24 hours | EVAL code = TO_INTEGER(response.keyword) | WHERE code >= 400 | STATS errors = COUNT(*), p95 = PERCENTILE(bytes, 95) BY host.keyword | SORT errors DESC | LIMIT 10- Mapping
answer: semantic_text,question: text. Use anrrfretriever with amatchon question and asemanticquery on answer. GET logs-*,remote:logs-*/_search {"size":0,"aggs":{"i":{"terms":{"field":"_index"}}}}, withcluster.remote.remote.skip_unavailable: true(the default).
5. Cluster Management
5.1 Monitor and maintain cluster health
Analogy: a hospital ward. Green means every patient (shard) has a bed and a backup nurse (replica). Yellow means every patient has a bed, but some backup nurses are missing: no data lost, but less safe. Red means at least one patient has no bed at all: a primary shard is unassigned and its data is unavailable. The allocation explain API is the doctor's chart that says why.
The monitoring toolkit
GET _cluster/health // status, nodes, unassigned_shards, relocating…
GET _cluster/health/orders?level=shards // per index or shard
GET _cluster/health?wait_for_status=green&timeout=30s
GET _health_report // 9.x health indicators with diagnoses & remediation
GET _health_report/shards_availability
GET _cat/health?v
GET _cat/nodes?v&h=name,node.role,master,heap.percent,ram.percent,cpu,load_1m,disk.used_percent
GET _cat/indices?v&health=yellow&s=index
GET _cat/shards?v&h=index,shard,prirep,state,unassigned.reason,node&s=state
GET _cat/allocation?v // disk per node, shard counts
GET _cat/recovery?v&active_only=true
GET _cat/thread_pool/search,write?v&h=node_name,name,active,queue,rejected
GET _cat/pending_tasks?v
GET _nodes/stats/jvm,fs,indices,breaker
GET _cluster/stats?human
GET _tasks?actions=*reindex*&detailed
GET _cluster/allocation/explain // first unassigned shard, or specify one📖 Cluster health API · Health report API · Cluster allocation explain API · cat APIs · Troubleshoot
Diagnosing yellow and red: the decision tree
GET _cluster/health: which status, and how manyunassigned_shards?GET _cat/shards?v&h=index,shard,prirep,state,unassigned.reason | grep UNASSIGNED(in Console, use&s=state).GET _cluster/allocation/explainwith the index, shard and primary flag. Readallocate_explanationand each node'sdeciders.- Fix according to the cause:
| Symptom or explanation | Cause | Fix |
|---|---|---|
| Replica unassigned, "a copy of this shard is already allocated to this node" | Single-node cluster or too few nodes | Add nodes or number_of_replicas: 0 (only if the task allows it) |
awareness decider says NO | Forced awareness with a zone missing | Expected. Restore the zone, or relax the awareness values. |
filter decider says NO | require/include/exclude rule matches no node | Fix or clear the index.routing.allocation.* settings |
disk_threshold decider says NO | Node above the low (85%) or high (90%) watermark | Free disk, delete old indices, add nodes, or (temporarily) adjust cluster.routing.allocation.disk.watermark.* |
Index is read-only, writes fail with FORBIDDEN/12/index read-only / allow delete | Flood stage (95%) reached | Free disk. The block is removed automatically once usage drops below the high watermark, or clear it: PUT idx/_settings {"index.blocks.read_only_allow_delete": null} |
max_retry decider: "shard has exceeded the maximum number of retries" | Repeated failures (e.g. an earlier disk or mapping problem) | Fix the cause, then POST _cluster/reroute?retry_failed=true |
data_tier decider says NO | _tier_preference asks for a tier with no nodes | Add a node with that role or change _tier_preference |
enable decider says NO | cluster.routing.allocation.enable set to none or primaries | Reset it to null (all) |
| Primary unassigned, "no valid shard copy" (red) | All copies lost (node or disk gone) | Bring the node back, restore from snapshot, or, as a last resort, allocate_stale_primary / allocate_empty_primary via _cluster/reroute with accept_data_loss: true |
total_shards_per_node limit | Too restrictive a per-node cap | Raise or remove index.routing.allocation.total_shards_per_node |
NODE_LEFT + delayed | Node left recently; waiting delayed_timeout (1m) | Wait, or lower the timeout |
POST _cluster/reroute?retry_failed=true
PUT _cluster/settings
{ "persistent": {
"cluster.routing.allocation.disk.watermark.low": "85%",
"cluster.routing.allocation.disk.watermark.high": "90%",
"cluster.routing.allocation.disk.watermark.flood_stage": "95%" } }
// Manual moves / last-resort recovery
POST _cluster/reroute
{ "commands": [
{ "move": { "index": "orders", "shard": 0, "from_node": "es01", "to_node": "es02" } },
{ "allocate_replica": { "index": "orders", "shard": 1, "node": "es03" } } ] }Cluster settings: persistent survives a full restart; transient is deprecated. Set a setting to null to reset it to the default. GET _cluster/settings?include_defaults=true&flat_settings=true shows everything.
📖 Red or yellow cluster health · Diagnose unassigned shards · Fix watermark errors · Cluster-level shard allocation and routing settings · Cluster reroute API
Performance signals to watch
| Signal | Where | Worry when… |
|---|---|---|
| JVM heap | _cat/nodes heap.percent, _nodes/stats/jvm | Consistently > 85%, long old-GC pauses |
| Circuit breakers | _nodes/stats/breaker | tripped counts increasing (requests rejected with 429s) |
| Thread-pool rejections | _cat/thread_pool | rejected > 0 on write or search → back-pressure |
| Disk | _cat/allocation | Approaching 85/90/95% |
| Slow queries or indexing | Slow logs: index.search.slowlog.threshold.query.warn: 5s, index.indexing.slowlog.threshold.index.warn: 10s | Repeated slow entries |
| Hot threads | GET _nodes/hot_threads | CPU-bound nodes |
| Pending tasks | _cat/pending_tasks | Master overloaded (too many shards or mapping updates) |
Monitoring solutions:
- Stack Monitoring: Elastic Agent or Metricbeat collects metrics into a (preferably separate) monitoring cluster; view them in Kibana → Stack Monitoring. Legacy internal collection is deprecated.
- AutoOps: Elastic's hosted, automated diagnosis with recommendations. Available for ECH, and via Cloud Connect for self-managed clusters.
- Kibana alerting rules on cluster health, CPU, disk and so on.
📖 Monitoring · Stack monitoring · Collect monitoring data with Elastic Agent · AutoOps · AutoOps vs Stack Monitoring · Slow logs · Update logging levels
Maintaining the cluster: snapshots, SLM and routine operations
Analogy: snapshots are incremental photo backups of your phone. The first one copies everything; later ones only copy new photos (new segments), yet every snapshot can restore the full album on its own.
// 1) Register a repository (shared FS: every node needs path.repo pointing to the same mount)
PUT _snapshot/my-repo
{ "type": "fs", "settings": { "location": "/usr/share/elasticsearch/snapshots/my-repo", "compress": true } }
POST _snapshot/my-repo/_verify
// 2) Take a snapshot
PUT _snapshot/my-repo/snap-2026.09.28?wait_for_completion=true
{ "indices": "orders,logs-app-*", "include_global_state": false,
"metadata": { "taken_by": "exam", "reason": "before migration" } }
GET _snapshot/my-repo/_all GET _snapshot/my-repo/snap-2026.09.28/_status
GET _cat/snapshots/my-repo?v
// 3) Restore (the target index must not exist and be open; rename or close/delete first)
POST _snapshot/my-repo/snap-2026.09.28/_restore
{
"indices": "orders",
"rename_pattern": "(.+)",
"rename_replacement": "restored-$1",
"include_aliases": false,
"index_settings": { "index.number_of_replicas": 0 },
"ignore_index_settings": ["index.refresh_interval"]
}Snapshot lifecycle management (SLM):
PUT _slm/policy/nightly
{
"schedule": "0 30 1 * * ?",
"name": "<nightly-{now/d}>",
"repository": "my-repo",
"config": { "indices": ["*"], "include_global_state": true },
"retention": { "expire_after": "30d", "min_count": 5, "max_count": 50 }
}
POST _slm/policy/nightly/_execute // run now
GET _slm/policy/nightly // last_success / last_failure
GET _slm/stats
POST _slm/_execute_retentionThe SLM schedule uses Quartz cron (seconds first: sec min hour day month weekday), and the name supports date math.
Other routine operations:
- Searchable snapshots: mount an index straight from a repository. Use
POST _snapshot/my-repo/snap/_mount?storage=full_copy(cold) orshared_cache(frozen), or let ILM do it. - Repository types:
fs,url(read-only),s3,gcs,azure,source(source-only). - Restore a whole cluster or feature states:
include_global_state,feature_states(e.g.security,kibana). - Close and open indices to cut resource use:
POST idx/_close. - Force merge read-only indices:
POST idx/_forcemerge?max_num_segments=1. - Clear caches:
POST idx/_cache/clear. Refresh/flush:POST idx/_refresh,POST idx/_flush. - Rolling restarts and upgrades: disable replica allocation → flush → restart → re-enable (see 1.2).
📖 Snapshot and restore · Manage repositories · Shared file system repository · Create snapshots and SLM · Restore a snapshot · Searchable snapshots · Add and remove nodes
5.2 Perform cross-cluster operations
Cross-cluster operations depend on remote clusters: a named connection from the local cluster (which starts the requests) to a remote cluster (which holds the data). Two features use it:
- Cross-cluster search (CCS): query remote data (queries in 4.6).
- Cross-cluster replication (CCR): continuously copy leader indices on the remote into read-only follower indices on the local cluster. It needs a Platinum/Enterprise license or the trial.
Analogy. CCS is a phone call to another library: you ask and hear the answer, but nothing is copied. CCR is a newspaper subscription: the publisher (leader) keeps sending every new edition, and your copy (follower) is read-only.
Step 1: connect a remote cluster
| API key authentication (recommended) | TLS certificate authentication (deprecated) | |
|---|---|---|
| Port on the remote | 9443 (dedicated remote cluster server) | 9300 (transport) |
| Trust | Local trusts the remote's CA; access is limited by a cross-cluster API key | Mutual TLS; both clusters trust each other's transport CA |
| Remote privileges | Defined in the API key (access.search, access.replication) | Roles with the same names must exist on the remote |
Connection modes: sniff (default; connects directly to seed nodes and discovers gateway nodes) or proxy (a single address, e.g. through a load balancer). ECH, ECE and ECK effectively use proxy mode.
API key setup, self-managed:
# On the REMOTE cluster (elasticsearch.yml): enable the remote cluster server interface
remote_cluster_server.enabled: true
remote_cluster.port: 9443
xpack.security.remote_cluster_server.ssl.enabled: true
xpack.security.remote_cluster_server.ssl.key: certs/remote.key
xpack.security.remote_cluster_server.ssl.certificate: certs/remote.crt// On the REMOTE: create a cross-cluster API key that limits what the local cluster may do
POST _security/cross_cluster/api_key
{
"name": "from-lab",
"access": {
"search": [ { "names": ["logs-*", "orders"] } ],
"replication": [ { "names": ["orders", "metrics-*"] } ]
}
}# On EVERY node of the LOCAL cluster: store the encoded key in the keystore, then reload
bin/elasticsearch-keystore add cluster.remote.cluster_b.credentialsPOST _nodes/reload_secure_settings
// On the LOCAL cluster (it also needs to trust the remote's CA:
// xpack.security.remote_cluster_client.ssl.certificate_authorities)
PUT _cluster/settings
{
"persistent": {
"cluster.remote.cluster_b.seeds": ["remote-es01:9443"],
"cluster.remote.cluster_b.skip_unavailable": true
}
}
// Proxy mode alternative
PUT _cluster/settings
{ "persistent": { "cluster.remote.cluster_b.mode": "proxy",
"cluster.remote.cluster_b.proxy_address": "lb.example.com:9443" } }
GET _remote/info // "connected": true, mode, num_nodes_connected, cluster_credentialsThe same can be done in Kibana: Stack Management → Remote Clusters. The local nodes that make the connections need the remote_cluster_client role, which is included by default when no roles are set explicitly.
Local user privileges for the API key model: add remote_indices to the user's role on the local cluster:
PUT _security/role/ccs-reader
{
"cluster": [],
"indices": [ { "names": ["logs-*"], "privileges": ["read", "view_index_metadata"] } ],
"remote_indices": [
{ "clusters": ["cluster_b"], "names": ["logs-*"], "privileges": ["read", "read_cross_cluster", "view_index_metadata"] }
]
}For CCR, the local user's role needs remote_indices with cross_cluster_replication and cross_cluster_replication_internal privileges, plus the local manage_ccr cluster privilege. The docs list the exact privileges per model.
📖 Remote clusters · Security models · Connection modes · Self-managed remote clusters · Add remote clusters with API key auth · Add remote clusters with TLS certificate auth · Migrate from certificate to API key · Create cross-cluster API key
Step 2a: cross-cluster search
See 4.6 for the queries. Per-remote options: skip_unavailable (default true); cluster.remote.<alias>.transport.compress. Check connectivity with GET _remote/info and GET _resolve/cluster/<alias>:*.
Step 2b: cross-cluster replication
Prerequisites: licence (Platinum/Enterprise or trial) on both clusters; the remote connection from the follower (local) cluster to the leader; soft deletes enabled on the leader index (the default since 7.0); compatible versions (the follower may be the same or newer).
// Follow one specific leader index (run on the FOLLOWER / local cluster)
PUT orders-copy/_ccr/follow?wait_for_active_shards=1
{
"remote_cluster": "cluster_b",
"leader_index": "orders",
"max_read_request_operation_count": 5120,
"settings": { "index.number_of_replicas": 0 }
}
// Auto-follow: automatically follow NEW leader indices (and data streams) that match patterns
PUT _ccr/auto_follow/logs-follower
{
"remote_cluster": "cluster_b",
"leader_index_patterns": ["logs-*", "metrics-*"],
"leader_index_exclusion_patterns": ["logs-debug-*"],
"follow_index_pattern": "{{leader_index}}-dr"
}Managing replication:
GET orders-copy/_ccr/stats GET orders-copy/_ccr/info GET _ccr/stats
POST orders-copy/_ccr/pause_follow
POST orders-copy/_ccr/resume_follow
// Turn a follower into a normal writable index (e.g. failover), in order:
POST orders-copy/_ccr/pause_follow
POST orders-copy/_close
POST orders-copy/_ccr/unfollow
POST orders-copy/_open
GET _ccr/auto_follow POST _ccr/auto_follow/logs-follower/pause
DELETE _ccr/auto_follow/logs-followerCCR facts:
- Followers are read-only. Writing to them fails.
- Auto-follow only picks up leader indices created after the pattern exists. Follow existing ones explicitly.
- Replication is pull-based: the follower fetches operations from the leader's shard history (soft deletes, retained by shard history retention leases).
- Mapping and settings changes on the leader are replicated automatically. Some follower settings can't be changed.
- Data streams are replicated through auto-follow patterns, which replicate the backing indices and create a matching follower data stream.
- Uses: disaster recovery (active–passive), bringing data closer to users (geo read replicas), and centralised reporting.
- Bi-directional replication is possible with two auto-follow setups and distinct write targets (e.g. aliases or data streams per region).
- The ILM action
unfollowconverts followers automatically as they age.
📖 Cross-cluster replication · Set up CCR · CCR prerequisites · Configure privileges for CCR · Create a follower index · Create an auto-follow pattern · Manage CCR · Uni-directional disaster recovery · Bi-directional disaster recovery
Other cross-cluster operations to recognise:
- Reindex from remote:
_reindexwithsource.remote(HTTP, port 9200; needsreindex.remote.whitelist). This one doesn't use remote-cluster connections. - Restore a snapshot into another cluster: register the same repository (read-only,
"readonly": true, on the second cluster) and restore. - Cross-cluster ES|QL and EQL, and Kibana data views with
cluster:indexpatterns.
5.3 Exercises: Cluster Management
- Your cluster is yellow. Find which shards are unassigned and why, then fix it without adding nodes and without deleting data.
- Index
hot-datais red after nodees03died permanently. A snapshot from last night exists. Recover it ashot-data. - Register an
fsrepositorybackups, snapshot only indices matchingorders*without global state, and create an SLM policy running every day at 02:15 that keeps between 3 and 10 snapshots for 14 days. - A node shows
disk.used_percent: 96, and writes tologs-xfail withindex read-only / allow delete. Explain what happened and fix it. - Connect cluster
labto clusterremoteasdrusing API-key authentication, permitting search onlogs-*and replication oforders. - On
lab, makeorders-drfollowremote'sorders. Then fail over: turnorders-drinto a writable index. - Configure auto-follow so every new
logs-*index onremoteis replicated tolabwith the suffix-copy.
Solution notes
_cat/shards→_cluster/allocation/explain. Typical fixes: remove a badindex.routing.allocation.require.*; resetcluster.routing.allocation.enable;_cluster/reroute?retry_failed=true; or (on a 1-node lab) setnumber_of_replicas: 0if the task says data only needs one copy.DELETE hot-data(or close it), thenPOST _snapshot/<repo>/<snap>/_restore {"indices":"hot-data"}. Verify with_cat/indicesand_count.PUT _snapshot/backups {"type":"fs","settings":{"location":"…"}}(the path must be underpath.repo). Snapshot with"indices":"orders*","include_global_state":false. SLM:"schedule":"0 15 2 * * ?",retention: {expire_after:"14d", min_count:3, max_count:10}.- The flood-stage watermark (95%) applied
index.blocks.read_only_allow_delete. Free disk (delete, move or shrink data, or add capacity). The block auto-releases below the high watermark, or remove it explicitly withnull. - On
remote: enable the remote cluster server and create a cross-cluster API key withsearch: logs-*,replication: orders. Onlab: add the key to the keystore ascluster.remote.dr.credentialson every node,_nodes/reload_secure_settings, setcluster.remote.dr.seeds, then checkGET _remote/info. PUT orders-dr/_ccr/follow {"remote_cluster":"dr","leader_index":"orders"}. Failover:pause_follow→_close→_ccr/unfollow→_open.PUT _ccr/auto_follow/logs {"remote_cluster":"dr","leader_index_patterns":["logs-*"],"follow_index_pattern":"{{leader_index}}-copy"}. Only new leader indices are picked up.
6. Security & Access Control
Analogy for the whole domain: an office building. Authentication is the badge check at the door: who are you? (native users, API keys, LDAP/AD, SAML, OIDC, PKI, service accounts). Authorization / RBAC is which doors your badge opens: roles bundle privileges on the cluster, on indices (even specific documents and fields inside a room), and in Kibana features and spaces. TLS is the armoured corridor between buildings (nodes) and to visitors (clients). Audit logs are the CCTV.
6.1 Implement role-based access control (RBAC)
The model
user ──(has)──► roles ──(grant)──► privileges on resources
▲ ├─ cluster: monitor, manage, manage_ilm, manage_index_templates, manage_security…
│ ├─ indices: read, write, create_doc, manage, view_index_metadata, all…
│ │ + field-level security (FLS) + document-level security (DLS)
│ ├─ applications: Kibana features & spaces
│ └─ remote_indices / remote_cluster (for CCS/CCR)
role mapping (LDAP groups, SAML attributes, PKI DN…) ──► rolesBuilt-in roles worth knowing: superuser, kibana_admin, kibana_system (for Kibana's own service connection), viewer (read-only, including Kibana), editor, monitoring_user, remote_monitoring_agent, ingest_admin, snapshot_user, machine_learning_admin/_user, transform_admin, beats_system, logstash_system. Built-in users: elastic (superuser), kibana_system, logstash_system, beats_system, apm_system, remote_monitoring_user.
Privileges, the most-used ones:
| Cluster privilege | Allows |
|---|---|
monitor | Read-only cluster info (health, stats, nodes) |
manage | Everything cluster-level except security |
manage_index_templates, manage_ilm, manage_slm, manage_pipeline, manage_ingest_pipelines, manage_enrich, manage_ccr, manage_transform, manage_ml | The named feature |
manage_security | Users, roles, API keys (effectively admin) |
manage_own_api_key | Create or invalidate your own API keys |
read_ccr, cross_cluster_search, cross_cluster_replication | Cross-cluster operations |
| Index privilege | Allows |
|---|---|
read | Search, get, count |
view_index_metadata | Get mappings, settings, aliases (Kibana needs this for data views) |
write | Index, update, delete (includes index, create, delete) |
create_doc | Only create new documents (ideal for shippers, data streams) |
create_index, delete_index, manage (settings, aliases, refresh…), manage_ilm, auto_configure, maintenance | Index administration |
monitor | Index stats, recovery, segments |
all | Everything on those indices |
Create roles and users
PUT _security/role/sales_analyst
{
"description": "Read EU sales, hide PII, use Discover & Dashboards in the 'sales' space",
"cluster": ["monitor"],
"indices": [
{
"names": ["sales-*", "orders"],
"privileges": ["read", "view_index_metadata"],
"field_security": {
"grant": ["*"],
"except": ["customer.email", "customer.phone", "card.*"]
},
"query": { "term": { "region": "EU" } }
},
{
"names": ["sales-reports"],
"privileges": ["read", "write", "create_index"]
}
],
"applications": [
{
"application": "kibana-.kibana",
"privileges": ["feature_discover.read", "feature_dashboard.all"],
"resources": ["space:sales"]
}
],
"metadata": { "owner": "exam" }
}
POST _security/user/jane
{
"password": "Str0ng-Passw0rd!",
"roles": ["sales_analyst"],
"full_name": "Jane Doe",
"email": "jane@example.com",
"metadata": { "team": "sales" }
}
POST _security/user/jane/_password { "password": "An0ther-Str0ng-One!" }
POST _security/user/jane/_disable POST _security/user/jane/_enable
GET _security/user/jane GET _security/role/sales_analyst
DELETE _security/role/old_roleField-level security (FLS) uses field_security.grant / except. Wildcards are allowed. Metadata fields such as _id are always visible.
Document-level security (DLS) uses a query, which can be a templated query based on the user:
"query": { "template": { "source": { "term": { "owner": "{{_user.username}}" } } } }When a user has several roles, the permissions are the union of all of them. A role without DLS on an index grants access to all documents of that index, even if another role restricts it. That's a classic exam trap.
Kibana privileges: application: "kibana-.kibana". Use "privileges": ["all"] or ["read"] for base access, or per-feature ones (feature_discover.read, feature_dashboard.all, feature_visualize.all…) for specific features. resources holds the spaces: space:<id>, or * for all. It's easiest to create these in Kibana → Stack Management → Roles and then check the result with GET _security/role/<name>.
Test what you granted
GET _security/_authenticate // who am I, which roles, which realm
GET _security/user/_has_privileges
{ "cluster": ["monitor"], "index": [ { "names": ["sales-2026"], "privileges": ["read", "write"] } ] }
// Run a request AS another user (requires the run_as privilege) to verify their view
GET sales-*/_search
# Header: es-security-runas-user: janeThe fastest real-world check is to log into Kibana as the user, or call the API with -u jane:password, and try the allowed and the forbidden actions.
API keys
POST _security/api_key
{
"name": "ingest-shipper",
"expiration": "30d",
"role_descriptors": {
"writer": {
"cluster": ["monitor"],
"indices": [ { "names": ["logs-app-*"], "privileges": ["create_doc", "auto_configure"] } ]
}
},
"metadata": { "app": "shipper" }
}
// → returns id, api_key, encoded → header: Authorization: ApiKey <encoded>
GET _security/api_key?name=ingest-shipper
DELETE _security/api_key { "name": "ingest-shipper" } // invalidate
POST _security/api_key/_query { "query": { "term": { "name": "ingest-shipper" } } }An API key's privileges are the intersection of the role_descriptors and the creator's privileges at creation time. Without role_descriptors, it gets a snapshot of the owner's permissions.
Role mappings (external realms)
PUT _security/role_mapping/ldap-admins
{
"enabled": true,
"roles": ["superuser"],
"rules": { "field": { "groups": "cn=admins,ou=groups,dc=example,dc=com" } }
}
PUT _security/role_mapping/saml-readers
{
"enabled": true,
"roles": ["viewer"],
"rules": { "all": [ { "field": { "realm.name": "saml1" } },
{ "field": { "groups": "readers" } } ] }
}Rules support field, any, all and except. Fields: username, dn, groups, realm.name, metadata.*. Use role_templates for dynamic role names.
Realms, in order of evaluation (order): reserved → file → native (both on by default) → ldap, active_directory, pki, saml, oidc, kerberos, jwt. SAML and OIDC work through Kibana. Service accounts (e.g. elastic/kibana, elastic/fleet-server) use service tokens instead of passwords.
📖 Users and roles · User roles · Defining roles · Role structure · Built-in roles · Security privileges · Kibana privileges · Kibana role management · Document and field level security · Privileges for data streams and aliases · Role mapping · Native realm · Built-in users · Service accounts · Elasticsearch API keys · Submit requests on behalf of other users
6.2 Secure a cluster for production
Analogy: moving from a house party to a bank. At a party the door is open and everyone knows each other (development mode). A bank has locked doors (authentication), badges per room (RBAC), armoured transport between branches (transport TLS), bulletproof glass at the counter (HTTP TLS), a vault for secrets (keystore), cameras (audit logs), and a building inspection before opening (bootstrap checks).
Layer 1: security on, with TLS everywhere
Since 8.0, a fresh self-managed install auto-configures security: it generates certificates, enables TLS on the transport and HTTP layers, prints the elastic password, and gives you an enrollment token for Kibana and additional nodes. Know how to do it manually too:
# 1. CA + transport (node-to-node) certificates
bin/elasticsearch-certutil ca --out elastic-stack-ca.p12
bin/elasticsearch-certutil cert --ca elastic-stack-ca.p12 --out elastic-certificates.p12
# 2. HTTP certificates (interactive wizard: CSR or CA-signed, hostnames, IPs)
bin/elasticsearch-certutil http
# 3. Store keystore passwords
bin/elasticsearch-keystore add xpack.security.transport.ssl.keystore.secure_password
bin/elasticsearch-keystore add xpack.security.transport.ssl.truststore.secure_password
bin/elasticsearch-keystore add xpack.security.http.ssl.keystore.secure_password# elasticsearch.yml
xpack.security.enabled: true
xpack.security.enrollment.enabled: true
xpack.security.transport.ssl.enabled: true # REQUIRED for multi-node clusters with security
xpack.security.transport.ssl.verification_mode: certificate # or full (also checks hostnames)
xpack.security.transport.ssl.client_authentication: required
xpack.security.transport.ssl.keystore.path: certs/elastic-certificates.p12
xpack.security.transport.ssl.truststore.path: certs/elastic-certificates.p12
xpack.security.http.ssl.enabled: true # HTTPS for REST clients & Kibana
xpack.security.http.ssl.keystore.path: certs/http.p12# Passwords & tokens
bin/elasticsearch-reset-password -u elastic # or -i for interactive
bin/elasticsearch-reset-password -u kibana_system
bin/elasticsearch-create-enrollment-token -s kibana # or -s node
bin/elasticsearch-service-tokens create elastic/kibana my-token# kibana.yml (secured connection to Elasticsearch)
elasticsearch.hosts: ["https://es01:9200"]
elasticsearch.serviceAccountToken: "AAEAAWVs..." # preferred over username/password
elasticsearch.ssl.certificateAuthorities: ["/etc/kibana/certs/http_ca.crt"]
server.ssl.enabled: true # HTTPS for browsers → Kibana
server.ssl.certificate: /etc/kibana/certs/kibana.crt
server.ssl.key: /etc/kibana/certs/kibana.key
xpack.encryptedSavedObjects.encryptionKey: "<32+ chars>" # needed for alerting and saved-object secrets
xpack.security.encryptionKey: "<32+ chars>"
xpack.reporting.encryptionKey: "<32+ chars>"Rules:
- With security enabled, a multi-node cluster must have transport TLS. Otherwise nodes refuse to form a cluster once they bind to a non-loopback address (production mode).
- Secrets belong in the keystore (
elasticsearch-keystore), never in plainelasticsearch.yml. Reloadable secure settings can be refreshed withPOST _nodes/reload_secure_settings. - Don't use the
elasticsuperuser for daily work or for Kibana. Create personal admin users, and use service accounts or API keys for applications. - Anonymous access stays disabled unless explicitly required.
Layer 2: production-mode node configuration and bootstrap checks
As soon as network.host binds to a non-loopback address, Elasticsearch runs in production mode and enforces bootstrap checks. A failing check stops the node from starting.
| Check / setting | Requirement |
|---|---|
| Heap size | -Xms = -Xmx (set in jvm.options.d/*.options or ES_JAVA_OPTS). By default Elasticsearch sizes the heap automatically; manually, ≤ 50% of RAM and below the compressed-oops threshold (~31 GB). |
| File descriptors | ≥ 65,535 (ulimit -n) |
| Max threads | ≥ 4,096 |
| Virtual memory | vm.max_map_count ≥ 262,144 (check the docs for the value recommended for your version) |
| Memory lock | If bootstrap.memory_lock: true, the lock must succeed. Also disable swap. |
| Discovery | discovery.seed_hosts / cluster.initial_master_nodes (first bootstrap only) or equivalent must be configured |
| Others | Max file size, max virtual memory, JVM checks (client JVM, serial GC), system call filter, OnError/OnOutOfMemoryError |
cluster.name: prod-search
node.name: es-prod-01
node.roles: [ master, data_hot, ingest ]
network.host: 10.0.1.11 # _site_, _local_, specific IP…
http.port: 9200
transport.port: 9300
discovery.seed_hosts: ["10.0.1.11", "10.0.1.12", "10.0.1.13"]
cluster.initial_master_nodes: ["es-prod-01", "es-prod-02", "es-prod-03"] # remove after first start
path.data: /var/lib/elasticsearch
path.logs: /var/log/elasticsearch
bootstrap.memory_lock: true
action.destructive_requires_name: true # no DELETE on wildcards / _all (the default is true in 8+)Layer 3: least privilege, network restrictions and auditing
Least privilege checklist:
- Personal admin accounts instead of the shared
elasticaccount. - Shippers (Agent, Beats, Logstash) get API keys with
create_doc+auto_configureon their own data streams only. - Applications get dedicated users or API keys limited to their indices.
- Analysts get read +
view_index_metadata, with FLS/DLS where needed, and Kibana privileges limited to specific spaces. action.destructive_requires_name: truepreventsDELETE *.
IP filtering (self-managed; on Cloud use traffic filters or network security):
xpack.security.transport.filter.allow: ["10.0.1.0/24"]
xpack.security.transport.filter.deny: _all
xpack.security.http.filter.allow: ["10.0.0.0/16"]
xpack.security.http.filter.deny: _allAudit logging (needs a Platinum/Enterprise licence or the trial):
xpack.security.audit.enabled: true
xpack.security.audit.logfile.events.include: ["access_denied", "authentication_failed", "connection_denied", "tampered_request", "run_as_denied", "security_config_change"]
xpack.security.audit.logfile.events.emit_request_body: falseThe output goes to <cluster>_audit.json in the logs directory. Ignore policies (xpack.security.audit.logfile.events.ignore_filters.*) reduce noise. Kibana has its own audit log (xpack.security.audit.enabled in kibana.yml).
Other production hardening:
- Encrypt data at rest via the file system or disk (Elasticsearch doesn't encrypt shards itself). On Cloud, use customer-managed keys.
- Keep Elasticsearch off the public internet. Put it behind a firewall, load balancer or VPN, and expose only Kibana over HTTPS if needed.
- Set a session timeout in Kibana (
xpack.security.session.idleTimeout,lifespan). - Rotate certificates before they expire. The docs cover rotating with the same CA and with a different CA.
- Snapshot the security feature state (
feature_states: ["security"]) so users and roles can be restored. - For FIPS environments, use FIPS 140-2 mode with approved algorithms and keystores.
📖 Security overview · Self-managed security setup · Automatic security setup · Minimal security setup · Set up transport TLS · Set up HTTPS · Configure security in Kibana · Manage TLS encryption · Update TLS certificates · Secure settings (keystore) · IP filtering (self-managed) · Audit logging · Enable audit logging · Important system configuration · Bootstrap checks · Important settings · Kibana session management · Tutorial: self-managed secure install
6.3 Exercises: Security
- Create role
logs_reader: read and view metadata onlogs-*, no access to the fielduser.password_hash, only documents whereservice.environmentisproduction, plus Discover (read) in the Kibana spaceops. Create useranawith this role and verify with_has_privilegesand by logging in. - Create role
ingest_writerthat can only append documents to data streams matchinglogs-app-*, and create an API key limited to it that expires in 7 days. - User
bobhas rolesA(DLS:team: blueonprojects) andB(plainreadonprojects). Which documents can he see, and why? Fix it so he sees onlyteam: blue. - Map LDAP group
cn=sre,ou=groups,dc=corpto rolesmonitoring_userandlogs_reader. - On a three-node lab started without security, enable security with transport TLS (own CA) and HTTPS, reset the
elasticpassword, connect Kibana with a service account token, and turn on audit logging for authentication failures and denied access. - List the bootstrap checks that would fail on a VM with
vm.max_map_count=65530,ulimit -n 4096and swap enabled withbootstrap.memory_lock: true, and fix each one.
Solution notes
indices: [{names:["logs-*"], privileges:["read","view_index_metadata"], field_security:{grant:["*"], except:["user.password_hash"]}, query:{term:{"service.environment":"production"}}}],applications: [{application:"kibana-.kibana", privileges:["feature_discover.read"], resources:["space:ops"]}].- Role:
indices: [{names:["logs-app-*"], privileges:["create_doc","auto_configure"]}]. API key:POST _security/api_keywithexpiration: "7d"and the samerole_descriptors. - He sees all documents: role permissions are a union, and B's unrestricted
readwins. Fix by removing B from bob, or adding the same DLS query to B. PUT _security/role_mapping/sre {"roles":["monitoring_user","logs_reader"],"enabled":true,"rules":{"field":{"groups":"cn=sre,ou=groups,dc=corp"}}}.certutil ca→certutil cert(copy to all nodes) →certutil http→ transport and HTTP SSL settings + keystore passwords → rolling or full restart →elasticsearch-reset-password -u elastic→elasticsearch-service-tokens create elastic/kibana kibana-token→kibana.ymlelasticsearch.serviceAccountToken+ CA →xpack.security.audit.enabled: truewith theevents.includelist.- Max map count →
sysctl -w vm.max_map_count=262144(persist it in/etc/sysctl.d). File descriptors → raise the limit to 65535 (limits.confor systemdLimitNOFILE). Memory lock →LimitMEMLOCK=infinity(systemd) orulimit -l unlimited. Disable swap (swapoff -a) as good practice.
7. Final checklist and study plan
7.1 A four-week plan
| Week | Focus | Deliverable |
|---|---|---|
| 1 | Domains 1–2: lab build, templates, dynamic templates, data streams, ILM, aliases | Every Domain 2 exercise done twice without notes, just the docs |
| 2 | Domain 3: mappings, analyzers, field types, reindex/UBQ, pipelines, enrich, Streams | A "migration" drill: v1 → v2 with a new mapping, pipeline and alias swap in < 20 min |
| 3 | Domain 4: Query DSL, bool, aggregations, ES|QL, semantic search, CCS | 20 queries on the sample data; translate 5 of them to ES|QL |
| 4 | Domains 5–6 + mock exams: health, snapshots, SLM, remote clusters, CCR, RBAC, TLS | Two timed mock exams of 10 mixed tasks in 3 hours |
7.2 "Can I do this from a blank Console with only the docs?"
- ☐ Explain node roles, tiers, primaries vs replicas, and the write and read paths
- ☐ Configure forced awareness and allocation filtering; explain a yellow cluster
- ☐ Create an index with custom analyzers, multi-fields, nested fields and strict mapping
- ☐ Component + index templates with priority; simulate them
- ☐ Dynamic templates with correct ordering
- ☐ A data stream from a template, with ILM rollover/warm/delete;
_ilm/explain - ☐ Alias swap, filtered alias, write index
- ☐ Choose an ingest method for a scenario
- ☐ Reindex with query, script and pipeline; update-by-query to populate a new multi-field
- ☐ A pipeline with grok/dissect, date, set, conditionals,
on_failure, enrich; simulate it - ☐ Streams: processing, retention and data quality in the UI
- ☐
match,match_phrase,multi_match,term/range, bool with filter/should logic - ☐ Terms/date_histogram/range buckets with metrics and pipeline aggs
- ☐ ES|QL with WHERE, EVAL, STATS…BY, SORT, LIMIT, DISSECT and LOOKUP JOIN
- ☐
semantic_text+ inference endpoint + semantic/match query + RRF hybrid - ☐ Remote cluster (API key) + CCS + CCR follower + auto-follow + failover
- ☐ Diagnose unassigned shards with allocation explain; fix watermark blocks
- ☐ Snapshot repository, snapshot, restore with rename, SLM policy
- ☐ Roles with FLS/DLS and Kibana privileges, users, API keys, role mappings
- ☐ TLS (transport + HTTP), keystore, bootstrap checks, audit logging
7.3 Further references
- 📖 Elastic Documentation home: the only resource allowed in the exam
- 📖 Elasticsearch REST API reference
- 📖 Kibana API reference
- 📖 Elasticsearch release notes: skim 9.0 → 9.3 for new features and breaking changes
- 🎓 Elastic Training: Elasticsearch Engineer course: the official course aligned to the exam
- 🧪 Elasticsearch Labs: tutorials on semantic search, ES|QL and retrievers
- 💬 Elastic Discuss forum: exam-experience threads
- 📝 Community practice exercises by Guido Lena Cota (written for the 8.x exam, still useful for templates, mappings and search): Store data · Deploy and operate a cluster
No Comments