Skip to content

Grafana Dashboard Validator · Observability

Find the import traps before the dashboard ships.

22 rules over a real parse of your dashboard JSON: the template variable nothing declares, the ${DS_PROMETHEUS} placeholder provisioning will never resolve, the AngularJS panel Grafana 12 removed, the panel with no type that draws an empty box. Every finding names the JSON path it lives at — and nothing you paste leaves the tab.

Runs in your browser — nothing you paste leaves this page. How we prove that

Runs in your browser 22 rules grafana-12 / schemaVersion 41 No signup Updated Jul 31, 2026

Grafana Dashboard Validator playground

Examples

The uid is the stable identifier you choose and keep in git; the id is a row number belonging to one Grafana database, and it should be null in any file you commit. schemaVersion records which of Grafana's own dashboard-format migrations have already been applied to the JSON.

Rules pinned to grafana-12 / schemaVersion 41 — every version-sensitive finding is phrased as a range, never as one exact release.

dashboard.json input

Results update as you type — press Enter to run now.

Press Esc to release keyboard focus from the editor; /Ctrl + Enter lints and leaves the editor. Dashboards are too large for a shareable link, so use Save snapshot instead — it stays in this browser. Nothing you paste is uploaded.

Findings

Paste a Grafana dashboard JSON above — or tap an example — to see every finding here, with the JSON path it lives at and the fix.

The Gap

A dashboard that imports is not a dashboard that works.

Grafana is forgiving in the worst possible way. An undefined variable is not an error — the query simply runs with $env still inside it, so the panel is empty, or matches far more series than you meant and is populated and wrong. A panel with no type draws a blank box. A repeat over a variable that does not exist renders exactly one panel and looks finished. Nothing in the interface objects to any of it.

And a dashboard is the one artefact nobody can review. A four-thousand-line JSON diff where gridPos values shifted, ids were renumbered and fieldConfig was rewritten by the UI is not a diff a human reads — it is a diff a human approves. The defects that survive review are the ones that look like every other line in the file.

Ask an assistant for a dashboard and you inherit the same class of bug with more confidence attached. Generated dashboards are fluent: plausible panel titles, plausible PromQL, a templating block — and queries that reference variables the block never declares, a schemaVersion picked from a model's memory of some older Grafana, and panel types that were removed two major releases ago. Verifying the claim takes seconds. Knowing what to verify is the hard part, so this tool does that part: every rule is listed on this page, and so is everything it deliberately refuses to flag.

Reviewing the queries too? PromQL Explainer breaks a query into plain English, and Alertmanager Route Tester proves where the alerts those panels watch actually land.

The Pipeline

How it works.

Four steps, all inside your browser tab, re-run as you type.

  1. Parse it, and say what that took.

    Strict JSON first. Then a byte-order mark, `// comments`, trailing commas, an API `{ dashboard: … }` wrapper or a dashboard stored as an escaped string — each one recovered and each one reported, because Grafana’s API is not this forgiving.

  2. Flatten every panel layout.

    Top-level `panels`, the children a collapsed row holds, the siblings an expanded row owns, and pre-schemaVersion-16 `rows[]` — all into one list where each panel remembers the JSON path it came from.

  3. Index the variables.

    Every string in the document is scanned for `$var`, `${var}`, `${var:format}` and `[[var]]`, matched against what `templating.list` declares plus Grafana’s built-ins. Used, unused and unresolved fall out of the same index.

  4. Run 22 rules, then report a path and a fix.

    Each rule runs inside its own try/catch, so one tripping costs a single note instead of the other findings. Every diagnostic names the path, the reason and the change to make — ready to paste into a review.

Reference

Variables, schema versions, and all 22 rules.

The rule set is pinned to grafana-12 / schemaVersion 41, and every version-sensitive finding is phrased as a range rather than one exact release. 7 rules are errors, 11 are warnings and 4 are notes.

The four variable syntaxes

All four resolve today, and two things that look like variables are not variables at all. The linter reads every one of these out of every string in the document — queries, titles, legend formats, panel links, annotation queries and other variables' queries.

Form Since What to know
$env Always Ends at the first character that is not a letter, digit or underscore — so "$env-prod" is the variable env followed by the literal text "-prod".
${env} Grafana 6 The current form. Unambiguous next to surrounding text, and the only one that can carry a format.
${env:regex} Grafana 6 A formatted interpolation — regex, csv, json, pipe, glob and others — which is what makes a multi-value variable safe inside a query.
[[env]] Pre-Grafana 6 Deprecated. Still resolves, cannot take a format. Reported as legacy-var-syntax.
$__rate_interval Grafana 7.2 A built-in, not one of yours. Every name beginning with two underscores is treated as a built-in and never reported.
${DS_PROMETHEUS} Not a template variable at all: an __inputs import placeholder. Reported as unresolved-ds-input.

schemaVersion milestones

The Grafana column is the release range each migration shipped in, not a one-to-one mapping: Grafana bumps schemaVersion inside minor releases, so treat these as landmarks rather than a lookup table.

schemaVersion Grafana What changed
16 5.x Panels moved out of "rows" into a top-level "panels" array, and gridPos replaced span. Below this, everything about a dashboard's layout is stored differently.
36 8.3–9.x A panel's "datasource" became a { type, uid } reference instead of a name string. This is the migration behind most "works on my instance" imports.
39 11.x The schema current through the Grafana 11 line, where Angular panels were disabled by default.
41 12.x The newest schema this linter knows. Anything above 41 is reported as a note, never an error.

The rule catalog

An error means Grafana does something other than what the JSON says; a warning means it loads and it is wrong or unportable; a note is worth knowing. Every finding in the playground links to its rule here.

no-uid warning

Give the dashboard a uid

Without a uid, every import creates a NEW dashboard instead of updating the one already there — so the same file imported twice leaves two copies, and every link anyone saved points at whichever one is now stale. Grafana accepts up to 40 characters of letters, digits, hyphens and underscores.

Fix "uid": "api-slo"

root-id-set warning

id must be null in a committed file

id is a row number inside one Grafana database. Carried into another instance it either fails the import or lands on a dashboard that has nothing to do with yours. Grafana assigns its own, always.

Fix "id": null

empty-title warning

Title the dashboard

A missing or blank title lists as "New dashboard", which is unfindable in search and indistinguishable from every other unnamed board somebody created by accident.

Fix "title": "API SLO"

duplicate-panel-id error

Panel ids must be unique

Grafana keys panel links, "View panel" URLs and repeats by panel id. Two panels sharing one id break all three, and nothing in the interface says so — the second panel simply stops being addressable.

Fix Renumber one of them; ids only have to be unique inside this dashboard

duplicate-variable error

Variable names must be unique

Two entries in templating.list with the same name: Grafana keeps the last one and silently drops the first, so the variable's type, query and default are whichever the second declaration happened to set.

Fix Rename or delete one of them

schema-version-old warning error below 16

Re-export an old schemaVersion

schemaVersion records which of Grafana's own format migrations the JSON has already been through. Below 36 a panel's datasource is still a name string; below 16 panels still live inside rows. Grafana migrates on load, but the file in your repository does not — so a review of that file is a review of something Grafana will never render.

Fix Open it in Grafana 9 or newer and export again

schema-version-unknown note

A schemaVersion this linter has not seen

Newer than the pinned 41, missing entirely, or written as a string instead of a number. Reported as a note and never as an error: an unknown schema is a limit of this linter, not a defect in your dashboard.

Fix Nothing to change — the note marks the findings below as advisory

undefined-variable error warning inside a regex

Every $variable must be declared

A reference nothing declares is left in the query as literal text, so the query runs with "$env" still inside it and the panel returns nothing — or, worse, returns something plausible. Grafana's own built-ins ($__rate_interval, $__from, $__range and the rest) are known and never reported, and neither is $1, which is a regex backreference.

Fix Add it under templating.list, or fix the spelling

unused-variable note

A variable nobody reads

Declared but never referenced by any panel, query, title, link or annotation. Harmless in itself — except that a "query" variable nobody reads still runs its query on every single dashboard load.

Fix Delete it, or use it

legacy-var-syntax warning

[[var]] is the pre-Grafana 6 form

It still resolves, and it is still deprecated. It is also the only form that cannot carry a format, so a value that needs to be a regex alternation or a CSV list has to be rewritten before it can be formatted at all.

Fix "${env}", or "${env:regex}" when the query needs a pattern

datasource-by-name warning

Reference datasources by uid, not name

A name resolves only if a datasource with exactly that name exists on the target instance. That single difference is what separates a dashboard that imports from one that shows "Datasource not found" on a colleague's Grafana. The { type, uid } form has been what Grafana writes since schemaVersion 36.

Fix "datasource": { "type": "prometheus", "uid": "P1809F7CD0C75ACF3" }

unresolved-ds-input error

${DS_…} needs the import dialog

"Export for sharing externally" replaces every datasource with an __inputs placeholder that only Dashboards → Import fills in. Provision that same file instead and Grafana reports "Datasource ${DS_PROMETHEUS} not found" — and a ${DS_…} reference with no __inputs block at all fails that way even through the dialog.

Fix Import through the dialog, or substitute the real { type, uid } first

empty-targets warning

A panel with no query

No targets at all, so the panel renders empty. Panel types that never query — row, text, dashlist, news, alertlist, annolist — are not reported, and neither are library panels, whose queries live in the library rather than in this file.

Fix Add a target, or delete the panel

deprecated-panel-type warning

graph, singlestat and table-old

Grafana 9–12 migrates these when the dashboard loads, which is exactly why they are worth flagging: what you review in the JSON is not what anybody will see on screen. Re-saving from Grafana 9 or newer writes the migration into the file, so the review and the render finally agree.

Fix "type": "timeseries" / "stat" / "table"

angular-panel error

AngularJS panel plugins

Angular support was deprecated in Grafana 9 and removed across Grafana 11–12. Unlike the core types above, a plugin has no automatic migration — so the panel does not degrade, it renders nothing.

Fix piechart for grafana-piechart-panel, geomap for grafana-worldmap-panel

repeat-undefined error

A repeat over nothing

A repeat naming a variable that does not exist produces one panel and no error message. A row meant to fan out over every cluster quietly shows a single cluster, and the dashboard looks finished.

Fix Declare the variable, or remove "repeat"

time-range-absurd warning

A default range nobody wants

Ranges longer than a year, ranges that run backwards, zero-length ranges, and expressions Grafana cannot parse at all. Every panel runs the default range the moment the dashboard opens, which makes this the cheapest performance mistake in the file to fix.

Fix "time": { "from": "now-6h", "to": "now" }

refresh-aggressive warning

A refresh under ten seconds

Below ten seconds the queries queue faster than they finish, in every tab that has the dashboard open, and the datasource pays for all of them. Grafana's own min_refresh_interval setting may override you anyway, which means the JSON says one thing and the instance does another.

Fix "refresh": "1m"

override-suspect note

An override that applies nothing

A byName matcher with no value matches no field; an override with an empty properties array sets nothing. Grafana keeps both in the JSON and applies neither, so they read like configuration that is doing something.

Fix Give it something to match and something to set, or delete it

empty-row note

A row with no panels

A collapsed row with an empty panels array is invisible until somebody expands it and finds nothing inside. An EXPANDED row is not reported when its panels follow it as siblings — that is how Grafana actually stores them, and reporting it would fire on every modern dashboard.

Fix Delete the row, or move panels into it

panel-no-type error

A panel with no type

Nothing tells Grafana what to render, so it draws an empty box where the panel should be. A panel without a type has usually been hand-edited, merged badly, or produced by a generator. Library panels are the one legitimate exception — Grafana saves them as {id, title, gridPos, libraryPanel} and this rule skips them.

Fix "type": "timeseries"

panel-zero-size warning

A panel that occupies no space

A gridPos with a width or height of zero is invisible. A missing gridPos makes Grafana fall back to a default position, where panels can land on top of each other. The grid is 24 columns wide.

Fix "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }

The Fence

What it deliberately does not flag.

Every one of these was considered and rejected, and the same list sits in a comment at the top of the engine. Silence you can read is worth more than a rule you learn to ignore.

Full schema validation

This is a structural lint, not a schema check. Grafana’s dashboard schema is large, versioned and still moving; a partial schema check reported as a schema check would be a lie.

PromQL, LogQL and SQL inside targets

A query language deserves its own tool. The PromQL Explainer does that job properly instead of this one half-parsing expressions.

Anything that needs your Grafana

Whether a uid is taken, whether a plugin is installed, whether a folder exists. There is no network here, so guessing would be inventing.

Typos inside $__names

Everything beginning with two underscores is treated as a Grafana built-in, because Grafana keeps adding them. Calling next year’s built-in "undefined" would be worse than missing a typo.

Panels with no title

Text panels and single-stat tiles are legitimately untitled. Only the DASHBOARD title is required.

gridPos overlap and layout geometry

Grafana repacks the grid when the dashboard loads, so an overlap in the JSON is not an overlap on screen.

Legacy rows[] panel sizing

Pre-schemaVersion-16 layouts used span, not gridPos. Reporting a missing gridPos there would fire on every panel of a dashboard whose real problem is already an error.

A refresh this linter cannot parse

Guessing what "1m30s" resolves to is guessing, and the finding would be about the guess rather than the dashboard.

Deprecated fields inside panel options

They are per-plugin, they move every release, and Grafana migrates them on load. A rule about them would be stale within a minor version.

Limits, stated rather than hidden: the linter reads up to 5,000,000 characters, keeps at most 50 findings per rule and 400 in total, and tells you the real count whenever a cap applies.

Next Step

Paste the report into the review.

"Copy report" gives you the whole run as plain text — one line per finding, with the JSON path, the rule id and the fix, and the pinned rule version at the top so nobody has to guess what checked it. Then keep going: read the queries those panels run, and prove the alerts they watch reach somebody.

report.txt
Grafana Dashboard Validator — 7 errors, 12 warnings, 3 notes
  — variables: 2 defined, 2 unresolved, schemaVersion 27
Rules: grafana-12 / schemaVersion 41

ERRORS (7)
  error duplicate-panel-id (panels[1].id): Panel id 1 is
    already used by "Requests" (panels[0]).
  error undefined-variable (panels[2].targets[0].expr):
    "$cluster" is used here, but no template variable named
    "cluster" is defined and it is not a Grafana built-in.

FAQ

Questions, answered.

Tap a question to expand the answer.

22 rules over a real parse of the dashboard: 7 errors, 11 warnings and 4 notes. The errors are the ones that change what Grafana does — a template variable nothing declares, a ${DS_…} placeholder with no __inputs block, an AngularJS panel that renders nothing on Grafana 11–12, two panels sharing an id, a repeat over a variable that does not exist, a panel with no type, and a schemaVersion so old that the JSON and the render are different dashboards. The warnings are the portability and review problems: a missing uid, a database id left in a committed file, a datasource referenced by name, a deprecated graph or singlestat panel, an invisible zero-width panel, a default time range longer than a year, a sub-ten-second refresh. Every rule has its own subsection on this page, and the playground's rule chips link straight to it.

No. The parser and all the rules are JavaScript running in your tab — there is no server, no API call and no logging, so 0 bytes are uploaded. That matters more here than for most tools: a dashboard JSON is a map of your internal estate. Metric names, datasource uids, hostnames in legend formats, service names in variable queries, sometimes an internal URL in a panel link. It is exactly the file you should not paste into a random online formatter.

No, and it says so rather than implying otherwise. Grafana’s dashboard schema is large, versioned and still moving, so a partial schema check presented as a schema check would be worse than no check at all. What this does is a structural lint: it reads the shapes that break imports and reviews — variables, datasource references, panel types, ids, layout, time and refresh — and names the JSON path of each finding. It does not parse PromQL, does not know your plugins, and does not talk to a Grafana. The full list of what it deliberately stays silent about is in "What it deliberately does not flag" above.

Because the file was produced by "Export for sharing externally". That export replaces every datasource reference with a placeholder — ${DS_PROMETHEUS} — and adds an "__inputs" block describing what each placeholder needs. Only the Dashboards → Import dialog reads that block and prompts you to pick a real datasource. Provisioning the same file, POSTing it to the API, or committing it into a Git-synced folder skips the dialog entirely, so the placeholder survives into the saved dashboard and every panel fails to resolve it. Either import it through the dialog, or substitute the real { type, uid } before you provision it. This linter reports the placeholder as an error either way, because the file cannot be provisioned as it stands.

The uid is the identifier you choose. It is part of the dashboard URL, it is what provisioning and the API address, and it should live in version control alongside the JSON. The id is a row number in one Grafana instance’s database — it means nothing anywhere else. A file with a uid updates the same dashboard every time it is imported; a file without one creates a new copy on every import. A file with a stale id either fails the import or targets a dashboard that has nothing to do with yours, which is why an export should carry "id": null.

The core graph, singlestat and table-old panels are migrated automatically when the dashboard loads, so they keep working — but the JSON in your repository is not migrated, which means the file you review and the panel Grafana renders are two different things. Re-save the dashboard from Grafana 9 or newer to write the migration into the file. AngularJS panel PLUGINS are a different story: grafana-piechart-panel, grafana-worldmap-panel and the rest have no automatic migration, and Angular support was deprecated in Grafana 9 and removed across Grafana 11–12, so those panels render nothing and have to be replaced by hand.

The rule set is pinned to grafana-12 / schemaVersion 41, printed on this page and on every copied report so a report is never ambiguous about what checked it. Version-sensitive rules are phrased as ranges — "Grafana 9–12", "removed across Grafana 11–12" — rather than as one exact release, because your instance is somewhere near this point rather than exactly on it. If your dashboard's schemaVersion is higher than 41, you get an INFO note saying so and the schema-specific findings are marked advisory. An unknown schema is a limit of this linter, not a defect in your dashboard, so it is never reported as an error.

Because Grafana does not fail — it interpolates nothing and runs the query with the literal text still in it. A PromQL selector of {env="$env"} matches no series, so the panel is empty; a regex-matching selector can match far MORE series than you meant, so the panel is populated and wrong. Neither case produces a message anywhere in the interface. The one exception this linter makes is a reference inside a string that looks like a regular expression, where "$" is also an end-of-line anchor: there the finding drops to a warning, because the "$env" you wrote may genuinely have been an anchor followed by text.

Up to 5,000,000 characters, which covers a generated dashboard of several thousand panels; a 500-panel dashboard lints in a few milliseconds. Past that limit it refuses with a message rather than freezing your tab, because an input that big is a log or an archive rather than a dashboard. Findings are capped too, at 50 per rule and 400 in total, and the panel states the cap and the real count whenever one applies — a truncated list that did not say it was truncated would be the same kind of quiet wrongness this tool exists to catch.

More free, private DevOps tools.

The Grafana Dashboard Validator is one tool in OpsCanopy — a growing canopy of browser-based validators, converters and testers that never touch a server.

39 free tools, every one offline-capable — opscanopy.com works with no signup and nothing uploaded.

Related: PromQL Explainer for the queries inside the panels, Alertmanager Route Tester for where the alerts beside them land, the Prometheus Relabel Tester for the labels your variables filter on, and the JSON ↔ YAML Converter when the provisioning file around the dashboard needs reshaping — or browse the full tools directory.

Not affiliated with, endorsed by or sponsored by Grafana Labs. "Grafana" is a trademark of Raintank, Inc. dba Grafana Labs, used here only to describe what this tool reads. Provided as-is for convenience; always confirm a dashboard against the Grafana that will render it. OpsCanopy is free and open.