Elastic Certified Engineer common mistakes
Elastic Certified Engineer · Understand the traps and how to avoid them.
Tries to apply a static node setting (e.g. `cluster.name`, `node.roles`, `path.repo`, `action.destructive_requires_name`) by calling `PUT _cluster/settings` and gets a 400 `illegal_argument_exception: Can not update setting [...]`, then wastes time retrying variations of the request.
Candidates treat every knob as runtime-tunable because most index and cluster settings they practise with are dynamic. Elasticsearch only allows a defined subset to be changed through the cluster-settings API; everything else lives in the node config and is read once at startup.
What to do instead
Decide the setting category first: try PUT _cluster/settings only for dynamic settings (verified via GET _cluster/settings?include_defaults=true which reports the setting's type). For static settings, edit /etc/elasticsearch/elasticsearch.yml on every node, restart the node, and confirm the effective value with GET _nodes/settings rather than assuming it took. For secrets use the keystore, not the YAML file.
Brings up the first nodes of a brand-new cluster without setting `cluster.initial_master_nodes` (or leaves a stale `cluster.initial_master_nodes` in place on a running cluster), and the nodes sit in `master_not_discovered_exception` / never elect a master, or the cluster refuses to bootstrap.
Since 7.0 the cluster-bootstrap step replaces the old auto-discovery of masters, and candidates copy an elasticsearch.yml from an existing node or from a single-node tutorial that never had to bootstrap. It is also easy to get the value wrong because it must exactly match the node.name values of the master-eligible nodes in the first election.
What to do instead
On the very first start only, list every initial master-eligible node's node.name in cluster.initial_master_nodes on those nodes (keep discovery.seed_hosts for the rest), start them, confirm with GET _cat/master and GET _cluster/health, then remove cluster.initial_master_nodes from all configs so a restart never re-bootstraps.
After seeing the shard count is wrong, runs `PUT /my-index/_settings` with a new `index.number_of_shards` and hits `illegal_argument_exception: Can not update the value of [index.number_of_shards]`, then tries the same thing on a closed index.
Shard count is baked into the index's routing at creation time; almost every other index setting is updatable, so candidates generalise and assume this one is too. Closing the index does not change that.
What to do instead
Treat shard count as immutable. Either POST /my-index/_reindex into a new index created with the target settings, or use _split (increase) / _shrink (decrease), which require the source to be read-only and, for split, a multiple of the original shard count. Put the desired value in an index template or component template so future indices are correct, and set index.number_of_replicas (which is dynamic) via _settings when only replicas are wrong.
Posts a `_bulk` body as a pretty-printed JSON array, or puts the action line and the document on the same line, or omits the final newline, and every item comes back with `Malformed action/metadata line` or the request ends with `The bulk request must be terminated by a newline [\n]`.
Candidates build the payload by copying a normal PUT /index/_doc/1 document flow, but _bulk is not a JSON array - it is newline-delimited NDJSON with a specific two-line rhythm, and pretty-printers plus editors that strip the trailing newline silently break it.
What to do instead
Write strictly NDJSON: one action/metadata line ({"index":{"_index":"x","_id":"1"}}), then exactly one source line, no wrapping array, no indentation, and a trailing newline at end of file. Send it with curl -H 'Content-Type: application/x-ndjson' --data-binary @bulk.ndjson (never -d, which can mangle newlines). Then read the response: check the top-level errors flag and the per-item items[].index.status, because a 200 HTTP status does not mean the documents were indexed.
Runs `POST _reindex` from a source index straight into a destination name that does not exist yet, and the destination ends up with dynamic mappings (`text` + `.keyword` for strings, wrong date/number types), or the request times out at the client while the task keeps running server-side.
Candidates assume reindex copies mappings and settings along with the documents. It copies neither - the destination is created from dynamic mapping or an index template - and a long reindex outlives typical client timeouts, so people retry and duplicate work.
What to do instead
Create the destination explicitly first (PUT /dest with the exact settings and mappings) or attach an index template, then reindex. For anything sizeable, add ?wait_for_completion=false and poll GET _tasks/<task_id>; add "slices": "auto" for parallelism and "script"/"source": {"query": ...} to scope the copy. Verify with GET /dest/_count and compare _mapping against the source before declaring success.
Indexes documents without an explicit mapping, then discovers the field cannot be sorted, aggregated, or exact-matched as expected - the string became a `text` field, or a date-like string got mapped as text, or a number arrived as a string and the type is now locked in.
Dynamic mapping guesses types from the first document seen and never revises them; candidates test with a single sample document and assume the mapping they would have chosen is what got created. Types cannot be changed in place afterwards.
What to do instead
Define the mapping up front - PUT /idx with mappings.properties (or an index template / component template for rolling indices) - and add a keyword sub-field for anything you will sort, aggregate, or exact-match on. Use "dynamic": "strict" in practice so a surprise field fails loudly, and inspect reality with GET /idx/_mapping and GET /idx/_field_caps?fields=* before writing queries against it.
Writes a `term` query against an analysed `text` field (or a user-input `match` query against a `keyword` field) and gets zero hits even though the document obviously contains the value; then escalates by adding wildcards instead of inspecting the field.
Candidates conflate 'the value is in the document' with 'the value is a token in the inverted index'. text is analysed at index time so the original casing/punctuation is gone, while keyword is stored whole, so each query type only works on one of the two.
What to do instead
Match the query type to the field type: match/multi_match on text, term/terms on keyword (or the .keyword sub-field). Confirm the analysis with GET /idx/_analyze using the field's analyser so you know which tokens actually exist, and check the real type with _mapping/_field_caps before blaming the query.
Runs a `terms` aggregation (or a metric agg) directly on a field inside a `nested` type and gets 0 buckets and no error, or aggregates on a plain `text` field and gets `illegal_argument_exception: Fielddata is disabled on text fields by default`.
Nested documents are stored as separate hidden Lucene documents, so an ordinary aggregation cannot see them without being told to step inside; and text fields have no doc values because they are analysed, so aggregation on them requires expensive fielddata. Both failures are silent or cryptic, which pushes candidates toward query syntax changes instead of structural ones.
What to do instead
For nested data, wrap the aggregation in a single nested agg ("nested": {"path": "comments"}) and put the terms/avg inside it referencing the inner field, using reverse_nested if you need to climb back to the parent. For strings, aggregate on the keyword field/sub-field, not the text field; only enable fielddata: true as a last resort because it consumes heap.
Paginates a search application with `from`/`size` and blows past 10,000 results (`search_phase_execution_exception: Result window is too large`), or loops over pages of an index that is being written to and silently gets duplicated and skipped documents.
The 10,000 default index.max_result_window is invisible until you cross it, and the temptation is to just raise it - which only works until the cluster runs out of heap. Paging with from also re-runs the query against a shifting index, so offsets drift.
What to do instead
For deep or user-facing paging use search_after with a deterministic sort that ends in a unique tiebreaker (_shard_doc or _id), and pair it with a point-in-time (POST /idx/_pit?keep_alive=1m) so every page sees the same snapshot; close the PIT when done. Use scroll only for bulk export, not for interactive paging. Leave index.max_result_window at its default.
Starts an ML datafeed while its anomaly detection job is still closed, or posts a job whose `analysis_config` lacks a valid detector/`bucket_span` combination, and the datafeed start fails (`Cannot start datafeed [...] because job is not open`) or the job is rejected on validation.
The job and the datafeed are two separate resources with an ordering dependency, and the API happily creates a job in the closed state. Candidates also hand-write analysis_config and omit fields that the UI would have filled in for them.
What to do instead
Respect the order: create the job, POST _ml/anomaly_detectors/<job_id>/_open, then POST _ml/datafeeds/<datafeed_id>/_start (and reverse it - stop the datafeed before closing the job). Validate before committing with POST _ml/anomaly_detectors/<job_id>/_validate and POST _ml/datafeeds/<datafeed_id>/_preview, then confirm ingestion with GET _ml/anomaly_detectors/<job_id>/_stats and read results via _results/buckets and _results/records.