v1.0 Start Reference FAQ Support
IntelliJ IDEA plugin · Elasticsearch DSL Debugger

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.

IntelliJ IDEA 2024.1+ Read-only against your cluster No telemetry
[ screenshot: the whole tool window at the Match stage ]
One query, mid-run at stage 4 of 10. Every panel is answering a different question at once: the index fields down the left, the query above, the ten stages across the middle, the clause graph below them carrying a count and a removal on every clause, and the Inspector on the right explaining whichever clause is under the cursor.

Start here

What it is, what it is not, and the first three minutes.

What it answersWhy a query returned what it did, and which clause is responsible.
Which languageThe Query DSL — the JSON bodies you send to _search. Not ES|QL, EQL, SQL or KQL.
What it is notAn editor. There is no completion, on purpose. Paste a query you already have.

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 thisOr 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

  1. Open the ES Debugger tool window.
  2. Choose a source: a bundled dataset, or your cluster and an index.
  3. Paste a search body, or pick one of the 43 examples in the sidebar.
  4. Press Debug query.
  5. 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

BadgeMeaning
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 docsMatches on its own — what this clause finds across the whole index, ignoring every other clause.
removed NRemoved here — how many of the documents that actually reached it were turned away. The number that matters.
removed 0Shown 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 1A should gating membership through minimum_should_match.
optionalA should beside a must or a filter: it affects score, never membership, and does not appear in the funnel at all.
disjunctionA 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.
disabledSwitched 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.

Simulation — the clause funnel from Debug query to Fetch. Arrow keys move too.

Switching clauses off

DisableTake 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.
IsolateSwitch off everything else, to see what one clause matches alone.
Enable everythingPut 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. removed is 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

NumberSource
N docsthat clause run by itself
removed Ndifference of two running intersections
rank badgeour ordering, by measured count
stage timingsElasticsearch 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.

[ screenshot: Why not? — one row per clause, the rejecting one in red ]
One row per clause, with the clause that turned the document away named above.

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.

Simulation — ten documents keyed on a unique 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 atYou can compare
Parse, kNN, Rewritenothing — no run is recorded before Match
Matchclause 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-level default_search, or one named on the clause
  • Uses search_quote_analyzer for phrase clauses, which is a common surprise
  • Says plainly when a keyword field is not analysed at all
Simulation — field 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().

[ screenshot: Performance — time by clause, with the slowest at the top ]
Where the time went, clause by clause. One clause taking half the total is the funnel’s argument applied to milliseconds.

The other six tabs

ExplainWhat a clause will actually do, read against your mapping and written in words — including the clauses that do not mean what they look like.
DataThe documents still alive at the step you stopped on, with a field summary. Right-click any row to ask why it survived.
ScoreWhy one document ranked above another: BM25, term by term, with the numbers behind it.
AggsThe buckets and the arithmetic that produced them. Where an offline answer is an approximation, it says so and says why.
RequestExactly what was sent, including any clauses you switched off. JSON with line numbers and folding.
ResponseExactly 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.

[ screenshot: Diagnose — a finding with its evidence and Review and apply ]
A finding, its evidence, and the change shown before it touches the editor. The diagnosis says what is certain, the suggestion says what it would cost, and the hit-set change is measured rather than predicted.

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 evaluationEvery clause type the examples use, run against each document, so the funnel counts are exact set intersections over that data.
AnalysisA tokenizer, a light stemmer, a stop list and the common filters — enough to make text behave plausibly, and not Lucene.
AggregationsBucket and metric aggregations, and the pipeline aggregations that can be computed from them.
ScoringBM25 over the sample corpus, so ordering is explicable rather than arbitrary.
ScriptsA 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_fn among them — report not simulated offline and produce no counts.
  • Fetch sub-phases such as fields and docvalue_fields say 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

LimitWhat 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 profile output.

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 standard and the two sides get compared against the wrong chain. It refuses rather than guessing.
  • One field and one text at a time. A multi_match over 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 size covers 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’s time_in_nanos; the plugin times nothing itself.
  • Per-clause time has its children subtracted, so a bool is 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 took and 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

StageRequest
ParseGET /<index>/_validate/query
kNNskipped unless the query has a top-level knn
RewriteGET /<index>/_validate/query?explain=true&rewrite=true
MatchPOST /<index>/_search, plus one _msearch carrying a count per clause and one per prefix of the conjunction — that pair is the funnel
ScorePOST /<index>/_search with explain
AggregatePOST /<index>/_search?typed_keys=true
Sort & collectPOST /<index>/_search with profile
Rescoreskipped unless the query has a rescore
ReducePOST /<index>/_search?typed_keys=true
FetchPOST /<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+SpaceField or index list, in any box that has one
Scroll · Shift+scroll · dragZoom, 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
TelemetryNone. Nothing about your queries or your cluster leaves your machine.
CredentialsThe IDE password safe, not project files.
Queries and historyYour project’s workspace.xml.
AI or cloud servicesNone. The plugin talks to your cluster and nothing else.

Common questions

Why does a clause show a huge count but 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.

Why is “removed” bigger than “matches on its own”?

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.

My query has no results and every clause looks fine.

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.

Does disabling a clause change my query?

No. The editor is untouched. The body actually sent is in Request, and the header counts how many clauses are switched off.

Why does Compare say nothing changed when I edited the query?

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.

Why is there no autocomplete?

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.

Can I use it without a cluster?

Yes. Two bundled datasets and 43 worked examples. Everything works offline except Text analysis, which reads _analyze and says so.

Does it support ES|QL, EQL or SQL?

No — the Query DSL only. For SQL, POST /_sql/translate gives you the DSL body without running it, and you can debug that here.

Is it safe to point at production?

It cannot write. Every request goes through a read-only allow-list, and the full list of what it sends is in Reference.