Metabase as code: export, plan, and apply dashboards, cards, collections, snippets and pulses via the Metabase REST API.
Project description
metabase-sync
Metabase as code. export, plan, and apply your dashboards, cards, collections, snippets and pulses via the regular Metabase REST API. Works with Metabase OSS (no Enterprise license required).
$ metabase-sync plan
plan ← state/
collections: 0 create, 0 update, 12 skip
cards: 1 create, 1 update, 48 skip
CREATE collections/finance/cards/q4-revenue.sql Q4 Revenue — SELECT SUM(revenue) FROM finance.q4
UPDATE collections/finance/cards/profit-margin.sql SQL: 23 → 25 lines, +2 −0
dashboards: 1 create, 0 update, 8 skip
CREATE collections/finance/dashboards/q4-review/dashboard.yaml Q4 Review (1 dashcards)
2 create, 1 update, 68 skip
run `metabase-sync apply` to apply this plan.
Why this exists
Metabase's official serialization and Remote Sync are gated behind Enterprise / Pro. If you self-host the open-source edition, the /api/ee/serialization/* endpoints return 404 and you have no first-party way to put your dashboards under version control.
This tool wraps the plain REST API and gives you the same workflow without the licence wall:
- One YAML/SQL tree per Metabase instance, suitable for git.
- A
plan/applyloop that reads exactly like Terraform. - Round-trip determinism:
export→apply→exportproduces zero diff. - Code-first authoring: drop a new
.sqland a newdashboard.yaml, runapply, done. No need to know any Metabase ids ahead of time.
Install
uv tool install metabase-sync # recommended: isolated install
# or
pipx install metabase-sync
# or
pip install metabase-sync
Quickstart
mkdir my-metabase && cd my-metabase
cat > .env <<EOF
METABASE_URL=https://your-instance.example.com
METABASE_API_KEY=mb_...
EOF
metabase-sync export # creates ./state/
git init && git add state && git commit -m "import metabase state"
Then your day-to-day:
# Edit a .sql or dashboard.yaml in your editor.
metabase-sync plan # see exactly what will change
metabase-sync apply # write changes
Commands
| Command | Purpose |
|---|---|
metabase-sync export |
Pull the live instance into the on-disk state tree. |
metabase-sync plan |
Read-only diff. Prints a per-item report and writes state/.plan.json. |
metabase-sync apply |
Re-derives the diff and executes it. Idempotent. |
metabase-sync apply --only cards |
Restrict to one resource (collections,snippets,cards,dashboards,pulses). |
metabase-sync index |
Debug: print remote resource counts. |
Both plan and apply re-fetch the live instance, so you can't apply a stale plan against a moved instance.
Authoring new cards and dashboards in code
You don't need to know any Metabase ids ahead of time. Create the files; apply allocates the ids and writes them back.
A new card
state/collections/finance/cards/q4-revenue.sql:
---
entity_id: null
name: Q4 Revenue
description: null
type: question
display: scalar
database: bigquery
parameters: []
visualization_settings: {}
enable_embedding: false
embedding_params: null
cache_ttl: null
archived: false
template_tags: {}
---
---body---
SELECT SUM(revenue) FROM finance.q4
Run plan to see the CREATE line, then apply. The entity_id: null becomes the server-assigned nanoid in your local file after apply.
A new dashboard referencing a new card
state/collections/finance/dashboards/q4-review/dashboard.yaml:
entity_id: null
name: Q4 Review
description: null
archived: false
auto_apply_filters: true
cache_ttl: null
enable_embedding: false
embedding_params: null
position: null
width: fixed
parameters: []
tabs: []
dashcards:
- entity_id: null
card_path: ../../cards/q4-revenue.sql # path relative to this dashboard dir
tab_position: null
row: 0
col: 0
size_x: 12
size_y: 6
parameter_mappings: []
visualization_settings: {}
series: []
card_path is the file path from this dashboard's directory to the card's .sql file. Apply resolves it after creating the card, then writes the new entity_id back to your file.
On-disk layout
state/
databases/<name>.yaml # manifest only — name + engine, NO credentials
snippets/<slug>.sql # YAML frontmatter + raw SQL body
collections/
<slug>/_collection.yaml
/<nested-slug>/_collection.yaml
/cards/<slug>.sql or .yaml
/dashboards/<slug>/dashboard.yaml
/cards/<slug>.sql # dashboard-internal cards
root/cards/... # cards directly under the root collection
pulses/<slug>.yaml # dashboard subscriptions
- Native SQL cards:
.sqlfile with YAML frontmatter + raw query body. - GUI / MBQL cards:
.yamlfile with the fulldataset_query(classic or MBQL5) inlined. - Dashcards: reference cards by
card_path(relative). - Pulses: reference cards by
card_path, the target dashboard bydashboard_path. Recipients are stored by email. - Snippets without a collection live in
state/snippets/; snippets inside a collection live instate/collections/<...>/snippets/. - Personal collections are filtered out.
Configuration
| Env var | Required | Default | Notes |
|---|---|---|---|
METABASE_URL |
yes | — | e.g. https://metabase.example.com |
METABASE_API_KEY |
yes | — | Admin API key (see below) |
STATE_DIR |
no | state/ |
Relative to CWD |
HTTP_TIMEOUT_S |
no | 120 |
Per HTTP request (large cards' result_metadata recompute can take time) |
HTTP_MAX_RETRIES |
no | 3 |
Retries on 408 / 429 / 502 / 503 / 504 / connection errors |
HTTP_RETRY_BACKOFF_S |
no | 1.0 |
Base delay; doubles per retry (1s, 2s, 4s) |
A .env file in the current working directory is read automatically.
API keys are minted from the Metabase admin UI (Settings → Admin settings → Authentication → API keys). The key needs admin permissions to fetch + write everything export and apply touch.
Exit codes
plan and apply follow the terraform convention so CI pipelines can fan out:
| Code | Meaning |
|---|---|
| 0 | Success and no changes (or apply finished cleanly) |
| 1 | Error (HTTP failure, preflight failure, missing recipient, concurrency drift) |
| 2 | plan detected pending changes (informational; not an error) |
CI/CD recipes
See examples/ for two GitHub Actions templates:
github-actions-plan-on-pr.yml— comment the plan output on every PR that touchesstate/.github-actions-apply-on-merge.yml— apply automatically on merge tomain, gated by an environment approval.
Round-trip guarantees
exportis deterministic: byte-identical output across runs.planagainst a freshly-exported tree reportsnothing to do..- SQL bodies are byte-faithful — trailing whitespace and template-tag UUIDs survive the round-trip.
Caveats
Before running this on a production Metabase, you should know:
- Apply overwrites concurrent UI edits unless you re-plan first.
applyruns a freshplanand checks the capturedupdated_atfor every item it touched. If a UI user has edited an item since you ranplan, apply aborts with the list and tells you to re-plan. Pass--forceto overwrite anyway. - Dashboard contents are a full replacement. Tabs and dashcards are PUT in one go with client-assigned negative temp ids; the server replaces existing rows and allocates new
dashcard.entity_ids. Two simultaneous applies race; wrap CI in a concurrency group to serialise them. - Out-of-scope resources are silently NOT synced. Alerts, segments, legacy metrics, permissions, users and groups are not part of the state tree.
exportprints a warning if it finds any so you don't discover this in production. - Personal collections are excluded. Cards/dashboards inside a user's personal collection don't appear in the export. Dashboards in shared collections that reference a personal-collection card will fail the reference preflight at plan time, not at apply time.
--deleteis not yet implemented. Items removed fromstate/are not auto-archived on apply. Archive them through the UI or directly via the API. Passing--deleteis rejected with exit code 2.
Pre-apply backup
apply --backup-dir <path> re-exports the live instance to <path> before mutating. If apply messes something up, metabase-sync apply --state-dir <path> rolls forward against the backup.
metabase-sync apply --backup-dir /tmp/pre-apply-$(date +%Y%m%d-%H%M%S)
Troubleshooting / FAQ
401 Unauthorized on the first call. Your API key was revoked or you're pointed at the wrong instance. Run metabase-sync diagnose — it prints the URL and key length.
plan reports updates I didn't make. Most often a Metabase version mismatch — server-generated lib/uuid values on GUI cards regenerate on every UI save. The tool strips them at diff time; if you still see noise, metabase-sync export once to refresh and try again.
How do I rename a card? Edit the name: in the frontmatter. The entity_id stays the same so the rename round-trips cleanly.
How do I move a card to another collection? Move the file. The collection that contains the card is determined by its on-disk path; on apply the collection_id is rebound and Metabase moves it.
How do I delete a card? Archive it via the Metabase UI for now. The --delete flag is not implemented in this release. After archival, re-export and commit.
metabase-sync diagnose to file a bug. It captures everything we'd ask for in a triage thread. Paste the output into the GitHub issue template.
My Metabase is on a really old version. Check the compatibility band. Versions older than v0.45 are explicitly refused; v0.45–v0.55 will warn but try; v0.55–v0.62 are in our tested band; newer versions warn but proceed.
Compatibility
| Metabase | Status |
|---|---|
| <v0.45 | Refused — collection API shape too different |
| v0.45–v0.55 | Warns; proceed at your own risk |
| v0.55–v0.62 | Tested in CI integration matrix |
| >v0.62 | Warns; we want to hear about issues |
latest |
Non-blocking CI job runs against it to surface upcoming breakage |
We pin v0.62.2 for the blocking CI job; the matrix in .github/workflows/ci.yml runs the same integration suite against v0.55, v0.58, v0.60, and latest non-blockingly.
Python: 3.11, 3.12, 3.13.
If something goes wrong, you can cp -r the backup over state/ and re-apply to roll back.
Security
- The API key is read from the environment (or an
.envfile). Anything that captures the environment — CI logs, shell history (printenv), error stack traces from third-party deps thatrepr()settings — can leak it. Tools like sops +sops exec-env encrypted.env 'metabase-sync apply'work well. - HTTPS verification follows httpx's defaults (CA bundle from
certifi). The tool does not disable cert verification. state/.plan.jsonandstate/.last-apply.jsoninclude full SQL bodies — they're written understate/and should be.gitignored. The default.gitignoresnippet:state/.plan.json state/.last-apply.json- The tool never serialises database connection credentials (
detailsis stripped fromdatabases/<name>.yaml).
Limitations
--delete(opt-in archival of items absent from disk) is not yet implemented.- Permissions, users, and groups are out of scope.
- Alerts, legacy metrics, and segments are not yet supported.
Contributing
See CONTRIBUTING.md for dev setup, running tests, and the release process.
License
MIT — see LICENSE.
Disclaimer
This is an unofficial, community-built tool. Not affiliated with or endorsed by Metabase, Inc. "Metabase" is a trademark of Metabase, Inc.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file metabase_sync-0.1.0.tar.gz.
File metadata
- Download URL: metabase_sync-0.1.0.tar.gz
- Upload date:
- Size: 54.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eaba02162aefa8de480b7b3e81f2b66762a173972b622b77b72fdbd0283c3fc4
|
|
| MD5 |
c93d9cdae7f86fcb7457d7064bc66be7
|
|
| BLAKE2b-256 |
a4afd2603f5d41eee7843f13bff7d909a56cd8aa8e378c446dea0db30774a40d
|
Provenance
The following attestation bundles were made for metabase_sync-0.1.0.tar.gz:
Publisher:
publish.yml on novucs/metabase-sync
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
metabase_sync-0.1.0.tar.gz -
Subject digest:
eaba02162aefa8de480b7b3e81f2b66762a173972b622b77b72fdbd0283c3fc4 - Sigstore transparency entry: 1887507189
- Sigstore integration time:
-
Permalink:
novucs/metabase-sync@c1ee03840ef331023294e4e7db2025491680870b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/novucs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c1ee03840ef331023294e4e7db2025491680870b -
Trigger Event:
push
-
Statement type:
File details
Details for the file metabase_sync-0.1.0-py3-none-any.whl.
File metadata
- Download URL: metabase_sync-0.1.0-py3-none-any.whl
- Upload date:
- Size: 52.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dfe663c4e8d5340b86ac1a8660cb5b29d1f3078a832714b35c48bb28cf902581
|
|
| MD5 |
ef3603523d2ad4635b3e9e28b35bcb78
|
|
| BLAKE2b-256 |
d17c96fb0b414bda98e6be1a49d58a80f8ab315858b4c46756d7c72001deec2b
|
Provenance
The following attestation bundles were made for metabase_sync-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on novucs/metabase-sync
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
metabase_sync-0.1.0-py3-none-any.whl -
Subject digest:
dfe663c4e8d5340b86ac1a8660cb5b29d1f3078a832714b35c48bb28cf902581 - Sigstore transparency entry: 1887507350
- Sigstore integration time:
-
Permalink:
novucs/metabase-sync@c1ee03840ef331023294e4e7db2025491680870b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/novucs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c1ee03840ef331023294e4e7db2025491680870b -
Trigger Event:
push
-
Statement type: