The REST API surface, from documents to the cluster
Every client library, including the Java API Client in chapter 09, is a typed wrapper around the Elasticsearch REST API. This chapter works through the parts of that API a search projection depends on: writing and reading single documents, optimistic concurrency, external versioning, bulk requests and their partial failures, the by-query APIs, background tasks, and the inspection APIs you reach for when something looks wrong. It finishes with a short look at ES|QL.
Two findings in this chapter shape the synchronisation design in chapter 04. External versioning rejects stale events only while a delete’s tombstone is remembered. And _update_by_query silently changes external versions, so the next legitimate event is rejected. You reproduce both.
You need the chapter 01 lab running. Requests use Kibana Dev Tools syntax, as in chapter 02; every one also works with curl. The chapter takes about 40 minutes and uses a scratch index that you delete at the end.
Conventions shared by every endpoint
A few rules apply across the whole API, and knowing them saves time in every later chapter.
- Paths name the target, then the action.
ch03-profiles/_doc/1is a document;ch03-profiles/_search,_count, and_bulkare actions on an index;_cluster/...,_cat/..., and_tasksact on the cluster. - Status codes carry meaning.
200and201succeed.400is a request the cluster will never accept, such as a mapping violation.404is a missing index or document.409is a version conflict.429means a thread pool queue is full and the request was rejected, so retry later with backoff.5xxmeans the cluster failed. - Errors share one envelope. Every error has an
errorobject with atype, areason, and usually aroot_causearray, plus a top-levelstatus. Error responses in this chapter are trimmed totype,reason, andstatus. filter_pathtrims responses. It is one of the common options every endpoint accepts. The responses shown here use it heavily. It does not apply to error bodies.
Create the scratch index
Chapter 02 showed what dynamic mapping guesses. This time, declare a small mapping and make it strict, so that unknown fields are rejected. Chapter 05 designs the real profile mapping; this one only has to be predictable.
PUT ch03-profiles{ "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "dynamic": "strict", "properties": { "userId": { "type": "long" }, "fullName": { "type": "text" }, "city": { "type": "keyword" }, "accountStatus": { "type": "keyword" }, "updatedAt": { "type": "date" } } }}{"acknowledged":true,"shards_acknowledged":true,"index":"ch03-profiles"}The index has a single shard, so the sequence numbers in the rest of the chapter match yours exactly.
Write a document three ways: create, index, update
What it is. Three endpoints write a single document, and they differ in what they do when the document already exists:
| Endpoint | Document absent | Document present |
|---|---|---|
PUT index/_create/<id> | Creates it | Fails with 409 |
PUT index/_doc/<id> | Creates it | Replaces the whole document |
POST index/_update/<id> | Fails with 404, unless you ask for an upsert | Merges the partial document into the existing one |
Why it matters at 300 million profiles. A projection is fed by events that can arrive twice. The endpoint you choose decides whether a duplicate is harmless (_doc replaces with the same content), an error you must handle (_create), or a merge that depends on what was there before (_update).
Example. Create profile 1, then try to create it again:
PUT ch03-profiles/_create/1{"userId":1,"fullName":"Prashant Kumar","city":"Patna","accountStatus":"ACTIVE","updatedAt":"2026-09-01T10:15:00Z"}{"_index":"ch03-profiles","_id":"1","_version":1,"result":"created", "_shards":{"total":1,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}version_conflict_engine_exception: [1]: version conflict, document already exists (current version [1])status: 409Replace it with the index endpoint, and add profile 2:
PUT ch03-profiles/_doc/1?filter_path=result,_version,_seq_no{"userId":1,"fullName":"Prashant Kumar","city":"Gaya","accountStatus":"ACTIVE","updatedAt":"2026-09-02T08:00:00Z"}{"_version":2,"result":"updated","_seq_no":1}PUT ch03-profiles/_doc/2?filter_path=result,_version,_seq_no{"userId":2,"fullName":"Priya Sharma","city":"Mumbai","accountStatus":"ACTIVE","updatedAt":"2026-08-11T09:00:00Z"}{"_version":1,"result":"created","_seq_no":2}Now change one field with the update API, and then send the identical update again:
POST ch03-profiles/_update/1?filter_path=result,_version,_seq_no{ "doc": { "accountStatus": "SUSPENDED" } }{"_version":3,"result":"updated","_seq_no":3}{"_version":3,"result":"noop","_seq_no":3}The second call returned noop: the merged document was identical to the stored one, so nothing was written and the version did not move. Noop detection is on by default for partial updates. A full PUT _doc has no such check and always writes a new version.
An update can also create a missing document when you set doc_as_upsert:
POST ch03-profiles/_update/3?filter_path=result,_version{ "doc": {"userId":3,"fullName":"José Fernandes","city":"Kochi","accountStatus":"ACTIVE","updatedAt":"2026-07-02T12:30:00Z"}, "doc_as_upsert": true}{"_version":1,"result":"created"}Common mistake. Using _update with partial documents to apply change events. If an event is lost or reordered, a partial update merges onto the wrong base and the document silently drifts from PostgreSQL. A projection is simplest when every write is a complete document built from the source row, sent with PUT _doc.
Read documents: get, exists, and multi-get
What it is. GET index/_doc/<id> returns one document. It is real time: it sees the latest write even before a refresh, as chapter 02 showed. HEAD checks existence without a body. _mget fetches many IDs in one round trip.
Why it matters at 300 million profiles. Exact lookup by userId (Q1 in chapter 01) never needs a search. A get goes to one shard, chosen by routing, instead of fanning out to all of them.
Example. Fetch profile 1 with only two fields of its source:
GET ch03-profiles/_doc/1?_source_includes=fullName,city{"_index":"ch03-profiles","_id":"1","_version":3,"_seq_no":3,"_primary_term":1,"found":true, "_source":{"fullName":"Prashant Kumar","city":"Gaya"}}HEAD ch03-profiles/_doc/1 returns status 200; HEAD ch03-profiles/_doc/99 returns 404. Fetch several at once:
GET ch03-profiles/_mget?filter_path=docs._id,docs.found,docs._source.city{ "ids": ["1", "2", "99"] }{"docs":[{"_id":"1","found":true,"_source":{"city":"Gaya"}}, {"_id":"2","found":true,"_source":{"city":"Mumbai"}}, {"_id":"99","found":false}]}Common mistake. Returning _source unfiltered to API callers. For user profiles, _source contains every indexed field, including personal data the caller may not be allowed to see. Always name the fields you return: _source_includes here, and source filtering on searches in chapter 11.
Optimistic concurrency with sequence numbers
What it is. Every write to a shard gets a sequence number, _seq_no, and the shard’s current _primary_term. Pass both back as if_seq_no and if_primary_term, and the write succeeds only if the document has not changed since you read it. The optimistic concurrency guide describes the mechanism. It is the Elasticsearch equivalent of UPDATE ... WHERE version = :read_version in PostgreSQL.
Why it matters at 300 million profiles. Any read-modify-write against the index needs it, such as an admin tool that edits a document directly, or a job that recalculates a field. For the projection itself, external versioning in the next section is usually the better fit, because the version comes from PostgreSQL rather than from Elasticsearch.
Example. Read the current sequence number of profile 2, then write with it:
GET ch03-profiles/_doc/2?filter_path=_seq_no,_primary_term{"_seq_no":2,"_primary_term":1}PUT ch03-profiles/_doc/2?if_seq_no=2&if_primary_term=1&filter_path=result,_seq_no{"userId":2,"fullName":"Priya Sharma","city":"Pune","accountStatus":"ACTIVE","updatedAt":"2026-09-03T09:00:00Z"}{"result":"updated","_seq_no":5}A second writer that read the same sequence number now loses:
PUT ch03-profiles/_doc/2?if_seq_no=2&if_primary_term=1{"userId":2,"fullName":"Priya Sharma","city":"Nagpur","accountStatus":"ACTIVE","updatedAt":"2026-09-03T09:05:00Z"}version_conflict_engine_exception: [2]: version conflict, required seqNo [2], primary term [1].current document has seqNo [5] and primary term [1]status: 409The sequence number jumped from 2 to 5 because it counts every operation on the shard, not only those on this document.
Common mistake. Retrying a 409 by blindly re-sending the same write. A conflict means your view of the document is stale: read it again, reapply the change, and write with the new sequence number, or give up.
External versioning: letting PostgreSQL decide what is newer
What it is. With version_type=external, you supply the version number yourself, and Elasticsearch accepts a write only if its version is greater than the stored one. The version usually comes from the source system: a row version column, or a change-log sequence number.
Why it matters at 300 million profiles. Change events for the same profile can arrive twice or out of order: a retry, a replay after an outage, a consumer restart. With external versions, a stale event is rejected by Elasticsearch itself, so the indexer does not need to read before it writes. This is the idempotency mechanism chapter 04 builds on.
Example. In this example the version is a row version from PostgreSQL that increases by one on every change to that row. Index version 5 of profile 10:
PUT ch03-profiles/_doc/10?version=5&version_type=external&filter_path=result,_version{"userId":10,"fullName":"Rahul Verma","city":"Lucknow","accountStatus":"ACTIVE","updatedAt":"2026-09-05T00:00:00Z"}{"_version":5,"result":"created"}A late event carrying version 4 is rejected, and so is a duplicate of version 5:
version_conflict_engine_exception: [10]: version conflict, current version [5] is higher or equal to the one provided [4]status: 409version_conflict_engine_exception: [10]: version conflict, current version [5] is higher or equal to the one provided [5]status: 409For an indexer, both 409s mean the same thing: Elasticsearch already has this change or a newer one. Treat them as success, not as errors.
Deletes take a version too. Delete profile 10 at version 7, then deliver a stale update at version 6:
DELETE ch03-profiles/_doc/10?version=7&version_type=external&filter_path=result,_version{"_version":7,"result":"deleted"}version_conflict_engine_exception: [10]: version conflict, current version [7] is higher or equal to the one provided [6]status: 409The deleted profile stayed deleted, because Elasticsearch remembers the version of a deleted document for a while. That memory is a tombstone, and the next experiment shows how long it lasts.
The tombstone expires
The retention period is the index.gc_deletes setting, which defaults to 60 seconds. Shorten it to one second so you can watch it expire:
PUT ch03-profiles/_settings{ "index": { "gc_deletes": "1s" } }Index profile 11 at version 1, delete it at version 3, wait a few seconds, refresh, and then deliver a stale event at version 2:
PUT ch03-profiles/_doc/11?version=1&version_type=external{"userId":11,"fullName":"Meera Iyer","city":"Chennai","accountStatus":"ACTIVE","updatedAt":"2026-09-01T00:00:00Z"}
DELETE ch03-profiles/_doc/11?version=3&version_type=external
POST ch03-profiles/_refresh
PUT ch03-profiles/_doc/11?version=2&version_type=external&filter_path=result,_version{"userId":11,"fullName":"Meera Iyer","city":"Chennai","accountStatus":"ACTIVE","updatedAt":"2026-09-02T00:00:00Z"}{"_version":2,"result":"created"}The profile is back. Version 2 is older than the delete at version 3, but once the tombstone was gone, Elasticsearch had nothing to compare it against. Restore the default before you continue:
PUT ch03-profiles/_settings{ "index": { "gc_deletes": null } }Principle. External versioning protects against stale updates indefinitely, but against stale updates after a delete only for gc_deletes. Any path that can deliver an old event more than 60 seconds after a delete, such as a replay from the start of a topic or a backfill racing a delete, can resurrect a deleted profile. For user profiles, that is a privacy defect, not a cosmetic one. Chapter 04 designs around it.
Common mistake. Using updatedAt in milliseconds as the external version. Two updates in the same millisecond get the same version and the second is dropped, and clock differences between application servers can make a newer change look older. Use a counter that the database increments.
Bulk requests and partial failure
What it is. The _bulk API sends many index, create, update, and delete operations in one request. The body is newline-delimited JSON: an action line, followed by a document line for everything except delete. Each operation succeeds or fails on its own.
Why it matters at 300 million profiles. Single-document requests cannot backfill 300 million rows in reasonable time. Every serious ingestion path uses bulk, and every bulk client must read the response per item, because a bulk request can return HTTP 200 while some of its operations failed.
Example. Send four operations. Two are designed to fail: one document carries a field that the strict mapping does not allow, and one tries to create a document that already exists.
POST ch03-profiles/_bulk?filter_path=errors,items.*._id,items.*.status,items.*.result,items.*.error.type,items.*.error.reason{"index":{"_id":"20","version":1,"version_type":"external"}}{"userId":20,"fullName":"Anjali Nair","city":"Kochi","accountStatus":"ACTIVE","updatedAt":"2026-09-10T10:00:00Z"}{"index":{"_id":"21","version":1,"version_type":"external"}}{"userId":21,"fullName":"Amit Das","city":"Kolkata","accountStatus":"ACTIVE","nickname":"Amu","updatedAt":"2026-09-10T10:00:00Z"}{"create":{"_id":"3"}}{"userId":3,"fullName":"José Fernandes","city":"Kochi","accountStatus":"ACTIVE","updatedAt":"2026-07-02T12:30:00Z"}{"delete":{"_id":"2"}}{ "errors": true, "items": [ {"index": {"_id":"20","result":"created","status":201}}, {"index": {"_id":"21","status":400,"error":{"type":"strict_dynamic_mapping_exception", "reason":"[1:89] mapping set to strict, dynamic introduction of [nickname] within [_doc] is not allowed"}}}, {"create": {"_id":"3","status":409,"error":{"type":"version_conflict_engine_exception", "reason":"[3]: version conflict, document already exists (current version [1])"}}}, {"delete": {"_id":"2","result":"deleted","status":200}} ]}The HTTP status of this response is 200. The only request-level hint is "errors": true. The items come back in the same order as the operations, and each has its own status. They fall into three classes, and a bulk client needs a rule for each:
| Item status | Meaning | What the indexer does |
|---|---|---|
2xx | Applied | Nothing |
409 on an external-version write | Elasticsearch already has this or a newer version | Treat as success |
429, or 5xx | Temporary: rejected under load, or a node failed | Retry only these items, with backoff |
Other 4xx | Permanent: the document or request is wrong | Do not retry; send to a dead-letter queue with the error |
Chapter 14 implements exactly this table in Kotlin, on top of the Java client’s bulk helper.
Common mistake. Checking only the HTTP status. The nickname document was never indexed, and nothing at the HTTP level says so.
Count documents
_count runs a query and returns only the number of matches. Refresh first so that recent writes are visible:
POST ch03-profiles/_refresh
GET ch03-profiles/_count?filter_path=count{ "query": { "term": { "accountStatus": "ACTIVE" } } }{"count":3}The three active profiles are 3, 11, and 20. Profile 1 is suspended, and profiles 2 and 10 are deleted. Chapter 14 uses _count to compare an index against PostgreSQL before an alias swap.
Change documents in place with the by-query APIs
What it is. _update_by_query runs a query and applies a script to every match. _delete_by_query deletes every match. Both take a point-in-time view of the matching documents and process them in batches.
Why it matters at 300 million profiles. They are the tempting shortcut for a data fix: “rename a city everywhere”. At this scale they run for a long time, create many deleted documents and merge work, and, as the next experiment shows, can break the versioning your sync pipeline relies on.
Example. Rename a city with a Painless script:
POST ch03-profiles/_update_by_query?conflicts=proceed&refresh=true&filter_path=total,updated,version_conflicts,failures{ "query": { "term": { "city": "Kochi" } }, "script": { "source": "ctx._source.city = params.to", "params": { "to": "Kochi (Ernakulam)" } }}{"total":2,"updated":2,"version_conflicts":0,"failures":[]}conflicts=proceed counts version conflicts instead of aborting on the first one. Now look at profile 20, which the bulk request wrote at external version 1:
GET ch03-profiles/_doc/20?filter_path=_version,_source.city{"_version":2,"_source":{"city":"Kochi (Ernakulam)"}}The update-by-query bumped the version to 2. When PostgreSQL’s next real change to profile 20 arrives as external version 2, Elasticsearch rejects it:
PUT ch03-profiles/_doc/20?version=2&version_type=external{"userId":20,"fullName":"Anjali Nair","city":"Kochi","accountStatus":"INACTIVE","updatedAt":"2026-09-11T10:00:00Z"}version_conflict_engine_exception: [20]: version conflict, current version [2] is higher or equal to the one provided [2]status: 409The indexer treats this 409 as “already applied”, so the change to INACTIVE is lost without any error. The projection now disagrees with PostgreSQL and nothing will correct it until the next change to that profile.
Principle. In a projection with external versions, never modify documents inside Elasticsearch. Fix the data in PostgreSQL and let the change flow through the pipeline, or rebuild the index from the source (chapter 14).
Common mistake. Running a large by-query request synchronously from a script or a tool with an HTTP timeout. The request keeps running on the cluster after the client gives up, and you lose the result. Run it as a task instead, which the next section shows.
Long-running work runs as a task
Any request that accepts wait_for_completion=false returns a task ID immediately and runs in the background:
POST ch03-profiles/_delete_by_query?conflicts=proceed&wait_for_completion=false{ "query": { "term": { "accountStatus": "SUSPENDED" } } }{"task":"zTj-BSTiRmeOpfWK_7mHaQ:12569"}Poll it with the tasks API, using the ID from your own response:
GET _tasks/zTj-BSTiRmeOpfWK_7mHaQ:12569?filter_path=completed,task.action,response.total,response.deleted,response.failures{"completed":true,"task":{"action":"indices:data/write/delete/byquery"}, "response":{"total":1,"deleted":1,"failures":[]}}The same pattern applies to _reindex, which chapter 14 uses to copy data between index versions.
Inspect the cluster with the cat and cluster APIs
The _cat APIs return aligned text tables for humans. Every one accepts v for headers and h to choose columns. These are the ones used most often in this series:
| Request | Answers |
|---|---|
GET _cat/health?v | Is the cluster green, yellow, or red? |
GET _cat/indices?v | How many documents, deleted documents, and bytes per index? |
GET _cat/shards?v | Where is each shard, and in what state? |
GET _cat/segments?v | How many segments, and how many deleted documents in each? |
GET _cat/nodes?v | Which nodes, with which roles, heap, and load? |
GET _cat/thread_pool/write,search?v&h=name,active,queue,rejected | Is the cluster rejecting work? |
Look at the scratch index after this chapter’s writes:
GET _cat/indices/ch03-profiles?v&h=health,index,pri,rep,docs.count,docs.deletedhealth index pri rep docs.count docs.deletedgreen ch03-profiles 1 0 3 13Three live documents, and thirteen deleted ones still occupying space in this run: every replace, update, and delete in this chapter left one behind until a merge removes it. Your deleted count may be lower if a merge has already run. That ratio is what a high update rate looks like, and chapter 15 discusses when it matters.
When a shard will not allocate, _cat tells you that, and the allocation explain API tells you why. Create an index that wants a replica the single node cannot host, and ask:
PUT ch03-yellow{ "settings": { "number_of_shards": 1, "number_of_replicas": 1 } }
GET _cluster/allocation/explain{ "index": "ch03-yellow", "shard": 0, "primary": false }{ "index": "ch03-yellow", "shard": 0, "primary": false, "current_state": "unassigned", "can_allocate": "no", "node_allocation_decisions": [{ "deciders": [{ "decider": "same_shard", "explanation": "a copy of this shard is already allocated to this node [[ch03-yellow][0], node[...], [P], s[STARTED], ...]" }] }], ...}The same_shard decider forbids placing a replica on the node that holds its primary: the rule behind chapter 02’s yellow cluster. In production, the same API explains shards blocked by disk watermarks, allocation filters, or a missing node. Delete the index:
DELETE ch03-yellowProduction note —
_catoutput is for people. Scripts and monitoring should call the JSON APIs, such as_cluster/health,_nodes/stats, and_stats, because_catcolumn formats are not a stable contract. Chapter 16 builds monitoring on those.
Query with ES|QL
ES|QL is a piped query language that runs on the _query endpoint. It is well suited to ad-hoc analysis and aggregation while you explore data:
POST _query?format=txt{ "query": "FROM ch03-profiles | STATS profiles = COUNT(*) BY city | SORT city" } profiles | city---------------+-----------------1 |Chennai2 |Kochi (Ernakulam)The user-facing search in this series uses the Query DSL through _search, because it needs relevance scoring, highlighting, and cursor pagination, which chapters 11 and 13 build. Treat ES|QL as the tool you use to look at the projection, not the API behind the search box.
Clean up
Warning —
DELETEremoves the index and all its documents permanently. Everything inch03-profileswas created in this chapter.
DELETE ch03-profilesWhat you can do now, and what comes next
You can write, read, and version single documents; send bulk requests and classify every item’s result; run by-query changes as tasks; and inspect indices, shards, and allocation decisions. You have also reproduced the two traps that decide how a projection is kept in sync: delete tombstones that expire after gc_deletes, and by-query changes that break external versioning.
This chapter did not cover search itself. The Query DSL gets its own chapter (11), after the mapping that decides what queries can match (chapters 05 to 07).
Chapter 04 designs the synchronisation pipeline from PostgreSQL to Elasticsearch: why a dual write is unsafe, how a transactional outbox and change data capture compare, and how external versions, per-profile ordering, and delete handling fit together into an idempotent indexer.