Skip to main content

mbql

Author and debug MBQL query bodies for the `mb` CLI — the query shape, clauses, filters, aggregation and breakout, order and limit, expressions, joins and FK columns, multi-stage pipelines, saved questions and definitions as sources, output column names, and the print-schema → dry-run → run loop. Use when writing or fixing any query body — `mb query`, a card's `dataset_query`, a transform's `source.query`, or a segment/measure `definition` — or when `--dry-run` or a run reports errors. Triggers — "write an MBQL query", "the dataset_query is wrong", "aggregate and group by", "join two tables", "month-over-month".

Source facts

Repository
metabase/metabase-cli
Last source activity
September 29, 2026 at 04:26
Detected SKILL.md language
English
Stars
16
Forks
6

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
mbql
description
Author and debug MBQL query bodies for the `mb` CLI — the query shape, clauses, filters, aggregation and breakout, order and limit, expressions, joins and FK columns, multi-stage pipelines, saved questions and definitions as sources, output column names, and the print-schema → dry-run → run loop. Use when writing or fixing any query body — `mb query`, a card's `dataset_query`, a transform's `source.query`, or a segment/measure `definition` — or when `--dry-run` or a run reports errors. Triggers — "write an MBQL query", "the dataset_query is wrong", "aggregate and group by", "join two tables", "month-over-month".
allowed-tools
Read, Write, Edit, Bash, AskUserQuestion
# MBQL MBQL is the structured query format: portable across warehouse engines, and a card built on it wires to dashboard filters as-is. Write a structured query first; write native SQL (a `mbql.stage/native` stage, see `native-sql`) for window functions beyond `offset`, CTEs, set operations, engine-specific functions, or when asked for SQL. Flag conventions, body input, output flags and `./.scratch` live in `core` (`mb skills get core`). ## The shape ```json { "lib/type": "mbql/query", "database": 1, "stages": [ { "lib/type": "mbql.stage/mbql", "source-table": 7, "aggregation": [["count", {}]], "breakout": [["field", { "temporal-unit": "month" }, 22]] } ] } ``` - `database`, `source-table`, `source-card` and field ids are numeric, copied from `mb database list`, `mb table get <id> --include fields`, `mb card list`. A wrong id that exists resolves to the wrong column without an error, so copy ids, never guess them. - The first stage names exactly one source: `source-table` (a table id) or `source-card` (a saved question or model id). Later stages name none; they read the previous stage's output columns. - Stage keys: `joins`, `expressions`, `filters`, `aggregation`, `breakout`, `fields`, `order-by`, `limit`. Each list holds at least one clause. ## Clauses Every clause is `[op, {options}, ...args]` with the options object at position 1, `{}` when there are none: ```json ["count", {}] ["sum", {}, ["field", {}, 42]] ["=", {}, ["field", {}, 61], "Gadget", "Widget"] ["field", { "temporal-unit": "month" }, 22] ``` An options key the server doesn't know is rejected or silently dropped, never applied, so spell keys exactly as written here. A field ref is `["field", {options}, <field id>]`. Its options: `temporal-unit` (bucket a date: `day`, `week`, `month`, `quarter`, `year`, …), `binning` (`{"strategy": "num-bins", "num-bins": 10}`), `join-alias` (a column of an explicit join), `source-field` (a column reached through an FK), `base-type` (on a column named by string, see Multi-stage). ## Filters, aggregation, breakout - `filters` are ANDed; OR is `["or", {}, a, b, …]`. Multi-value is `["in", {}, <field>, "a", "b"]` / `["not-in", …]`. - Relative dates: `["time-interval", {}, <field>, -30, "day"]`, `["time-interval", {}, <field>, "current", "month"]`. A stated year or date range is absolute: `["between", {}, <field>, "2024-01-01", "2024-12-31"]` (inclusive). - `aggregation` is a list of aggregation clauses, `breakout` a list of refs. "only / where X" is a filter; "by / per / over time" is a breakout. - Name every aggregation a later stage, a chart setting or a target table reads: `["sum", {"name": "revenue", "display-name": "Revenue"}, <field>]`. Unnamed, it is `count`, `sum`, `avg`, … and a second `sum` is `sum_2`. - An aggregation can be arithmetic over aggregations: `["/", {"name": "aov"}, ["sum", {}, <field>], ["count", {}]]`. - Date arithmetic is `["datetime-diff", {}, a, b, "day"]`. ## Order and limit `order-by` holds `["asc", {}, <ref>]` / `["desc", {}, <ref>]`. To order by an aggregation of the same stage, set a `lib/uuid` from `mb uuid --format text` (the bare value) in that aggregation's options and point the ref at the same string: ```json "aggregation": [["count", {}], ["sum", {"name": "revenue", "lib/uuid": "<uuid>"}, ["field", {}, 40]]], "breakout": [["field", {}, 43]], "order-by": [["desc", {}, ["aggregation", {}, "<uuid>"]]], "limit": 10 ``` A query read back from Metabase carries a `lib/uuid` on every clause, and each must be unique within the query: drop it from a clause you duplicate. "Top / first / latest N" is `order-by` plus `limit: N` in the stage. ## Expressions `expressions` is a list of clauses, each named by `lib/expression-name` in its options; `["expression", {}, "<name>"]` uses one in the same stage: ```json "expressions": [["+", {"lib/expression-name": "Subtotal"}, ["field", {}, 40], ["field", {}, 44]]], "aggregation": [["sum", {}, ["expression", {}, "Subtotal"]]] ``` ## Joins **A column of an FK-related table:** put the FK column's id in `source-field` and the related table's column id third. The join is added for you: ```json ["field", { "source-field": 1711 }, 1682] ``` (`1711` = orders.customer_id, `1682` = customers.plan.) **An explicit join** for a non-FK condition, a chosen strategy, or a joined source question: ```json "joins": [ { "alias": "Customers", "strategy": "left-join", "stages": [{ "lib/type": "mbql.stage/mbql", "source-table": 170 }], "conditions": [["=", {}, ["field", {}, 1711], ["field", { "join-alias": "Customers" }, 1684]]], "fields": "all" } ], "breakout": [["field", { "join-alias": "Customers" }, 1682]] ``` - `alias`, `stages` and `conditions` are required; `strategy` is `left-join` (default), `inner-join`, `right-join` or `full-join`. - `fields` picks the joined columns a row listing returns: `"all"`, or a list of refs. Without it the join adds none. - In the join's stage, every ref to a joined column carries `join-alias`, conditions included. A later stage reads it by name, like any earlier-stage column. ## Multi-stage A later stage reads the previous stage's output by column name, with the column's `base-type`: `["field", {"base-type": "type/BigInteger"}, "count"]`. Filter on an aggregate (HAVING), or aggregate an aggregate, in the next stage: ```json "stages": [ { "lib/type": "mbql.stage/mbql", "source-table": 175, "aggregation": [["sum", { "name": "total" }, ["field", {}, 1715]]], "breakout": [["field", {}, 1711]] }, { "lib/type": "mbql.stage/mbql", "filters": [[">", {}, ["field", { "base-type": "type/Float" }, "total"], 1000]], "order-by": [["desc", {}, ["field", { "base-type": "type/Float" }, "total"]]], "limit": 3 } ] ``` The names and base types are the `name` and `base_type` of the columns the earlier stages return: run them once (`mb query … --json`, `data.cols`) and copy both. A breakout keeps its field's name (`CREATED_AT`, even when bucketed); an aggregation has the `name` you gave it. **Window:** `offset` sits in `aggregation` and reads another breakout row. Month-over-month against a monthly breakout: ```json "aggregation": [ ["sum", { "name": "revenue" }, ["field", {}, 1715]], ["offset", { "name": "prev_month" }, ["sum", {}, ["field", {}, 1715]], -1] ], "breakout": [["field", { "temporal-unit": "month" }, 1717]] ``` ## Saved questions, models and definitions - A saved question or model is a source: `"source-card": 137` on the first stage, same database as the rest of the query; its columns go by name with `base-type`, as in a later stage. - A metric is an aggregation: `["metric", {}, <card id>]`, on a stage whose source is the metric's table. - A segment is a filter: `["segment", {}, <segment id>]`, on its table. - A metric's output column is named for its inner aggregation (`sum`, `count`); set `"name"` in its options for a later stage to read it by. <!-- requires: measures --> A measure is an aggregation, `["measure", {}, <measure id>]`, on its table, and is named like a metric. <!-- /requires --> ## Authoring loop: print-schema → dry-run → run ```bash mb query --file q.json --dry-run --profile <n> # check + compile on the server, no run mb query --file q.json --profile <n> --json # check + run mb query --print-schema --profile <n> > ./.scratch/mbql-schema.json # the full JSON Schema mb query --file q.json --compile --profile <n> # the SQL the server generates mb query --file q.json --metadata --profile <n> --json # tables (with FK targets), fields, snippets it touches mb query --file q.json --export-format csv --profile <n> > rows.csv # stream the rows up to the server's download limit, no envelope cap ``` - `--dry-run` checks the shape locally, then has the server compile the query to SQL without running it. It answers `{ ok, errors: [{ path, message }], sql }`: exit `0` with the compiled `sql`, or exit `2` with `sql: null` when either check rejects the body. A local error's `path` is a JSON Pointer into the body; a server error's is `""`. Exit `1` means the compile could not run (the server refused permission, server unreachable). - The server compile catches what the shape check cannot: a ref to a missing aggregation or expression, an unknown clause, a duplicate `lib/uuid`, a missing table, field, card or segment, a raw-variable template tag with no value or `default` outside an optional `[[ ]]` clause, a `required` tag with no value. A column's type not suiting its operator and a misspelled column name in a later stage are caught only by the warehouse, so a mistake there compiles and fails on the run. When a run fails, read the message and fix the body it names; an error naming nothing in the body (a `NullPointerException`) is a server fault, so stop editing a body that is otherwise correct. - A run checks the shape first and never sends an invalid body; exit `1` is a server or warehouse error after that. - A server error `lib/uuid: missing required key` at a clause means that clause's arguments are wrong: their count, a unit it doesn't take, a bad time zone. - `--compile` shows what a clause becomes: read the SQL when an aggregation, join or temporal bucket misbehaves, or seed a native query from a working body (`--no-pretty --format text` prints it bare). It needs native query permission on the database, and its SQL leaves out the row limit a run adds. `--metadata` shows what a body reaches before running it: every source table with its columns, the tables their foreign keys point at, and the fields and snippets a native body's template tags name. Both pre-flight the body like a run; `--dry-run`, `--compile`, `--metadata` and `--export-format` are mutually exclusive. - A csv or xlsx export of a pivot passes `--pivot-results` with `--visualization-settings '{"pivot_table.column_split":{"rows":[…],"columns":[…],"values":[…]}}'`, naming the body's breakout and aggregation columns; an ad-hoc query has no card to take the layout from. - A run answers `data.rows` and slim `data.cols` (`name`, `display_name`, `base_type`, `semantic_type`); `--full` returns the raw `/api/dataset` envelope. ## Where the query goes Every command below takes the query object itself at the path shown, checked by the same pre-flight (`--skip-validate` sends it unchecked). | Command | The query lives at | Notes |
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub