The Elasticsearch REST API: A Practical ES Cheat Sheet
A working Elasticsearch cheat sheet for the REST API — index and get docs, the query DSL match vs term, bool queries, aggregations, mappings, and cat APIs.
The Elasticsearch REST API: A Practical ES Cheat Sheet
Almost everything you do with Elasticsearch goes through one HTTP endpoint at a time. There is no special client protocol you must learn first — you PUT an index, POST a document, and GET a search, and the cluster answers with JSON. Once that clicks, the whole system stops feeling like a black box. This post walks the REST API basics the way I actually use them, with requests you can paste straight into Kibana Dev Tools or pipe through curl.
If you want the full searchable reference while you read, keep the Elasticsearch cheat sheet open in another tab. It has 80+ entries, each with a pasteable example and a one-line production pitfall.
Index, Get, and Search
The three verbs you reach for first are creating an index, indexing a document, and reading it back. Be explicit about shard count — the old default of 5 was almost always wrong for small indices.
PUT /products
{ "settings": { "number_of_shards": 1, "number_of_replicas": 1 } }
POST /products/_doc
{ "name": "Aurora Wireless Headset", "status": "active", "price": 89 }
GET /products/_doc/1
Searching is just a GET (or POST) to /<index>/_search with a query body. The simplest useful search asks "which docs match this text in this field":
GET /products/_search
{
"query": { "match": { "name": "wireless headset" } }
}
The response carries hits.total.value (how many matched) and hits.hits (the actual documents, ranked by _score). That _score is the difference between full-text and exact filtering, which is exactly where most people trip.
match vs term: The One That Bites Everyone
This is the single most common Elasticsearch bug report, so it earns its own section. match is a full-text query: it runs your search string through the same analyzer the field used at index time, so "Wireless Headset" becomes the tokens wireless and headset, lowercased. term is an exact, not analyzed query: it looks for the literal bytes you give it.
Here is the worked scenario. You map status as a text field, index "status": "active", and later try:
GET /products/_search
{ "query": { "term": { "status": "active" } } }
Zero hits. Why? Because at index time active was analyzed into the token active, but term does not analyze, so it compares your string active against... the indexed token active — which actually matches here. The real trap appears with mixed case or multi-word values: term: { "status": "Active" } misses everything, because the indexed token was lowercased to active. The fix is the standard string recipe — map it as text with a keyword sub-field:
PUT /products/_mapping
{
"properties": {
"status": {
"type": "text",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
}
}
}
Then filter exact values on status.keyword with term, and search human text on status with match. When in doubt, POST /products/_analyze with the field's analyzer to see exactly how your string tokenizes. Use match when you want relevance ("headset" should find "Wireless Headset Pro"); use term on a keyword field when you want an exact category, status, or ID filter.
bool Queries: must, should, filter, must_not
Real queries combine conditions, and bool is how you do it. The four clauses each have a job:
must— conditions that must match and affect score.filter— conditions that must match but are not scored (and get cached, so they are fast).should— optional boosts; with nomust, at least oneshouldmust match.must_not— exclusions.
A typical "active products under 100 dollars, ideally matching 'wireless'" query puts the cacheable exact conditions in filter and the relevance term in must:
GET /products/_search
{
"query": {
"bool": {
"must": { "match": { "name": "wireless" } },
"filter": [
{ "term": { "status.keyword": "active" } },
{ "range": { "price": { "lte": 100 } } }
],
"must_not": { "term": { "discontinued": true } }
}
}
}
The rule of thumb I follow: anything that is a yes/no WHERE clause goes in filter, because you do not need a relevance score for "is this active" and the filter cache makes repeat queries cheap. Save must and should for the text relevance that actually deserves scoring.
Aggregations: Grouping and Metrics
Aggregations answer "group by X, compute Y" over millions of docs in sub-second time. The two workhorses are terms (group-by) and the metric aggs (avg, sum, stats). You nest them to get a metric per bucket:
GET /products/_search
{
"size": 0,
"aggs": {
"by_status": {
"terms": { "field": "status.keyword", "size": 10 },
"aggs": { "avg_price": { "avg": { "field": "price" } } }
}
}
}
"size": 0 tells ES to skip returning documents and just compute the buckets. Note the agg runs on status.keyword, not status — aggregations need a non-analyzed field. For time series, swap terms for date_histogram; for "top sellers per category" panels, nest top_hits inside terms. One caveat worth remembering: terms buckets are approximate at high cardinality, and cardinality (distinct count) is a HyperLogLog++ estimate, not exact. If you need exact distinct counts beyond ~100k, that is a database job, not an aggregation.
cat APIs for Ops
When something is on fire, you do not want JSON — you want a quick table. The _cat APIs print human-readable rows, and ?v adds a header line:
GET /_cat/indices?v
GET /_cat/nodes?v
GET /_cat/health?v
GET /_cat/shards?h=index,shard,prirep,state,unassigned.reason&v
The last one is my go-to when a cluster goes yellow: it lists every shard, whether it is a primary or replica, its state, and why an unassigned shard could not be placed. Pair it with GET /_cluster/allocation/explain when you need the full reason in plain English. The most common yellow-cluster cause is a single node with number_of_replicas: 1 — a replica cannot live on the same node as its primary, so it stays unassigned forever until you add a node or drop replicas to 0.
I keep these four _cat calls in my shell history permanently. During an incident at 2am, the difference between reading a clean table and parsing nested JSON is the difference between a five-minute fix and an hour of squinting. The cheat sheet's cluster-ops section has every _cat endpoint with the columns that actually matter, so I am never guessing the ?h= field names under pressure.
Quick Reference
| Task | Request | |------|---------| | Create index | PUT /products with settings | | Index a doc | POST /products/_doc | | Get by id | GET /products/_doc/1 | | Full-text search | GET /products/_search with match | | Exact filter | term on field.keyword | | Combine conditions | bool with must / filter / must_not | | Group + metric | terms agg + avg sub-agg, "size": 0 | | Inspect mapping | GET /products/_mapping | | See tokenization | POST /products/_analyze | | List indices | GET /_cat/indices?v | | Diagnose yellow | GET /_cat/shards?...&v + allocation/explain |
Elasticsearch is rarely the only data store in a stack. If your services also lean on a cache or a relational store, the Redis cheat sheet and PostgreSQL cheat sheet cover the same paste-and-run style for those layers, so you can move across the whole data tier without re-learning each tool's quirks from scratch.
Start with index, get, and search. Add bool once you need real WHERE clauses, reach for aggregations when you want grouped metrics instead of raw docs, and keep the _cat APIs one keystroke away for when the cluster misbehaves. That handful of endpoints covers the large majority of day-to-day Elasticsearch work.
Made by Toolora · Updated 2026-06-13