03 — Basic search
Elasticsearch was built for search, and this chapter is where that comes alive. You will learn how to express queries in the JSON-based Query DSL, understand the response envelope, and combine the fundamental building blocks —
match,term,range, andbool— to answer real business questions against your data.
Goals for this chapter
By the end of this chapter you should be able to:
- Send a well-formed search request via Kibana Dev Tools and read every important field in the response.
- Explain the difference between full-text (
text) fields and exact-match (keyword) fields and choose the right query type accordingly. - Use
match,term,terms,range, andboolqueries on theshop-orders-demodataset. - Understand how Elasticsearch calculates a relevance score and why
queries inside a
filterclause have no score. - Control which documents are returned, how many, and in what order.
3.1 Query DSL — request structure
The anatomy of a search request
Elasticsearch exposes its search functionality via a REST endpoint. The most important pattern you will use every day is:
GET /shop-orders-demo/_search
That alone is a valid request — it returns up to 10 documents with no filtering. But real searches carry a JSON body:
GET /shop-orders-demo/_search
{
"query": {
"match_all": {}
}
}
match_all matches every document in the index. It is the
simplest possible query and the right starting point when you want to
explore the data.
Alternative: the ?q=
URI search
You may occasionally see the shorthand URI search:
GET /shop-orders-demo/_search?q=order_status:delivered
This uses Lucene query syntax in the URL. It is fine for one-off exploration in the terminal, but it is not recommended for application code — it is harder to express complex logic, escaping is tricky, and it is easy to make mistakes. Use the JSON Query DSL for everything else.
Reading the response envelope
Run the match_all query above and look at the response
carefully. Here is an annotated version:
{
"took": 3,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 10000,
"relation": "gte"
},
"max_score": 1.0,
"hits": [
{
"_index": "shop-orders-demo",
"_id": "order-00001",
"_score": 1.0,
"_source": {
"order_id": "order-00001",
"order_status": "delivered",
"total_amount": 1250.00,
"customer": {
"name": "Jana Nováková"
}
}
}
]
}
}| Field | What it means |
|---|---|
took |
How many milliseconds the search took on the server side (network latency not included). |
_shards.total |
How many shards were queried. |
_shards.successful |
How many responded successfully. If this is less than
total, some results may be missing. |
hits.total.value |
The count of matching documents. |
hits.total.relation |
"eq" means the count is exact; "gte" means
there are at least that many (Elasticsearch stops counting at
10 000 by default for performance). |
hits.max_score |
The highest relevance score among all matched documents. |
hits.hits[] |
The actual documents returned (up to size, default
10). |
hits.hits[]._id |
The document’s unique identifier in this index. |
hits.hits[]._score |
How relevant this document is to the query (higher is better). |
hits.hits[]._source |
The original JSON you indexed, returned verbatim. |
Tip:
hits.total.valuetells you how many documents matched, not how many were returned. The returned count is controlled bysize— see section 3.8.
3.2 Full-text search vs exact match — the core concept
This is the single most important concept to internalize before writing queries.
Elasticsearch stores fields in two fundamentally different ways depending on their mapping type:
| Field type | What happens at index time | What you use to query it |
|---|---|---|
text |
The value is analyzed — broken into tokens, lowercased, stop words may be removed, etc. | match, match_phrase |
keyword |
The value is stored as-is, byte for byte. | term, terms |
Example with the dataset:
customer.nameis atextfield. The value"Jana Nováková"is indexed as the tokensjanaandnováková. Amatchquery for"jana"will find it.order_statusis akeywordfield. The value"delivered"is stored exactly asdelivered. Atermquery for"delivered"will find it; a query for"Delivered"(capital D) will not.
Why does this matter right now? Using the wrong query type for the field type is the most common beginner mistake.
termon atextfield almost always returns nothing (section 3.4 explains why in detail). Choosing correctly is straightforward once you know the field’s type.
The full story — how analyzers work, what tokenization does, and how
to check your index mapping — is covered in Chapter 04 — Mapping
and analyzers. For now, just remember the rule:
text → match; keyword →
term.
In our dataset, fields like customer.name and
notes are text. Fields like
order_status, channel,
payment_method, delivery.country_code, and
order_id are keyword.
3.3
match query — full-text search on analyzed fields
Use match when searching fields that hold human-readable
text and are mapped as text.
Basic example
Search for orders where the notes field mentions
“urgent”:
GET /shop-orders-demo/_search
{
"query": {
"match": {
"notes": "urgent"
}
}
}
Elasticsearch analyzes the query string the same way it analyzed the
documents when they were indexed. So this will match notes containing
“urgent”, “Urgent”, or “URGENT” — because both the stored tokens and the
query token end up as urgent.
Multi-word queries and the OR default
If you provide multiple words, match applies OR
logic by default:
GET /shop-orders-demo/_search
{
"query": {
"match": {
"notes": "urgent delivery problem"
}
}
}
This returns documents where the notes field contains any of those three words. Documents containing all three will score higher, but documents containing only one will still appear.
Requiring all words with
operator: and
To require that every word is present, set operator to
and:
GET /shop-orders-demo/_search
{
"query": {
"match": {
"notes": {
"query": "urgent delivery",
"operator": "and"
}
}
}
}
Now only documents containing both “urgent” and “delivery” (in any order, anywhere in the field) are returned.
match_phrase —
words must appear in order
When the order of words matters (for example, a customer’s full
name), use match_phrase:
GET /shop-orders-demo/_search
{
"query": {
"match_phrase": {
"customer.name": "Jana Nováková"
}
}
}
This requires both tokens to appear next to each other, in that
order. "Nováková Jana" would not match.
Tip:
customer.nameis atextfield, somatchandmatch_phraseare the right tools. If you need an exact case-sensitive match on the full name as written, usecustomer.name.keyword(a sub-field) with atermquery — covered in section 3.4.
3.4
term query — exact match on keyword, numeric, and boolean
fields
Use term when you need an exact, case-sensitive match on
a keyword, numeric, date, or boolean field.
Basic example
Find all orders with status delivered:
GET /shop-orders-demo/_search
{
"query": {
"term": {
"order_status": "delivered"
}
}
}
The value "delivered" must match exactly.
"Delivered" or "DELIVERED" would return zero
results.
More examples
Find all orders placed via the web channel:
GET /shop-orders-demo/_search
{
"query": {
"term": {
"channel": "web"
}
}
}
Find orders that have been returned (returned is a
boolean field):
GET /shop-orders-demo/_search
{
"query": {
"term": {
"returned": true
}
}
}
The term on a
text field pitfall
Pitfall: If you run
termon atextfield, you will almost always get zero results — even if the value exists. Why? Because the stored tokens are lowercase (e.g.,jana), but you are querying the original string (e.g.,"Jana Nováková"). The comparison is exact, byte for byte, and they do not match. Always usematchfortextfields.
Matching multiple values
with terms
When you want to match any one of several values, use the
terms query (plural):
GET /shop-orders-demo/_search
{
"query": {
"terms": {
"order_status": ["delivered", "shipped"]
}
}
}
This is equivalent to
order_status = 'delivered' OR order_status = 'shipped' in
SQL. You can include as many values as needed in the array.
Another example — orders paid by card or bank transfer:
GET /shop-orders-demo/_search
{
"query": {
"terms": {
"payment_method": ["card", "bank_transfer"]
}
}
}
3.5 range
query — numeric and date ranges
Use range when you want to filter by a numeric value or
date interval.
Operators
| Operator | Meaning |
|---|---|
gte |
greater than or equal to |
gt |
greater than (strictly) |
lte |
less than or equal to |
lt |
less than (strictly) |
Numeric range
Find orders where the total amount is between 500 and 2000 CZK:
GET /shop-orders-demo/_search
{
"query": {
"range": {
"total_amount": {
"gte": 500,
"lte": 2000
}
}
}
}
Find high-value orders (total over 5000):
GET /shop-orders-demo/_search
{
"query": {
"range": {
"total_amount": {
"gt": 5000
}
}
}
}
Date range with date math
Elasticsearch supports date math — a compact syntax
for expressing relative dates. The anchor now means “this
instant”.
| Expression | Meaning |
|---|---|
now |
Current timestamp |
now-30d |
30 days ago |
now-1h |
1 hour ago |
now/d |
Start of today (rounds down) |
2024-01-01 |
Fixed absolute date |
Find orders created in the last 30 days:
GET /shop-orders-demo/_search
{
"query": {
"range": {
"created_at": {
"gte": "now-30d",
"lte": "now"
}
}
}
}
Find orders created in 2024:
GET /shop-orders-demo/_search
{
"query": {
"range": {
"created_at": {
"gte": "2024-01-01",
"lt": "2025-01-01"
}
}
}
}
Tip: When you use
rangeon a date field inside aboolfilter clause (see section 3.6), Elasticsearch can cache the result efficiently. This is the recommended pattern for time-based filtering — always preferfilterovermustfor ranges. The reason is explained in section 3.6.
3.6 bool query —
combining conditions
The bool query is the workhorse of Elasticsearch. Almost
every real-world query is a bool with multiple clauses. It
lets you combine any number of conditions using four types of
clauses:
| Clause | Documents must match? | Affects _score? |
Use for |
|---|---|---|---|
must |
Yes | Yes | Conditions that are required AND should influence relevance ranking |
filter |
Yes | No | Conditions that are required but do NOT need to affect score — faster, cacheable |
should |
No (by default) | Yes | Conditions that boost score when they match; optional |
must_not |
Must NOT match | No | Exclusions |
Scoring rule of thumb
Use filter for exact values, ranges,
date windows, booleans, and any condition where you just want to narrow
results down — Elasticsearch caches filter results and skips scoring, so
it is faster.
Use must for full-text queries where
the quality of the match should influence how results are ranked.
A realistic example
Find orders that: - Were shipped to the Czech Republic OR Slovakia
(filter) - Have status delivered or
shipped (filter) - Have a total amount above
1000 (filter) - Mention “poškozeno” (damaged) in the notes
— and this should influence ranking (must) - Are NOT
returned (must_not)
GET /shop-orders-demo/_search
{
"query": {
"bool": {
"filter": [
{
"terms": {
"delivery.country_code": ["CZ", "SK"]
}
},
{
"terms": {
"order_status": ["delivered", "shipped"]
}
},
{
"range": {
"total_amount": {
"gte": 1000
}
}
}
],
"must": [
{
"match": {
"notes": "poškozeno"
}
}
],
"must_not": [
{
"term": {
"returned": true
}
}
]
}
}
}
Documents that contain “poškozeno” more prominently or in a shorter notes field will score higher and appear first. Documents that merely pass the filter conditions contribute no score.
Using should —
optional boosts
should clauses are optional by default: they are not
required for a document to match, but if they do match, the document’s
score is boosted. This is useful for relevance tuning — for example,
preferring gold-tier customers:
GET /shop-orders-demo/_search
{
"query": {
"bool": {
"filter": [
{ "term": { "order_status": "processing" } }
],
"should": [
{ "term": { "customer.loyalty_tier": "gold" } },
{ "term": { "shipping_method": "express" } }
]
}
}
}
All processing orders are returned, but those belonging to gold-tier customers or using express shipping appear first.
minimum_should_match
By default, inside a bool that also has
must or filter clauses, none of the
should clauses need to match. If the bool has
only should clauses, at least one must match.
You can override this with minimum_should_match:
GET /shop-orders-demo/_search
{
"query": {
"bool": {
"should": [
{ "term": { "channel": "web" } },
{ "term": { "payment_method": "card" } },
{ "term": { "customer.loyalty_tier": "gold" } }
],
"minimum_should_match": 2
}
}
}
This requires at least two of the three should
conditions to match.
Tip: Think of
mustas SQLANDwith scoring,filteras SQLANDwithout scoring,shouldas “nice to have / boost”, andmust_notas SQLNOT. When in doubt, start withfilterfor constraints andmustonly for text fields where ranking matters.
3.7 Relevance and scoring
What is _score?
Every document in the hits.hits[] array has a
_score. This is a floating-point number that represents how
well the document matches the query. Results are sorted by
_score descending by default — the most relevant document
appears first.
When you search for “express delivery” in the notes
field, a document that mentions it five times in a short note will score
higher than a document that mentions it once in a long note.
Elasticsearch uses an algorithm called BM25 to compute
this.
BM25 at a glance
BM25 (Best Match 25) considers three things:
- Term frequency (TF): How often does the search term appear in this field? More occurrences → higher score.
- Inverse document frequency (IDF): How rare is this term across all documents in the index? Rare terms are more meaningful — they contribute more to the score. A term like “the” appears everywhere and contributes little; a term like “reklamace” (complaint) is more specific.
- Field length normalization: A term found in a short
notesvalue is more significant than the same term buried in a very long paragraph.
You do not need to tune BM25 for now. The default works well. What matters is understanding that score reflects quality of match, not just presence or absence.
Filter context has no score
Queries placed inside a filter or must_not
clause run in filter context — they evaluate to yes/no
and contribute 0 to the score. This is both a performance
optimization (Elasticsearch can cache filter results) and a feature: you
do not want your exact-match conditions to distort ranking.
Queries inside must and should run in
query context — they compute a score and influence
ranking.
Debugging with explain
When you are puzzled about why a document scored the way it did, use
explain: true on a single-document lookup:
GET /shop-orders-demo/_explain/order-00001
{
"query": {
"match": {
"notes": "poškozeno"
}
}
}
The response shows the full calculation tree — TF, IDF, field length normalization — for that specific document and query. It is verbose, so use it as a learning or debugging tool, not in production queries.
Tip: For most CRUD-style lookups where you do not care about ranking (e.g., “give me all orders for customer X”), put all conditions in
filter. You save both CPU and memory because scores are not computed and results are cached.
3.8 Results: hits, pagination, field selection, and sorting
Controlling
how many documents are returned (size)
By default, Elasticsearch returns 10 documents. Use size
to change this:
GET /shop-orders-demo/_search
{
"size": 25,
"query": {
"term": { "order_status": "delivered" }
}
}
Pagination with from
and size
from sets the offset (zero-based). This is analogous to
SQL’s LIMIT / OFFSET:
GET /shop-orders-demo/_search
{
"from": 20,
"size": 10,
"query": {
"term": { "order_status": "delivered" }
}
}
This returns documents 21–30 (page 3 of 10).
Pitfall: deep pagination is expensive. Elasticsearch must collect and sort
from + sizedocuments on every shard and then merge them. A request withfrom: 10000, size: 10is much more costly thanfrom: 0, size: 10. For deep pagination (scrolling through thousands of results), use thesearch_afterparameter instead — it is efficient because it uses the sort value of the last seen document as a cursor.search_afteris covered in Chapter 05 — Advanced querying.
Selecting which
fields are returned (_source)
By default, the full _source document is returned. For
large documents or bandwidth-sensitive applications, limit what comes
back:
GET /shop-orders-demo/_search
{
"size": 5,
"_source": ["order_id", "order_status", "total_amount", "customer.name"],
"query": {
"term": { "channel": "web" }
}
}
You can also exclude fields while including everything else:
GET /shop-orders-demo/_search
{
"_source": {
"excludes": ["notes", "tags"]
},
"query": {
"match_all": {}
}
}
Sorting results
By default, results are sorted by _score descending. You
can override this with explicit sort:
GET /shop-orders-demo/_search
{
"size": 10,
"sort": [
{ "created_at": "desc" },
{ "total_amount": "desc" }
],
"query": {
"term": { "order_status": "processing" }
}
}
This returns the most recently created processing orders, breaking ties by highest total amount.
Important: You can only sort on fields that are not analyzed — meaning
keyword, numeric (double,integer),date, andbooleanfields. You cannot sort on atextfield because the value has been broken into tokens and the original string is not directly sortable. If you need to sort by customer name, usecustomer.name.keyword(the un-analyzed sub-field). The full explanation ofkeywordsub-fields is in Chapter 04 — Mapping and analyzers.
Sorting and _score
together
When you specify an explicit sort, _score
is no longer computed by default (to save CPU). If you need both,
include _score in your sort list:
GET /shop-orders-demo/_search
{
"sort": [
{ "_score": "desc" },
{ "created_at": "desc" }
],
"query": {
"match": { "notes": "reklamace" }
}
}
Labs
Lab 1 — Explore the response envelope
Goal: Become comfortable reading the Elasticsearch response before writing complex queries.
- Run
GET /shop-orders-demo/_searchwithmatch_alland no other parameters. - Note the values of
took,hits.total.value,hits.total.relation, andhits.max_score. - Expand one document in
hits.hits[]and identify_id,_score, and_source. - Add
"size": 1to the request and re-run. Observe thathits.total.valuedoes not change — only the number of returned documents changes. - Add
"_source": ["order_id", "total_amount", "order_status"]and verify the response only includes those fields.
Takeaway: hits.total.value is always
the total number of matching documents;
hits.hits[] is only the current page.
Lab 2 — match vs
term in practice
Goal: Observe the difference between full-text and exact-match queries.
- Run a
termquery onorder_statuswith value"delivered"— note the result count. - Run the same
termquery with"Delivered"(capital D) — observe zero results. - Run a
matchquery oncustomer.namewith a partial first name (e.g.,"Jana") — observe matching documents. - Now try a
termquery oncustomer.namewith"Jana Nováková"— observe that you likely get zero results (because the field is analyzed and stored as lowercase tokens). - Switch to
customer.name.keywordwithtermand provide the exact full name as stored — observe results.
Takeaway: text fields →
match; keyword fields → term.
Mixing them up is the most common beginner mistake.
Lab 3 — Build a
bool query step by step
Goal: Practice combining multiple conditions into a
single bool query.
Build a query that finds:
Orders delivered to Germany (
delivery.country_code: DE) — usefilterWith total amount over 2000 — use
filterwithrangeThat are NOT cancelled — use
must_notWhere the notes mention “package” — use
mustwithmatch
Start with just one filter clause, run it, check the
count. Add the next clause, run again. Continue until you have the full
query.
Then add a should clause that boosts orders with
shipping_method: express and observe whether the ordering
of results changes.
Takeaway: Build bool queries
incrementally — it is much easier to debug one clause at a time.
Lab 4 — Pagination and sorting
Goal: Practice controlling result size, offset, and order.
- Write a query that returns
deliveredorders sorted bytotal_amountdescending, page size 5. - Add
from: 5to get the second page. Confirm the first document on page 2 has a lowertotal_amountthan the last document on page 1. - Add
_sourcefiltering to return onlyorder_id,total_amount,customer.name, anddelivery.city. - Try setting
from: 500and note that the query still works but is noticeably slower — this is the deep pagination cost.
Takeaway: Use from/size
for shallow pagination only. For large offsets,
search_after (Chapter 05) is the right tool.
Summary
- Every search request is a
GET /<index>/_searchwith a JSON body containing a top-levelqueryobject. The response envelope containstook,_shards, andhitswithtotal,max_score, and the array of matched documents. - Fields mapped as
textare analyzed (tokenized, lowercased) — query them withmatchormatch_phrase. Fields mapped askeywordstore values exactly as written — query them withtermorterms. Using the wrong query type is the most common beginner error; the full explanation is in Chapter 04. matchperforms full-text search; multiple words default to OR but can be changed to AND withoperator: and.match_phraserequires words to appear in order.termis for exact matches onkeyword, numeric, and boolean fields.terms(plural) matches any one of a list of values.rangesupportsgte,gt,lte,lton numeric and date fields. Date math (now-30d,now/d) makes time-based filtering concise.boolcombines conditions:filter(required, no score, cached),must(required, affects score),should(optional boost, controlled byminimum_should_match), andmust_not(excluded, no score). Usefilterby default for constraints; usemustonly when relevance ranking matters._scorereflects relevance using BM25 (term frequency, inverse document frequency, field length). Filter-context queries have no score. Useexplainto debug scoring on a specific document.- Control result size with
size, offset withfrom. Limit returned fields with_source. Sort withsorton non-analyzed fields. Avoid deepfrompagination — usesearch_afterinstead (Chapter 05).