Feature
Deletes & updates
Segments are immutable, so nothing is ever edited in place. An update is a full-document upsert keyed on id, and a delete is a tombstone that removes a document from every query's view until compaction drops it for good.
Updates are upserts
Indexing a document whose id already exists replaces it. There is no separate update endpoint and no partial update: the fields you send become the document, and fields you omit are gone.
{
"documents": [{
"id": "d1",
"fields": [
{"name": "title", "field_type": "Text", "value": "Breach notification policy (rev 2)"},
{"name": "status", "field_type": "Keyword", "value": "published"}
]
}]
}
Read-modify-write is on you: fetch the current document, change what you need, and send the whole thing back. Sending only the changed field silently drops the rest.
Within a batch
Repeating an id inside a single request is allowed and resolves last-wins, so a batch built from a stream of events does not need to be deduplicated first.
What it costs
If the previous version is still in the write buffer, replacing it is free — the stale copy is dropped before the buffer is ever written out. If it has already been flushed into a segment, the upsert takes a rewrite path over the segments holding those ids, which is much more expensive than an append.
For high-churn workloads that matters: prefer batching updates so the rewrite is
amortised over many ids, and prefer append-only modelling — a new document per
version, with a Keyword field marking the current one — when the
update rate is high enough that rewrites dominate.
Delete by query
Deletes are expressed as a filter, using exactly the
filter DSL that search uses. There is no
delete-by-id endpoint; a term on the id field is how you spell one.
{
"filter": {
"bool": {
"must": [
{"term": {"tenant": "acme"}},
{"range": {"created_at": {"lt": "2025-01-01"}}}
]
}
}
}
{"deleted": 1284, "namespace": "acme-corp/my-index"}
query is accepted as an alias for filter, so an
OpenSearch-shaped delete body works unchanged. Deleting from a namespace that
does not exist is a 404; a filter that matches nothing is a
success with "deleted": 0.
Dry-run your filter first
Run the identical clause as a search filter and read
total_hits before you run it as a delete. The two paths evaluate
the same clause the same way, so the count you see is the count you will
remove — including the traps on the filtering page, where a
term on an Integer field matches nothing at all.
Tombstones
A delete does not touch the segment holding the document. It records a tombstone
in the namespace's manifest, and every subsequent query skips tombstoned
documents while scoring. The effect is immediate and complete for readers: a
deleted document cannot appear in results, contribute to
total_hits, or be returned by a neighbour search.
What it does not do is reclaim space. The bytes stay in object storage until compaction merges that segment, drops the tombstoned documents, and publishes a new manifest without them. The old segment is deleted only after a grace period long enough that no in-flight query could still be holding a reference to it.
Two practical consequences:
- Storage does not shrink when you delete. It shrinks when the affected segments next compact.
- Heavily tombstoned segments still cost. Their postings are still read; the tombstoned documents are filtered out during scoring. A namespace that has deleted most of its content benefits from compaction well before its segment count would otherwise call for it.
Deleting a namespace
For bulk removal, a match_all delete empties a namespace's
documents:
{"filter": {"match_all": {}}}
A namespace-level delete endpoint exists in the API contract but is not implemented by the server yet, so this is the route today. It tombstones every document rather than dropping the segments outright — worth knowing before reaching for it as a cheap reset. Recreating under a fresh namespace name is faster.
Checking what is there
exists answers which of a list of ids are present, without fetching
the documents. It is the cheap way to deduplicate an ingest batch against what
you have already written.
{"ids": ["d1", "d2", "d3"]}
The response separates the ids that are present from the ones that are missing. Like search, it only sees flushed documents — an id sitting in the write buffer is not yet visible.
Visibility
Writes and deletes alike are governed by the flush boundary. An indexed document becomes queryable when the buffer it sits in is written out as a segment, either automatically at the flush threshold or immediately when you call flush:
{}
# → {"status": "flushed"}
Read-your-writes is therefore bounded by the flush interval rather than immediate — the same trade OpenSearch makes with its refresh interval, made explicit. Durability is not bounded by it: an indexed document is appended to a write-ahead log in object storage before the call returns, so it survives losing the node whether or not it has been flushed into a segment yet.