Query DSL basics: Elasticsearch best practices explained
Writing efficient Elasticsearch queries is a skill that separates engineers who reach for trial-and-error from those who ship reliable search in production. The Query DSL (Domain Specific Language) is your toolkit for building searches that are both fast and correct, but it's easy to write queries that scan too much data or miss results entirely. Over the next few sections, we'll walk through the core query types and show you how to combine them effectively.
Understanding the Query DSL
Elasticsearch exposes search through a JSON-based query language. Rather than SQL-like syntax, you describe what you're looking for in terms of matching conditions, and the engine decides how to execute it efficiently. This is fundamentally different from traditional databases because Elasticsearch has to reason about relevance and text analysis, not just equality.
The Query DSL has two main contexts: query context (affects relevance scoring) and filter context (yes/no matching, cacheable, no scoring). We'll see this distinction matter in real code shortly.
match — Full-text search on a text field
The match query is your workhorse for searching text. It analyzes the query input and the field value using the same analyzer, then looks for overlap. This is how you implement a search box that "just works."
Consider an e-commerce product catalog. A customer types "ceramic mug" into the search bar. We want to find products whose name, description, or tags contain those terms in any order:
GET products/_search
{
"query": {
"match": {
"name": "ceramic mug"
}
}
}Elasticsearch will tokenize "ceramic mug" into ["ceramic", "mug"], then check which documents in the name field contain one or both tokens. Documents with both terms rank higher than those with only one. By default, match uses OR logic (the operator parameter controls this).
A real-world scenario: a SaaS platform searches customer support tickets. A support agent types "reset password fails" and expects to see tickets mentioning any of those terms. Here's the query:
GET support_tickets/_search
{
"query": {
"match": {
"description": {
"query": "reset password fails",
"operator": "or"
}
}
}
}You can also require all terms to be present by changing operator to "and", or use minimum_should_match to set a threshold (e.g., at least 2 of 3 terms). The fuzziness parameter lets you tolerate typos—useful for user-facing search:
GET products/_search
{
"query": {
"match": {
"name": {
"query": "cermic mug",
"fuzziness": "AUTO"
}
}
}
}This will still match "ceramic mug" even though "cermic" is misspelled.
term — Exact match on keyword fields
While match analyzes both the query and the field, term does not. It looks for an exact match of the query value against the field's indexed terms. Use term for fields that should not be analyzed: product IDs, status codes, category slugs, enum values.
In a ride-hailing platform, rides have a status field with values like "accepted", "in_progress", "completed". Searching for rides with status "completed" should return only those rides, not "completed_with_rating" or anything else:
GET rides/_search
{
"query": {
"term": {
"status": "completed"
}
}
}A common mistake is using term on a text field that has been analyzed. If you index "ceramic mug" in a text field, it's stored as ["ceramic", "mug"], so term searching for "ceramic mug" as a whole phrase will fail.
When you define your mapping, keyword fields (not analyzed) are ideal for term queries. If you need exact phrase matching on text, use match_phrase instead:
GET products/_search
{
"query": {
"match_phrase": {
"name": "ceramic mug"
}
}
}The match_phrase query requires the tokens to appear in the exact order and within a certain distance (slop).
range — Numbers and dates between bounds
The range query finds documents where a field value falls within a boundary. It works on numeric types, dates, and IP addresses. This is essential for filtering by price, date range, or version.
In a travel booking system, we want to show hotels with a nightly rate between $80 and $200:
GET hotels/_search
{
"query": {
"range": {
"nightly_rate": {
"gte": 80,
"lte": 200
}
}
}
}The operators are:
gte(greater than or equal)gt(greater than)lte(less than or equal)lt(less than)
For date ranges, Elasticsearch understands ISO 8601 formats and date math. Retrieve all orders from the past 7 days:
GET orders/_search
{
"query": {
"range": {
"created_at": {
"gte": "now-7d",
"lt": "now"
}
}
}
}The "now-7d" syntax is date math, evaluated at query time. You can also use "2024-01-01T00:00:00Z" for absolute dates. Combine range with other queries via bool to filter results—we'll cover that next.
bool — Combine must, should, filter and must_not
Real queries rarely consist of a single condition. The bool query composes multiple queries together using boolean logic. It has four clauses:
- must: All clauses must match (AND logic). Affects relevance score.
- should: At least one clause should match (OR logic). Affects score.
- filter: All clauses must match (AND logic). Does not affect score; cacheable.
- must_not: No clauses should match (NOT logic). Does not affect score.
Here's a practical example: you're building a job search for an HR platform. You need to find jobs that match a candidate's skills, are in their preferred location, preferably pay above their target salary, and exclude any remote-only roles (assume the candidate prefers onsite or hybrid):
GET jobs/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"description": "Python Django"
}
}
],
"filter": [
{
"term": {
"location": "San Francisco"
}
},
{
"range": {
"salary_min": {
"gte": 120000
}
}
}
],
"should": [
{
"range": {
"salary_max": {
"gte": 180000
}
}
}
],
"must_not": [
{
"term": {
"work_type": "remote"
}
}
]
}
}
}Breaking this down:
- The job description must mention "Python Django" (relevance matters).
- Location must be "San Francisco" and salary must start at $120k or more (filter—cached, no scoring).
- We prefer jobs with a max salary of $180k or higher (boosts score but isn't required).
- We exclude any remote-only roles.
The distinction between must and filter is important for performance. Filters don't compute relevance scores and are cached by Elasticsearch, so they're cheaper. Use filter for categorical constraints (status, location, category) and must when relevance scoring matters (full-text search terms).
Another common pattern is nested bool queries for complex logic. For example, find products where the brand is Nike OR Adidas AND the price is under $150:
GET products/_search
{
"query": {
"bool": {
"must": [
{
"bool": {
"should": [
{ "term": { "brand": "Nike" } },
{ "term": { "brand": "Adidas" } }
],
"minimum_should_match": 1
}
},
{
"range": {
"price": {
"lte": 150
}
}
}
]
}
}
}Common mistakes
We've touched on a few already, but here are the pitfalls that burn us most often:
Using term on analyzed text fields: If a field is analyzed (the default for text fields), the indexed value differs from what you'd expect. Always check your mapping. Use keyword subfields for exact matching on text:
{
"mappings": {
"properties": {
"status": {
"type": "keyword"
}
}
}
}Forgetting filter context when relevance doesn't matter: Wrapping a status check in must instead of filter wastes CPU on scoring. It won't change the result set, only the speed.
Over-using match_phrase: Phrase queries are slower than individual term matches. Use them only when word order is semantically important (e.g., "New York" vs. "York New").
Not considering analyzer mismatch: If you index with a standard analyzer but query with a different one, you'll see unexpected results. Index-time and query-time analyzers should be consistent.
Ignoring minimum_should_match in bool queries: If you have multiple should clauses and no must clauses, Elasticsearch requires at least one should to match by default. But it's easy to misunderstand the behavior—explicitly set it if your intent differs.
Key takeaways
matchis for full-text search; it analyzes the input and finds overlapping tokens.termfinds exact matches on keyword fields; never use it on analyzed text without a keyword subfield.rangehandles numeric and date filtering; learn date math for relative queries like "past 7 days."boolcomposes queries withmust,should,filter, andmust_notto express complex logic.- Use
filterfor categorical constraints (status, location) to save CPU and enable caching; usemustfor relevance-sensitive full-text terms. - Always verify your field mappings; the analyzer applied at index time determines what queries will work.
- Test with real data and real query patterns—production traffic often exposes edge cases that lab testing misses.






Comments
Loading comments…