Business Intelligence

How Do You Migrate Metabase Questions and Models Into dbt?

Why your Metabase logic sits in copied questions, how to get every question out as SQL with our open-source unshackle CLI, and which ones should become dbt models and metrics.

Javier BonillaCo-founder & CTO

To migrate Metabase questions and models into dbt, get every saved question out as SQL first. Then find the numbers that repeat across those questions and define each one once in dbt: Metabase models become dbt models, repeated sums become MetricFlow metrics, and questions someone asked once stay plain SQL. The copying is mechanical. Deciding which questions are the same number is the real job.

Metabase's own sample instance shows the problem. It ships a metric called Revenue, described as the "canonical metric for revenue across all product lines." Ten other questions and dashboard tiles in the same instance add up the same column on their own, and not one of them uses it.

Where does Metabase keep your business logic?

Metabase gives it a few homes. Questions are saved queries, built in the query builder or written in SQL. Models are derived tables that people start new questions from. Segments are saved filters on a table. Metrics are a formula plus the dimensions people can break it out by, and the current kind only arrived in Metabase 51.

Most teams' logic ended up in the first one. A question carries its own copy of the formula, its own date window and its own filters. When someone needs a variation, they duplicate a question that works and change the filter.

Revenue, eleven times: eight dashboard tiles and three saved questions in Metabase's sample instance each sum ORDERS.TOTAL with their own date window (4 months, 12 months, 2 years, 36 months or none). In dbt they become one revenue metric.

We exported Metabase's sample instance, v0.65.3 with its bundled Sample Database, and got 39 pieces of SQL: 22 dashboard tiles and 17 saved questions. Eleven of them sum ORDERS.TOTAL, which is revenue, each with its own date window baked in. Three are built on top of another saved question.

In the sample, every copy uses the same sum, so the formulas still agree. In a real instance, they drift. A fractional CDO on the Metabase forum lists what they keep finding in teams' instances: "200+ questions, nobody knows what's still used" and "metrics defined 4 different ways".

How do you get every Metabase question out as SQL?

There are three routes, and only one gives you SQL files and MetricFlow metrics:

  • Metabase serialization. Metabase's own export is on its Pro and Enterprise plans only. It writes Metabase's YAML format, which describes questions and dashboards for another Metabase to import. It doesn't write dbt models.
  • dbt-metabase. This open-source tool mostly goes the other direction. It pushes your dbt docs into Metabase, and it can pull questions and dashboards out as dbt exposures, with each question's SQL kept as text in the exposure's description. That's useful for lineage, but it doesn't give you dbt models or metrics.
  • unshackle. We built unshackle, a free, open-source CLI that reads Metabase through its API and writes the result into a Git repo. It needs an API key, not a paid plan: our test ran on the free open-source edition.

With unshackle 0.1.0, the whole export is one command:

pip install unshackle
export METABASE_URL=https://metabase.example.com
export METABASE_API_KEY=<a key in the Administrators group>
unshackle migrate metabase

Questions land as SQL files in queries/. For query-builder questions, Metabase compiles the SQL itself, so you get exactly what Metabase would run. SQL variables like {{category}} become named parameters. Each dashboard becomes a notebook with one SQL cell and one chart per tile, and a question that sits on a dashboard lands there instead of in queries/. On the sample's E-commerce Insights dashboard, 22 of the 23 tiles came out with SQL. Nothing changes in Metabase, and unshackle runs no queries on your warehouse.

Which questions should become dbt models and metrics?

This is the part a tool can't decide for you. Once the SQL sits in a repo, search it for the columns your dashboards sum and count. Then sort what you find with the rule we'd use:

  1. A number that appears in two or more questions, or one people argue about, becomes a MetricFlow metric.
  2. A Metabase model becomes a dbt model. unshackle writes its SQL and marks it: "materialize as a SQL model."
  3. A question asked once, for one person, stays a SQL file. It doesn't need a metric. If nobody opens it again, it doesn't need to move at all.

Metrics you already built in Metabase get a head start. Simple ones, a sum or an average with a filter on the same table, arrive as MetricFlow metrics with the filter kept. Ones built on a custom expression or a segment come back marked TODO(unshackle), with Metabase's compiled SQL next to them, so you can rebuild them as ratio or derived metrics. unshackle doesn't guess.

What does revenue look like once it's in dbt?

All eleven copies collapse into one metric. Here it is in the semantic_models format unshackle 0.1.0 writes, which dbt Core 1.12 parses:

semantic_models:
  - name: orders
    model: ref('orders')
    defaults:
      agg_time_dimension: created_at
    entities:
      - name: order
        type: primary
        expr: id
    dimensions:
      - name: created_at
        type: time
        type_params:
          time_granularity: day
    measures:
      - name: revenue
        agg: sum
        expr: total

metrics:
  - name: revenue
    label: Revenue
    type: simple
    type_params:
      measure: revenue

The date windows stop being copies of SQL. "Revenue per quarter for the last three years" becomes a query: mf query --metrics revenue --group-by metric_time__quarter --start-time 2023-11-01. A category filter becomes a --where on the same metric.

The Revenue per quarter tile's SQL from Metabase, SQL with date arithmetic around SUM(ORDERS.TOTAL), next to the one-line MetricFlow query that replaces it.

Before the metrics will load, dbt needs three things:

  1. The tables unshackle refers to, as dbt models or sources. Its ref() names come from the warehouse table names.
  2. A time spine. MetricFlow needs one for any metric with a time dimension.
  3. A clean parse. Run dbt parse and fix what it flags. Check that dimension labels are unique within each semantic model. Then run mf validate-configs. Our two migrated metrics passed with 0 errors.

If you want dbt's newer metrics spec, dbt-autofix deprecations --semantic-layer converts the files. Review its diff before you commit it.

How do you check the migrated numbers against Metabase?

Check the numbers before anyone switches, and start with the cards people quote in meetings, like Revenue per quarter. unshackle verify takes the CSV Metabase returns for a card and compares it, row by row, with the migrated SQL run on your warehouse. You write the cases: which card, which columns to match on, how much difference to allow. Both of ours passed. It checks the migrated SQL, not the dbt metric, so also run the metric for the same period and compare it with the card.

Can Metabase keep running on top of dbt?

Yes, for tables. Metabase reads the warehouse, not your dbt project, so the tables dbt builds show up after its next schema sync. Point the questions you keep at them and Metabase keeps working.

Metrics don't make the trip. A MetricFlow metric doesn't show up in Metabase as a metric, so every question that needs revenue sums the column again, or you rebuild revenue as a Metabase metric. Either way, revenue lives in more than one place, the problem you migrated to fix.

Querio reads the metric itself, from your dbt project's semantic_manifest.json, so revenue stays defined once, in dbt, and we keep no second copy. Querio's agent, Claude, ChatGPT or any agent connected through our MCP server can ask for revenue by name. The query runs as the signed-in person and returns only what they're allowed to see. The MCP server is on every plan, Free included, and there's no per-seat pricing.

If you're weighing the switch, here's how Querio compares with Metabase, and our dbt Semantic Layer guide covers what dbt gives you for free. Moving from Looker instead? We wrote up converting LookML to MetricFlow too.

FAQ

What happens to Metabase dashboard filters?

They come across as notes, not working filters. A date range filter on a dashboard compiles in Metabase to an expression unshackle can't bind, so it leaves a TODO(unshackle) to rewrite it as :date_range_from and :date_range_to parameters in the SQL. A "group by" filter that lets viewers switch between day, week and month gets fixed at the card's own unit. In dbt, both become query-time choices on the metric.

What about Metabase's row and column security?

It's a Pro and Enterprise feature, and its rules don't turn into dbt files: dbt's Semantic Layer runs queries with the warehouse credentials you configure, so the policies on those credentials are what apply. So set access in the warehouse, and make every tool on top query as the person asking. Querio connects to Snowflake, BigQuery and Databricks with each person's own login, so the warehouse's rules apply.

Magic happens when people and agents work together

Get started for freeBook a demo