Elasticsearch DSL Debugger
Find the clause that took your results away. Paste a query, watch it narrow clause by clause, with a document count after every step.
Start here
What it is, what it is not, and the first three minutes.
_search. Not ES|QL, EQL, SQL or KQL.What it needs before anything works
Three things, in this order. Skipping any of them produces a tool that looks like it is working.
| Do this | Or else |
|---|---|
| Check cluster | Confirms the connection and reports the version. Rules are version-aware, so a clause that was right on 8.11 and is deprecated on 8.15 is only flagged correctly once the version is known. |
| Name an index | The index box takes an index, an alias, a wildcard or a comma-separated list. Nothing runs without a target — a cluster alone is not somewhere to search. |
| Refresh fields | Reads the mapping. Most diagnostics need it, the field lists come from it, and Text analysis cannot resolve a field’s search analyzer without it — so it refuses rather than analysing with the wrong one. The tab says so and stays disabled until you press this. |
Three minutes
- Open the ES Debugger tool window.
- Choose a source: a bundled dataset, or your cluster and an index.
- Paste a search body, or pick one of the 43 examples in the sidebar.
- Press Debug query.
- Press Run all stages, then read the clause badges.
Results describe the run that produced them, not the text in the editor. Edit the query and the counts grey out and the header says so — press Debug query again to run what is now on screen. Nothing recalculates on its own, deliberately: a debugger that re-ran on every keystroke would hit your cluster on every keystroke.
The same applies to Disable, Isolate and Enable everything. They change which clauses go into the next request, and the numbers move when you press Re-run — not when you click the menu item.
Debug query, not Run. Pressing it sends GET /<index>/_validate/query and
stops at stage one. The first _search comes at the Match stage. Every stage shows
exactly what it sent in Inspector › Request.
Connecting a cluster
Settings holds your connections — URL plus basic auth, an API key, a bearer token, or AWS SigV4. Passwords go to the IDE password safe, never a project file.
Check cluster reports the version and whether the target resolves. Refresh fields reads the mapping, which is what makes field completion and most diagnostics possible.
The index box takes an index, an alias, a wildcard like logs-*, a comma-separated list,
or remote:index for cross-cluster search. Click it for the list.
Writing SQL? POST /_sql/translate returns the DSL a SQL statement would run,
without running it. Paste that body here and everything below applies. It is generated DSL rather
than written DSL — read it as a record of what ran, not as a style to copy.
The funnel
Where the documents went, clause by clause.
Elasticsearch runs a search in two phases: a query phase on every shard, then a fetch phase on the coordinating node. The debugger breaks that into ten steps you can stop at, each reporting how many documents are still alive.
Parse · kNN · Rewrite · Match · Score · Aggregate · Sort & collect · Rescore · Reduce · Fetch
Step with Next stage and Previous stage, or jump to the end with Run all stages. Stopping matters because you get a document count after each one — that is how you find the step where they went.
Reading the badges
| Badge | Meaning |
|---|---|
lead 2nd 4th= |
The order the conjunction is walked, cheapest first — one walk across the whole
query, not one per bool, so the ranks and the removed numbers
beside them describe the same pass. Ties share a rank and carry =, because
which of two equals runs first is not a fact about your query. A bool inside
must_not is opaque, so its children carry no rank: that walk never
happens. |
N docs | Matches on its own — what this clause finds across the whole index, ignoring every other clause. |
removed N | Removed here — how many of the documents that actually reached it were turned away. The number that matters. |
removed 0 | Shown in red, not amber. The clause turned nothing away: it is costing a scorer on every shard and narrowing nothing. This is the finding to act on, and the one number in the funnel that does not depend on the order we chose. |
msm 1 | A should gating membership through
minimum_should_match. |
optional | A should beside a must or a
filter: it affects score, never membership, and does not appear in the funnel at
all. |
disjunction | A should in a bool with no
must and no filter. Elasticsearch requires one of them even with no
minimum_should_match written, so the group does narrow — and it enters
the funnel as a single clause, not one per branch. |
disabled | Switched off by you, and absent from the request that ran. |
The two numbers are unrelated, and the second is often much larger. A filter removes everything that does not match it. A clause matching 70,000 documents out of half a million removes the other 430,000 when it runs first, and almost none of them when it runs last. The first says how big the clause is; the second says what it did.
Step through it
Starts where you would: a query in the editor and nothing run yet. Press Next to press Debug query, then walk the ten stages the way the debugger builds them. Every count comes from the bundled E-commerce catalogue — 48 documents.
Switching clauses off
| Disable | Take a clause out and press Re-run. Disabling can only widen a result, so the clause that brings your rows back is the one at fault. |
| Isolate | Switch off everything else, to see what one clause matches alone. |
| Enable everything | Put it all back. |
Your query in the editor is never edited. The body actually sent is in Inspector › Request, and the header counts how many clauses are switched off.
Navigating a large query
Scroll to zoom, drag to pan, Shift+scroll to pan sideways. Find a clause jumps to a field or type. Collapse all, Expand all and Fit handle the shape; Indented tree is there for queries wider than the screen.
Where the numbers come from
Which parts are Elasticsearch’s own output, which are arithmetic on real queries, and which are ours.
Elasticsearch runs a search in two phases and does not stop between them, so the ten steps are the debugger’s framing rather than something the engine exposes. Each one asks a fresh question and shows you the request it sent.
Inside a bool, the result is the intersection of the clauses. Two quantities follow
from that and are measured directly, one search each:
- What a clause matches alone — that clause run by itself against the index.
- The running intersection — the first n clauses run together.
removedis the difference between one of these and the next.
Against a cluster, every number in the funnel is the count of a query that was really run. The
_msearch carrying them is in the Request tab, and any line of it pasted into
Kibana returns the same number.
Two things worth knowing about them
removed depends on the order the clauses are walked, and that order is ours
— measured counts, cheapest first, which approximates what Lucene does rather than reproducing
it. matches alone does not depend on it. A clause that removes zero placed last
removes zero in any order, which is why that finding is the one to act on.
The funnel checks itself. The last step is the whole conjunction, so its count must equal the count the query itself returned — two independent requests. If they disagree, the panel says so rather than showing you numbers that describe something other than your query.
Source of each number
| Number | Source |
|---|---|
N docs | that clause run by itself |
removed N | difference of two running intersections |
| rank badge | our ordering, by measured count |
| stage timings | Elasticsearch profile |
| Score breakdown | _explain |
| Why not? rows | _explain, one row per clause |
| Text analysis tokens | _analyze with explain: true |
Five of the seven are Elasticsearch’s own output, presented differently. One is arithmetic on queries that were really run. One is an ordering we chose.
That describes a run against a cluster. Offline the first three come from the plugin’s own evaluator, timings are absent rather than invented, and Text analysis is disabled — see the offline simulator.
The Inspector
Eleven tabs. Each answers a different question about the run — or, for Compare, about two of them.
Why not?
The question every bug report is phrased as: this document should be here and it isn’t.
Give an _id, or find the document by something you know — sku =
SKU-0000003. Up to three fields, combined with and. Every clause is then asked
about that one document separately, so a document failing three clauses reports all three
— not just the first one Lucene reached. When several documents match your key they stay in a
dropdown you can move between without re-running.
Compare
The rest of the Inspector answers what is happening. This answers did my edit work.
Every run past the Match stage is remembered. Change something, run again, and this shows what moved: which clauses, which documents, which buckets, and any stage whose status changed.
The finding it exists for is the one a count cannot give you. A query going from 47 hits to 47 hits looks unchanged; if three of those documents are different ones, it is not.
sku, so every number is exact.By default a run is measured against the one before it. Several edits into chasing a regression that compares two broken states, so Pin as baseline holds the run that was last correct.
The document rows compare a page, not every match. With size: 10 against
83,500 matches, re-sorting changes the whole page and reads as ten arriving and ten leaving —
true of the page, nothing about the rest. Raise size, or narrow the query, to compare
everything. The panel says so whenever the page is a slice.
Comparisons last for the session and are not saved: a baseline from last week would be measured against an index that has changed underneath it, so most of what you saw would be documents being written and deleted rather than anything your query did.
How far you run decides what you can compare
| Stopped at | You can compare |
|---|---|
| Parse, kNN, Rewrite | nothing — no run is recorded before Match |
| Match | clause counts and removals, but no documents |
| Fetch (all stages) | everything, including which documents changed |
Stepping one stage at a time and pressing Run all stages give the same result if you stop in the same place — only where you stop matters.
Text analysis
Why a match finds nothing when the words look right.
A search succeeds when the tokens a field stored and the tokens your query produces agree. This shows both, stage by stage, with the words each filter added or removed.
- Position, length and offset for every token, so multi-word synonyms and shingles are visible
- Names which analyzer ran and what chose it — the field’s
search_analyzer, an index-leveldefault_search, or one named on the clause - Uses
search_quote_analyzerfor phrase clauses, which is a common surprise - Says plainly when a
keywordfield is not analysed at all
title_en, search text ny, both chains to the point where they part company.Right-click a match in the graph and choose Analyze to open it with the field,
the text and any clause analyzer already filled in. This tab needs a cluster: it reads
_analyze.
Performance
Where the milliseconds went, by phase, by clause, by shard, from Elasticsearch’s own profiler.
Each clause is named in your DSL — wildcard sku : *00042* — rather than in
Lucene’s toString().
The other six tabs
| Explain | What a clause will actually do, read against your mapping and written in words — including the clauses that do not mean what they look like. |
| Data | The documents still alive at the step you stopped on, with a field summary. Right-click any row to ask why it survived. |
| Score | Why one document ranked above another: BM25, term by term, with the numbers behind it. |
| Aggs | The buckets and the arithmetic that produced them. Where an offline answer is an approximation, it says so and says why. |
| Request | Exactly what was sent, including any clauses you switched off. JSON with line numbers and folding. |
| Response | Exactly what came back, same treatment. |
Diagnostics
38 rules for the mistakes that never raise an error.
The ones that return a result rather than a failure, so nothing tells you they happened. A
term on a text field is the familiar example; most of the others are less obvious.
What sets them apart is not the list. Every finding carries its evidence — the numbers it was derived from — and a suggested fix. Several prove the fix by running both queries and comparing the results, so you see that it worked rather than being told it should.
Review and apply shows the change before it touches your editor.
The offline simulator
How the plugin runs a query with no cluster, and where that stops being the same thing.
Pick a bundled dataset instead of a connection and the whole tool still works: ten stages, the funnel, per-clause counts, aggregations, sorting, scoring. There is no Elasticsearch behind it. The plugin carries its own query engine and runs your query against 48 or 96 in-memory documents.
It is there so the tool can be learned, and a query understood, without a connection — on a plane, in a review, or before you have credentials for anything. The 43 worked examples all run through it.
What it does itself
| Query evaluation | Every clause type the examples use, run against each document, so the funnel counts are exact set intersections over that data. |
| Analysis | A tokenizer, a light stemmer, a stop list and the common filters — enough to make text behave plausibly, and not Lucene. |
| Aggregations | Bucket and metric aggregations, and the pipeline aggregations that can be computed from them. |
| Scoring | BM25 over the sample corpus, so ordering is explicable rather than arbitrary. |
| Scripts | A small subset of Painless — arithmetic and field access, not the language. |
Where it stops
The line matters more than the coverage, because a simulator that guesses is worse than no simulator. Where it cannot reproduce Elasticsearch it reports that it cannot, rather than producing a number:
- Pipeline aggregations it does not implement —
moving_fnamong them — report not simulated offline and produce no counts. - Fetch sub-phases such as
fieldsanddocvalue_fieldssay they were not simulated. - Counts that would be approximate on a real multi-shard index are labelled approximate, with the reason.
- Text analysis is disabled entirely. It reads
_analyze, and answering with our own analyzer would be telling you what we do while claiming to tell you what your cluster does. - Timings are absent rather than invented. There is no shard, so there is nothing to time.
Learn the tool on the bundled data; trust a number from the index it is about. The offline counts are exact for the sample corpus and produced by our code rather than by Elasticsearch. Those are not the same claim, and a finding you intend to act on should come from your own cluster.
Limitations
What the plugin caps, approximates, or cannot know. Each one is stated in the interface at the moment it applies; they are gathered here so none of them is a surprise.
Three kinds of thing are listed together, because someone who hits one cannot tell them apart: caps chosen to keep a request count sane, approximations where an exact answer is not available, and things Elasticsearch will not tell us.
The funnel
| Limit | What it means |
|---|---|
| Sampled above 12 clauses | Up to twelve, every step of the conjunction is measured. Above that it measures twelve spread points and then bisects every span that lost documents until each drop belongs to one clause. The whole conjunction is always measured, so the final count is your real result at any size, and where two clauses cannot be separated the panel says the drop belongs to the pair rather than guessing between them. |
| 60 clauses carry counts | The per-clause search carries sixty items. A query with more leaf clauses than that has no
N docs badge beyond the sixtieth. |
| Checked against the query | The last step is the whole conjunction, so its count must equal the count the query itself returned — two independent requests. The funnel flattens a tree into a chain, and that algebra is verified every run rather than asserted: if the two disagree, the panel says so and names both numbers. |
removed depends on the order |
And the order is ours: measured counts, cheapest first. Lucene orders by estimated cost, which is not exposed. A clause that removes zero placed last removes zero in every order — that finding survives it, and it is the one people act on. |
Results and documents
- The Data tab shows at most 500 documents — ten when the request has no
size. The stated total is always the real total. - Why not? takes three fields, combined with and: enough to identify a document, not a query builder.
- The per-shard listing stops at six shards — and nothing else does. That listing
costs one search per shard, so on a 100-shard index it would mean a hundred extra searches
against production. Timings, the clause table and the shard-skew finding are computed from
every shard, because they come from the single search’s
profileoutput.
Text analysis
- Needs a cluster, and needs the mapping loaded. The query side is resolved by
analyzer name, which comes from the mapping — without it the resolution falls
through to
standardand the two sides get compared against the wrong chain. It refuses rather than guessing. - One field and one text at a time. A
multi_matchover five fields is five checks.
Compare
- Six runs, this session only. A baseline from last week would be measured against an index that has changed underneath it.
- The document rows compare a page unless
sizecovers every match. - Nothing is recorded before Match, and documents only exist from Fetch.
- Timing changes under 50 ms are hidden as scheduling noise. Status changes never are.
Performance timings
- Elasticsearch’s numbers, not ours. Every figure is
profile’stime_in_nanos; the plugin times nothing itself. - Per-clause time has its children subtracted, so a
boolis not credited with the work of every clause inside it. - Shard work is not wall clock. Shards run in parallel, so the summed work exceeds
tookand is meant to. The panel states both. - Profiling changes what it measures — Elasticsearch’s own caveat. Read the proportions, not the milliseconds.
- Cache state is not controlled. A second run can be much faster because filter bitsets are cached, which is why Compare hides timing changes under 50 ms.
By design, and not planned
Deep pagination can be diagnosed but never demonstrated. _pit and
_scroll leave state on your cluster, so they are refused. The plugin will tell you that
from: 10000 is past the result window and what to do instead — it will not open a
point in time to show you. That is the read-only guarantee costing a feature, and it is the right
trade.
- Elasticsearch 8 and 9. Older majors are flagged at Check cluster. OpenSearch is named correctly and its version numbers are not modelled, so the version-aware rules do not apply to it.
- The Query DSL only — not ES|QL, EQL, SQL or KQL.
- Nothing re-runs on its own. A debugger that re-ran on every keystroke would reach your cluster on every keystroke.
- No completion, deliberately.
- Two paths not yet exercised against a live server: AWS SigV4 signing, and skipping TLS verification for a self-signed development certificate. Both are implemented; neither has met the thing it exists for. If either matters to you, write and it will be confirmed or fixed.
Reference
Every request the plugin makes, and where it makes it.
What each stage sends
| Stage | Request |
|---|---|
| Parse | GET /<index>/_validate/query |
| kNN | skipped unless the query has a top-level knn |
| Rewrite | GET /<index>/_validate/query?explain=true&rewrite=true |
| Match | POST /<index>/_search, plus one _msearch
carrying a count per clause and one per prefix of the conjunction — that pair is the
funnel |
| Score | POST /<index>/_search with explain |
| Aggregate | POST /<index>/_search?typed_keys=true |
| Sort & collect | POST /<index>/_search with
profile |
| Rescore | skipped unless the query has a rescore |
| Reduce | POST /<index>/_search?typed_keys=true |
| Fetch | POST /<index>/_search?typed_keys=true |
Outside the stage loop: Text analysis calls _analyze, Why not? calls
_search once per clause for the document you named, and Refresh fields reads
_mapping, _settings and _field_caps.
Keyboard
| Ctrl+Space | Field or index list, in any box that has one |
| Scroll · Shift+scroll · drag | Zoom, pan sideways, pan |
Next stage, Previous stage and Run all stages are in Find Action and bindable under Settings › Keymap.
Safety and privacy
What it can do to your cluster, and what leaves your machine.
It reads and it cannot write. Not by policy — by construction. Every request goes
through an allow-list of read-only endpoints, and every mutating one is refused before it is sent: no
_bulk, no _update, no _delete_by_query, no
_reindex, no PUT, no DELETE.
Endpoints that would leave server-side state — _pit, _scroll,
_async_search — are refused too. A point in time may be suggested to you;
the plugin never opens one.
| Allowed | _search _msearch
_count _explain _validate _analyze
_mapping _settings _field_caps _alias
_aliases _cat _cluster _nodes
_stats _resolve _terms_enum _synonyms |
| Telemetry | None. Nothing about your queries or your cluster leaves your machine. |
| Credentials | The IDE password safe, not project files. |
| Queries and history | Your project’s workspace.xml. |
| AI or cloud services | None. The plugin talks to your cluster and nothing else. |
Common questions
removed 0?
It matches almost everything, so by the time it runs there is nothing left for it to exclude. It is costing you a scorer on every shard and narrowing nothing. This is the funnel’s most common finding.
They count different things. A filter removes everything that does not match it, so a clause matching 70,000 of half a million removes the other 430,000 when it runs first.
Check Text analysis on any match clause. A stemmer, a stopword list or an index-level
default_search analyzer can mean the tokens your query produces were never the tokens
the field stored.
No. The editor is untouched. The body actually sent is in Request, and the header counts how many clauses are switched off.
A run is recorded when it reaches Match. If you edited and did not re-run, there is nothing new to compare. If you stopped before Match, no run was recorded at all.
Deliberate. This is a debugger, not an editor — and a position-blind completion that offers field names where query types belong is worse than none. Write your query in Kibana or in your code, then bring it here.
Yes. Two bundled datasets and 43 worked examples. Everything works offline except Text analysis,
which reads _analyze and says so.
No — the Query DSL only. For SQL, POST /_sql/translate gives you the DSL body
without running it, and you can debug that here.
It cannot write. Every request goes through a read-only allow-list, and the full list of what it sends is in Reference.