
Data Metabase
- 133 installs
- 73 repo stars
- Updated July 13, 2026
- vasilyu1983/ai-agents-public
Helps with ai & agent building tasks.
About
data-metabase is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- data-metabase
- AI & Agent Building
- AI-coding skill
Data Metabase by the numbers
- 133 all-time installs (skills.sh)
- +2 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #3,612 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vasilyu1983/ai-agents-public --skill data-metabaseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 133 |
|---|---|
| repo stars | ★ 73 |
| Last updated | July 13, 2026 |
| Repository | vasilyu1983/ai-agents-public ↗ |
What it does
Helps with ai & agent building tasks.
Files
Metabase
Automate Metabase via API: reports (cards), dashboards, and chart settings.
Quick Start
Inputs (env vars)
METABASE_URL(e.g.,https://metabase.example.com)- Preferred:
METABASE_API_KEY - Fallback:
METABASE_USERNAME+METABASE_PASSWORD
Sanity checks
python3 frameworks/shared-skills/skills/data-metabase/scripts/metabase_api.py health
python3 frameworks/shared-skills/skills/data-metabase/scripts/metabase_api.py whoamiLive API documentation
Your Metabase instance serves OpenAPI docs at /api/docs (e.g., https://metabase.example.com/api/docs). Use this to discover version-specific endpoints and request shapes.
Workflow
1. Confirm API availability (GET /api/util/health). 2. Authenticate with an API key (preferred) or a short-lived session (fallback). 3. Discover IDs (prefer discovery over hardcoding across environments):
collection_idfor where to savedatabaseid fordataset_querysource-table/ field ids if using MBQL
4. Create/update a card:
- Prefer native SQL for stable automation.
- Set
display+visualization_settingsexplicitly.
5. Create/update a dashboard and add cards with consistent layout. 6. Validate by running/exporting results.
Key Concepts
- UI "Question" == API
card - Chart configuration lives on the card as
display+visualization_settings - Most viz keys are easiest to manage by copying from an existing card JSON, then editing
Guardrails
- Prefer Metabase "serialization" (Pro/Enterprise) for bulk, cross-environment migrations; use direct API for incremental upserts.
- Do not hardcode numeric IDs across environments when you can discover them or use serialization/entity IDs.
- Never commit
METABASE_API_KEY, passwords, or session tokens. - Prefer a dedicated, least-privileged automation account and collection.
References (read only as needed)
| Topic | File |
|---|---|
| Authentication (API key + fallback) | references/api-auth.md |
| Reports (cards): create/edit patterns | references/reports-cards.md |
| Dashboards and card placement | references/dashboards.md |
Charts and visualization_settings | references/charts-settings.md |
| Embedding & external integration | references/embedding-integration.md |
| Permissions & collections management | references/permissions-collections.md |
| Native SQL query patterns | references/native-query-patterns.md |
Scripts
scripts/metabase_api.py is a small, dependency-free helper to test auth and upsert cards.
Examples:
# Print authenticated user (tries API key, then session)
python3 frameworks/shared-skills/skills/data-metabase/scripts/metabase_api.py whoami
# Export an existing card JSON (use as a template for visualization_settings)
python3 frameworks/shared-skills/skills/data-metabase/scripts/metabase_api.py export-card --id 123 --out card.json
# Export an existing dashboard JSON (use as a template for layout)
python3 frameworks/shared-skills/skills/data-metabase/scripts/metabase_api.py export-dashboard --id 5 --out dashboard.json
# Create/update a card from a JSON spec (see references/reports-cards.md)
python3 frameworks/shared-skills/skills/data-metabase/scripts/metabase_api.py upsert-card --spec card-spec.json
# Create/update a dashboard from a JSON spec (base fields only)
python3 frameworks/shared-skills/skills/data-metabase/scripts/metabase_api.py upsert-dashboard --spec dashboard-spec.jsonFact-Checking
- Use web search/web fetch to verify current external facts, versions, pricing, deadlines, regulations, or platform behavior before final answers.
- Prefer primary sources; report source links and dates for volatile information.
- If web access is unavailable, state the limitation and mark guidance as unverified.
{
"metadata": {
"skill": "data-metabase",
"updated": "2026-01-25",
"total_sources": 12,
"description": "Official Metabase documentation links for API usage, embedding, permissions, and administration. Use for web search grounding when automating Metabase.",
"version": "1.1"
},
"categories": {
"api_and_automation": [
{
"name": "Metabase API documentation",
"url": "https://www.metabase.com/docs/latest/api",
"type": "documentation",
"relevance": "Primary reference for Metabase REST API endpoints, request/response shapes, and auth notes.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "api"]
},
{
"name": "Metabase administration guide",
"url": "https://www.metabase.com/docs/latest/administration-guide/start",
"type": "documentation",
"relevance": "Admin settings, security controls, and environment configuration relevant to API access.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "admin", "security"]
},
{
"name": "Working with the Metabase API (Learn)",
"url": "https://www.metabase.com/learn/metabase-basics/administration/administration-and-operation/metabase-api",
"type": "tutorial",
"relevance": "Practical guide covering session caching, 401 handling, browser DevTools technique for discovering API shapes.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "api", "tutorial"]
},
{
"name": "API keys documentation",
"url": "https://www.metabase.com/docs/latest/people-and-groups/api-keys",
"type": "documentation",
"relevance": "API key creation, group assignment, and permission scoping for programmatic access.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "api", "authentication"]
}
],
"query_language": [
{
"name": "MBQL Reference (GitHub Wiki)",
"url": "https://github.com/metabase/metabase/wiki/(Incomplete)-MBQL-Reference",
"type": "reference",
"relevance": "Metabase Query Language reference for structured (query-builder) queries; essential for programmatic card creation.",
"update_frequency": "periodic",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "mbql", "query"]
}
],
"serialization": [
{
"name": "Serialization documentation",
"url": "https://www.metabase.com/docs/latest/installation-and-operation/serialization",
"type": "documentation",
"relevance": "Export/import dashboards and questions between environments (Pro/Enterprise). Entity IDs for cross-instance stability.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "serialization", "migration"]
}
],
"collections_permissions": [
{
"name": "Collections",
"url": "https://www.metabase.com/docs/latest/permissions/collections",
"type": "documentation",
"relevance": "How collections work and how permissions interact with saved questions/dashboards.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "collections", "permissions"]
},
{
"name": "Permissions overview",
"url": "https://www.metabase.com/docs/latest/permissions/start",
"type": "documentation",
"relevance": "Permission model for databases, collections, and user groups (critical for automation accounts).",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "permissions"]
}
],
"charts_and_dashboards": [
{
"name": "Visualizations",
"url": "https://www.metabase.com/docs/latest/questions/visualizations/start",
"type": "documentation",
"relevance": "UI-level chart types and settings; useful for mapping desired UI behavior to visualization_settings.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "charts", "visualization"]
},
{
"name": "Dashboards",
"url": "https://www.metabase.com/docs/latest/dashboards/introduction",
"type": "documentation",
"relevance": "Dashboard concepts and configuration patterns.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "dashboards"]
}
],
"embedding": [
{
"name": "Embedding introduction",
"url": "https://www.metabase.com/docs/latest/embedding/introduction",
"type": "documentation",
"relevance": "Embedding options and security trade-offs (relevant when API automation feeds embedded dashboards).",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true,
"tags": ["metabase", "embedding"]
},
{
"name": "Metabase GitHub repository",
"url": "https://github.com/metabase/metabase",
"type": "reference",
"relevance": "Source of truth for edge-case behavior and API internals when docs are ambiguous.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false,
"tags": ["metabase", "source"]
}
],
"community_tools": [
{
"name": "metabase-api Python wrapper (PyPI)",
"url": "https://pypi.org/project/metabase-api/",
"type": "library",
"relevance": "Community Python wrapper with sync/async support; alternative to custom scripts for common operations.",
"update_frequency": "periodic",
"access": "free",
"add_as_web_search": false,
"tags": ["metabase", "python", "library"]
}
]
}
}
Metabase API Authentication
Goal: programmatic access to Metabase with an API key (preferred) and safe fallbacks.
Inputs
METABASE_URL: Base URL, e.g.https://metabase.example.com(no trailing/preferred)- Preferred:
METABASE_API_KEY - Fallback:
METABASE_USERNAME+METABASE_PASSWORD
Strategy: confirm auth by calling a cheap endpoint
Use an endpoint that requires auth and returns the current principal:
GET /api/user/current(commonly available)
If the request returns HTTP 200, your auth method is accepted.
API key authentication
Metabase API key auth has multiple variants across versions/editions. If you are not sure which header your instance expects, try these in order (and keep the one that returns 200 from GET /api/user/current):
1. X-API-KEY: <key> 2. Authorization: Bearer <key>
If both fail with 401/403:
- Confirm API keys are enabled in your Metabase instance.
- Check the Metabase admin UI for an "API keys" page and regenerate a key.
- Fall back to session auth if allowed (below).
Session authentication (fallback)
If your environment permits a service username/password (not recommended for long-lived automation), create a session:
POST /api/sessionwith JSON body:{"username":"...","password":"..."}
Use the response id as the session token in subsequent requests:
X-Metabase-Session: <id>
Session lifetime and caching
- Sessions are valid for 14 days by default
- Configure via env var:
MAX_SESSION_AGE(value in minutes) - Cache the session token and reuse until it expires
- Logins are rate-limited; avoid creating new sessions per request
Handling 401 errors (auto-retry pattern)
When the API returns 401 (Unauthorized), your session may have expired. Implement auto-retry:
def request_with_retry(method, path, headers, body=None):
status, payload, raw = _request(method, path, headers, body)
if status == 401:
# Refresh auth and retry once
_, new_headers = _pick_auth_headers()
status, payload, raw = _request(method, path, new_headers, body)
return status, payload, rawThis pattern handles:
- Expired sessions
- Rotated API keys
- Temporary auth failures
Safety notes
- Never commit
METABASE_API_KEY, passwords, or session tokens to the repository. - Prefer a least-privileged service user and a dedicated collection for automation-managed assets.
Charts and Visualization Settings (Metabase)
Metabase stores chart configuration on the card:
display: the visualization type (table/line/bar/pie/etc.)visualization_settings: an object with visualization-specific keys
Recommended approach: copy, then edit
Metabase visualization keys change over time and depend on chart type. The most reliable way to automate chart settings is:
1. Create the chart in the UI. 2. Export the card JSON (GET /api/card/:id). 3. Reuse and modify the exported display and visualization_settings as your template.
Practical patterns
Pattern: enforce consistent naming and formatting
- Keep
nameconsistent (stable identifiers help cross-environment diffs). - Keep
displayexplicit (do not rely on defaults). - Keep
visualization_settingsminimal (only keys you need).
Pattern: keep a "golden card" per chart type
Create one card per visualization type (line/bar/table/pie) configured exactly as desired. Export each as a template and reuse its visualization_settings for future cards.
Dashboards in Metabase API
Create and manage dashboards programmatically, including card placement and layout.
Contents
- Core endpoints
- Create a dashboard
- Add a card to a dashboard
- Update card positions
- Add a text card
- Common layout patterns
- Workflow: replicate dashboards across environments
Core endpoints
| Action | Method | Endpoint |
|---|---|---|
| Create dashboard | POST | /api/dashboard |
| Read dashboard | GET | /api/dashboard/:id |
| Update dashboard | PUT | /api/dashboard/:id |
| Delete dashboard | DELETE | /api/dashboard/:id |
| Add card | POST | /api/dashboard/:id/cards |
| Update cards | PUT | /api/dashboard/:id/cards |
| Remove card | DELETE | /api/dashboard/:id/cards/:card_id |
Create a dashboard
curl -X POST "$METABASE_URL/api/dashboard" \
-H "X-API-KEY: $METABASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Sales Overview",
"description": "Key sales metrics",
"collection_id": 10
}'Response includes the new dashboard id.
Add a card to a dashboard
Use POST /api/dashboard/:id/cards with placement properties:
curl -X POST "$METABASE_URL/api/dashboard/5/cards" \
-H "X-API-KEY: $METABASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"cardId": 123,
"row": 0,
"col": 0,
"sizeX": 6,
"sizeY": 4
}'Card placement properties
| Property | Type | Description |
|---|---|---|
cardId | int | ID of the saved question (card) to add |
row | int | Vertical position (0 = top) |
col | int | Horizontal position (0 = left, max typically 17) |
sizeX | int | Width in grid units (min 2, typical max 18) |
sizeY | int | Height in grid units (min 2) |
Grid system: Metabase uses an 18-column grid. Cards have minimum dimensions (typically 2x2 or 3x3 depending on version).
Update card positions
Use PUT /api/dashboard/:id/cards with an array of card updates:
curl -X PUT "$METABASE_URL/api/dashboard/5/cards" \
-H "X-API-KEY: $METABASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"cards": [
{"id": 101, "row": 0, "col": 0, "sizeX": 9, "sizeY": 4},
{"id": 102, "row": 0, "col": 9, "sizeX": 9, "sizeY": 4},
{"id": 103, "row": 4, "col": 0, "sizeX": 18, "sizeY": 6}
]
}'Note: The id in the cards array is the dashcard_id (dashboard-card relationship ID), not the card/question ID. Get this from the dashboard GET response.
Add a text card
Text cards have cardId: null and use visualization_settings for content:
curl -X POST "$METABASE_URL/api/dashboard/5/cards" \
-H "X-API-KEY: $METABASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"cardId": null,
"row": 0,
"col": 0,
"sizeX": 18,
"sizeY": 2,
"visualization_settings": {
"text": "## Sales Dashboard\nUpdated daily at 6am UTC",
"virtual_card": {"display": "text"}
}
}'Common layout patterns
Two-column layout
+------------------+------------------+
| Card A (9x4) | Card B (9x4) | row 0
+------------------+------------------+
| Card C (18x6) | row 4
+-------------------------------------+JSON for this layout:
{
"cards": [
{"id": 101, "row": 0, "col": 0, "sizeX": 9, "sizeY": 4},
{"id": 102, "row": 0, "col": 9, "sizeX": 9, "sizeY": 4},
{"id": 103, "row": 4, "col": 0, "sizeX": 18, "sizeY": 6}
]
}Header + KPIs + chart
+-------------------------------------+
| Text Header (18x2) | row 0
+--------+--------+--------+----------+
| KPI 1 | KPI 2 | KPI 3 | KPI 4 | row 2
+--------+--------+--------+----------+
| Main Chart (18x8) | row 6
+-------------------------------------+Workflow: replicate dashboards across environments
1. Export source dashboard: GET /api/dashboard/:id 2. Extract card IDs and layout from dashcards array 3. Create cards in target (or use serialization for Pro/Enterprise) 4. Create dashboard in target: POST /api/dashboard 5. Add cards with same layout: POST /api/dashboard/:id/cards
Pro/Enterprise: Use serialization for full dashboard export/import with Entity IDs.
Metabase Embedding and Integration
Purpose: Operational guide for embedding Metabase dashboards and questions into external applications — public links, signed embedding (JWT), interactive embedding (SDK), theming, security, and troubleshooting. Freshness anchor: Q1 2026.
---
Decision Tree: Choosing an Embedding Approach
START: How will users access the embedded content?
│
├─ Public / no authentication needed
│ └─ Public sharing links
│ - Zero setup, URL-based
│ - No parameter filtering
│ - WARNING: anyone with URL can access
│
├─ Authenticated users, read-only dashboards
│ └─ Signed embedding (JWT)
│ - Server-side token generation
│ - Parameter locking for row-level security
│ - Iframe-based, limited interactivity
│
├─ Authenticated users, full interactivity
│ └─ Interactive embedding (SDK)
│ - Full Metabase experience in your app
│ - Drill-down, filtering, custom questions
│ - Requires Metabase Pro/Enterprise
│
└─ Programmatic data access (no UI)
└─ Metabase API
- REST API for query results
- JSON responses for custom rendering---
Quick Reference: Embedding Comparison (2026)
| Feature | Public Link | Signed (JWT) | Interactive (SDK) | API |
|---|---|---|---|---|
| Authentication | None | Server-side JWT | SSO integration | API key/session |
| Parameters | URL params only | Locked or editable | Full filter UI | Request body |
| Interactivity | View only | View + locked filters | Full (drill, filter, explore) | Programmatic |
| Theming | No | Basic (CSS) | Full (SDK theme API) | N/A |
| License | Free | Free | Pro/Enterprise | Free |
| Security | Low | Medium | High | High |
| Implementation | Minutes | Hours | Days | Hours |
| Best for | Public data | Customer portals | SaaS analytics | Custom UIs |
---
Public Sharing Links
Setup
Admin → Settings → Public Sharing → EnableUsage
- Use when: Data is truly public, no sensitivity
- URL pattern:
https://metabase.company.com/public/dashboard/{uuid} - Parameters: Append
?param_name=valueto URL
Security Checklist
- [ ] Only enable for genuinely public dashboards
- [ ] Review shared items regularly (Admin → Sharing)
- [ ] Disable public sharing globally if not needed
- [ ] No PII or sensitive data in public dashboards
- [ ] Consider rate limiting via reverse proxy
---
Signed Embedding (JWT)
How It Works
1. User visits your app
2. Your server generates JWT with:
- Dashboard/question ID
- Locked parameters (e.g., customer_id)
- Expiration time
3. Frontend loads iframe with signed URL
4. Metabase validates JWT and renders contentBackend Setup
# Python — JWT token generation
import jwt
import time
METABASE_SECRET_KEY = "your-embedding-secret-key" # from Admin → Embedding
def generate_embed_url(dashboard_id: int, params: dict) -> str:
"""Generate signed embedding URL for a dashboard."""
payload = {
"resource": {"dashboard": dashboard_id},
"params": params,
"exp": int(time.time()) + (10 * 60) # 10 minute expiration
}
token = jwt.encode(payload, METABASE_SECRET_KEY, algorithm="HS256")
return f"https://metabase.company.com/embed/dashboard/{token}"
# Example: embed dashboard 42 locked to customer_id=123
url = generate_embed_url(
dashboard_id=42,
params={"customer_id": 123}
)// Node.js — JWT token generation
const jwt = require("jsonwebtoken");
const METABASE_SECRET_KEY = process.env.METABASE_EMBED_SECRET;
function generateEmbedUrl(dashboardId, params) {
const payload = {
resource: { dashboard: dashboardId },
params: params,
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 min
};
const token = jwt.sign(payload, METABASE_SECRET_KEY);
return `https://metabase.company.com/embed/dashboard/${token}`;
}
// Example
const url = generateEmbedUrl(42, { customer_id: 123 });Frontend Integration
<!-- Iframe embedding -->
<iframe
src="{{ embed_url }}"
frameborder="0"
width="100%"
height="800"
allowtransparency="true"
loading="lazy"
></iframe>Parameter Control
| Parameter Mode | In JWT params | User Can Change | Use When |
|---|---|---|---|
| Locked | {"customer_id": 123} | No | Row-level security |
| Editable | {"date_range": null} | Yes | User-controlled filters |
| Disabled | Not included | No (hidden) | Irrelevant parameters |
JWT Payload Examples
// Locked customer_id, editable date range
{
"resource": {"dashboard": 42},
"params": {
"customer_id": 123,
"date_range": null
},
"exp": 1707580800
}
// Multiple locked parameters
{
"resource": {"dashboard": 42},
"params": {
"customer_id": 123,
"region": "US",
"plan_type": "enterprise"
},
"exp": 1707580800
}
// Question (not dashboard) embedding
{
"resource": {"question": 99},
"params": {},
"exp": 1707580800
}---
Interactive Embedding (SDK)
Prerequisites
- Metabase Pro or Enterprise license
- SSO configured (SAML, JWT, or OIDC)
React SDK Setup
npm install @metabase/embedding-sdk-react// MetabaseProvider.jsx
import { MetabaseProvider } from "@metabase/embedding-sdk-react";
const config = {
metabaseInstanceUrl: "https://metabase.company.com",
authProviderUri: "/api/metabase/auth", // your auth endpoint
};
const theme = {
colors: {
brand: "#4C51BF",
"text-primary": "#1A202C",
"text-secondary": "#718096",
background: "#FFFFFF",
"background-hover": "#F7FAFC",
},
fontSize: "14px",
fontFamily: "Inter, sans-serif",
};
function App() {
return (
<MetabaseProvider config={config} theme={theme}>
<YourApp />
</MetabaseProvider>
);
}Key SDK Components
InteractiveDashboard— Full dashboard with filters, drill-downStaticQuestion— Single chart/visualization (read-only)- Props:
dashboardId,initialParameterValues,withDownloads,hiddenParameters
Auth Endpoint Pattern
- Your server verifies app authentication
- Creates JWT with
email,first_name,last_name,groups(maps to Metabase groups) - Returns
{ id: token }to SDK - SDK uses token to authenticate with Metabase instance
---
Theme and Appearance Customization
CSS Variables (Signed Embedding)
/* Custom styles for iframe embedding */
:root {
--mb-color-brand: #4C51BF;
--mb-color-brand-light: #EBF4FF;
--mb-color-text-primary: #1A202C;
--mb-color-text-secondary: #718096;
--mb-color-bg-white: #FFFFFF;
--mb-color-bg-light: #F7FAFC;
--mb-font-family: "Inter", sans-serif;
}URL Parameters for Appearance
| Parameter | Values | Effect |
|---|---|---|
bordered | true/false | Card borders |
titled | true/false | Dashboard/question title |
theme | night | Dark mode |
hide_parameters | param1,param2 | Hide specific filters |
hide_download_button | true | Remove download option |
# Example: dark mode, no title, no borders
/embed/dashboard/{token}#theme=night&titled=false&bordered=false---
SSO Integration for Embedded Analytics
SSO Flow for Interactive Embedding
1. User logs into your app (your SSO)
2. Your app calls your auth endpoint
3. Auth endpoint creates Metabase JWT with user info + groups
4. SDK uses JWT to authenticate with Metabase
5. Metabase maps JWT groups to Metabase permission groups
6. User sees only data their group permitsMetabase JWT SSO Configuration
Admin → Settings → Authentication → JWT
├─ JWT Identity Provider URI: https://yourapp.com/api/metabase/auth
├─ String used by the JWT signing key: [shared secret]
├─ User attribute → Email: email
├─ User attribute → First Name: first_name
├─ User attribute → Last Name: last_name
└─ User attribute → Groups: groupsGroup Mapping
| Your App Role | Metabase Group | Permissions |
|---|---|---|
admin | Administrators | Full access |
analyst | Data Analysts | All dashboards, SQL access |
customer:acme | ACME Corp | Sandboxed to ACME data |
viewer | Viewers | Curated dashboards only |
---
Troubleshooting Common Embedding Issues
| Issue | Cause | Fix |
|---|---|---|
| Iframe shows "Embedding is not enabled" | Embedding not toggled on | Admin → Settings → Embedding → Enable |
| JWT error "Token is expired" | Clock skew or short TTL | Increase expiration; sync server clocks |
| Dashboard shows no data | Parameters not matching filter values | Verify parameter names match dashboard filter slugs |
| CORS errors in browser console | Metabase not allowing your domain | Admin → Settings → Embedding → Authorized origins |
| Iframe blocked by CSP | Content Security Policy too restrictive | Add frame-src https://metabase.company.com to CSP |
| Blank iframe on Safari | Third-party cookie blocking | Use SDK instead of iframe; or SameSite=None cookies |
| Slow initial load | No caching, cold warehouse | Configure Metabase caching; use saved questions |
| "No permission" error in embed | JWT groups not mapping to Metabase groups | Verify group names in JWT match Metabase group names exactly |
| Parameters ignored in embed | Wrong parameter key name | Use dashboard filter slug, not display name |
| Dark mode not applying | Theme parameter syntax error | Use #theme=night as URL hash, not query param |
Debug Checklist
- [ ] Verify embedding secret matches between app and Metabase
- [ ] Check JWT payload with jwt.io (do NOT paste production secrets)
- [ ] Confirm parameter names match filter slugs in dashboard URL
- [ ] Test in incognito mode (rule out cached auth issues)
- [ ] Check browser console for CORS/CSP errors
- [ ] Verify authorized origins include your domain
- [ ] Test with a simple dashboard first before complex ones
---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Public sharing for sensitive data | Data exposure to anyone with URL | Use signed or interactive embedding |
| Long-lived JWT tokens (>1 hour) | Security risk if token leaked | Keep TTL to 10-15 minutes |
| Hardcoding embedding secret in frontend | Secret exposed in browser | Generate JWT server-side only |
| Not locking customer_id parameter | Users can see other customers' data | Always lock tenant identifiers in JWT params |
| Embedding without CSP headers | Clickjacking vulnerability | Set Content-Security-Policy: frame-ancestors |
| Using iframe when SDK is available | Missing interactivity, worse UX | Use SDK for Pro/Enterprise |
| No caching on embedded dashboards | Slow load, poor user experience | Enable Metabase caching for embedded questions |
| Embedding full Metabase URL | Exposes Metabase instance to end users | Use /embed/ routes, not regular dashboard URLs |
---
Cross-References
permissions-collections.md— Permission model that governs embedded contentnative-query-patterns.md— SQL patterns used in embedded questionssecurity-access-patterns.md— Security layers for data underlying embedded dashboards
---
Last updated: 2026-02-10 | Next review: 2026-05-10
Metabase Native Query Patterns
Purpose: Operational reference for writing effective SQL in Metabase — variables, field filters, template tags, SQL snippets, caching, performance tuning, and common gotchas. Freshness anchor: Q1 2026.
---
Quick Reference: Template Tag Types
| Tag Type | Syntax | Generates | Use When |
|---|---|---|---|
| Text | {{text_var}} | String literal (quoted) | Free-text filter input |
| Number | {{number_var}} | Numeric literal | ID or amount filters |
| Date | {{date_var}} | Date literal | Date range filtering |
| Field Filter | {{field_filter_var}} | Full WHERE clause | Dynamic dashboard filters |
| Snippet | {{snippet: name}} | Reusable SQL fragment | Shared CTEs or conditions |
| Card (Sub-query) | {{#card_id}} | Saved question as subquery | Composing questions |
---
Variables (Text, Number, Date)
Basic Variable Syntax
-- Text variable: user types a string
SELECT *
FROM orders
WHERE status = {{status}}
-- Number variable: user types a number
SELECT *
FROM orders
WHERE customer_id = {{customer_id}}
-- Date variable: user selects a date
SELECT *
FROM orders
WHERE created_at >= {{start_date}}
AND created_at < {{end_date}}Variable Configuration
Click variable tag in editor sidebar:
├── Variable type: Text | Number | Date
├── Filter widget type: dropdown, search, date picker
├── Required: yes/no
├── Default value: optional
└── Label: human-readable nameOptional Variables (Handle NULL)
-- Optional filter: include WHERE only when value provided
SELECT *
FROM orders
WHERE 1=1
[[AND status = {{status}}]]
[[AND region = {{region}}]]
[[AND created_at >= {{start_date}}]]
-- The [[ ]] brackets make the clause optional
-- If user leaves filter empty, clause is excluded entirelyVariable Gotchas
| Issue | Cause | Fix |
|---|---|---|
| Text variable adds unwanted quotes | Metabase auto-quotes text vars | Use for string comparisons only |
| Number in text variable causes type error | Variable type mismatch | Set variable type to Number |
| Date comparison returns no results | Timezone mismatch | Use ::date cast or DATE_TRUNC |
| Optional clause breaks SQL | Syntax error inside [[ ]] | Ensure [[ ]] wraps complete AND clause |
| Variable in LIKE clause fails | Auto-quoting interferes | Use LIKE CONCAT('%', {{search}}, '%') |
---
Field Filters
What Makes Field Filters Special
- Generates an entire WHERE clause (not just a value)
- Connects to Metabase's filter widget (date pickers, dropdown lists)
- Supports "between", "is", "is not", relative dates
- Maps to a specific database column
Field Filter Syntax
-- Field filter replaces entire WHERE condition
SELECT
DATE_TRUNC('day', created_at) AS order_date,
COUNT(*) AS order_count,
SUM(amount) AS total_revenue
FROM orders
WHERE {{created_at_filter}}
GROUP BY 1
ORDER BY 1Field Filter Configuration
Click the variable tag → Set type to "Field Filter"
├── Database: your_database
├── Table: orders
├── Column: created_at
└── Widget type: Date (auto-selected for date columns)Field Filter on Non-Date Columns
-- Field filter on a category column
SELECT *
FROM orders
WHERE {{status_filter}}
-- Configure: Field Filter → orders → status
-- Widget: dropdown with auto-populated valuesField Filter Limitations
| Limitation | Workaround |
|---|---|
| Only works with a single table column | Use regular variables for cross-table filters |
| Cannot use inside CTEs directly | Filter in final SELECT, or use subquery |
| Cannot combine with other conditions on same column | Use regular variables instead |
| Must map to exact column in database | Create view if column name differs |
Field Filter in CTE Workaround
-- WRONG: field filter in CTE (will fail)
-- WITH filtered AS (
-- SELECT * FROM orders WHERE {{date_filter}}
-- )
-- CORRECT: filter in final query or use subquery
SELECT
o.order_id,
o.amount,
c.name
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE {{date_filter}} -- field filter on orders.created_at---
SQL Snippets
Creating Snippets
Native query editor → Snippet icon (or type {{snippet:)
→ Create new snippet
→ Name: "active_orders_cte"
→ Content: (SQL below)Snippet Examples
-- Snippet: "active_orders_cte"
active_orders AS (
SELECT *
FROM orders
WHERE status IN ('completed', 'processing')
AND is_test = false
AND created_at >= '2025-01-01'
)
-- Usage in any question:
WITH {{snippet: active_orders_cte}}
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS order_count
FROM active_orders
GROUP BY 1-- Snippet: "standard_exclusions"
AND is_test = false AND is_internal = false AND status != 'cancelled'
-- Usage: SELECT * FROM orders WHERE created_at >= {{start_date}} {{snippet: standard_exclusions}}
-- Snippet: "revenue_calculation"
SUM(CASE WHEN status = 'completed' THEN amount - COALESCE(refund_amount, 0) ELSE 0 END)
-- Usage: SELECT region, {{snippet: revenue_calculation}} AS net_revenue FROM orders GROUP BY 1Snippet Best Practices
| Practice | Reason |
|---|---|
Name with clear prefix: cte_, calc_, filter_ | Discoverability |
| Document what the snippet does in a comment | Maintainability |
| Keep snippets small and focused | Reusability |
| Use snippets for shared business logic | Consistency across questions |
| Review snippets quarterly | Prevent drift from business rules |
---
Saved Question Reuse (Card References)
Syntax
-- Reference a saved question by ID
SELECT
region,
COUNT(*) AS customer_count
FROM {{#42}} -- saved question #42 as subquery
GROUP BY 1
-- Metabase wraps saved question as:
-- (SELECT ... FROM ... WHERE ...) AS question_42Use Cases
- Use when: Reusing a complex filtered dataset across multiple analyses
- Use when: Non-SQL users built a question in the GUI that SQL users want to extend
- Avoid when: Performance-critical queries (adds subquery overhead)
Gotchas with Card References
| Issue | Cause | Fix |
|---|---|---|
| Slow performance | Subquery not optimized | Rewrite as CTE or materialized view |
| Column names change | Someone edited the saved question | Pin column aliases in the saved question |
| Circular reference | Question A references B which references A | Restructure to avoid cycles |
| Cannot use in CTE | Metabase limitation | Use as subquery in FROM clause |
---
Result Caching Configuration
Cache Settings
Admin → Settings → Caching
├── Saved question cache duration: 300 seconds (default)
├── Cache strategy: TTL | Schedule | Adaptive
├── Minimum query duration to cache: 1000ms
└── Max cache entry size: 100MB (default)Per-Question Cache Override
Question → Info → Caching
├── Use default: inherits global setting
├── Custom TTL: set specific duration
├── Schedule: cache refreshes at specific times
└── Don't cache: always run freshCache Strategy Decision
| Strategy | Use When | Setting |
|---|---|---|
| TTL (time-to-live) | Data updates on known schedule | Duration = pipeline interval |
| Schedule | Dashboard must be fresh by 9 AM | Schedule = daily at 8:30 AM |
| Adaptive | Variable update frequency | Metabase auto-adjusts |
| No cache | Real-time data required | Disable per question |
Cache Monitoring
-- Check cache hit rate (Metabase application database)
SELECT
DATE_TRUNC('day', started_at) AS day,
COUNT(*) AS total_queries,
COUNT(CASE WHEN cache_hit THEN 1 END) AS cache_hits,
ROUND(100.0 * COUNT(CASE WHEN cache_hit THEN 1 END) / COUNT(*), 1) AS hit_rate_pct
FROM query_execution
WHERE started_at > NOW() - INTERVAL '7 days'
GROUP BY 1
ORDER BY 1;---
Query Performance in Metabase
Performance Checklist
- [ ] Check
query_executiontable for slow queries (>10s) - [ ] Add database indexes for columns used in field filters
- [ ] Use materialized views for complex aggregations
- [ ] Enable caching for dashboards viewed frequently
- [ ] Limit result rows (Metabase caps at 2000 for display, 1M for download)
- [ ] Avoid SELECT * — select only needed columns
- [ ] Use date range filters to limit scanned data
Identifying Slow Queries
-- Top 10 slowest queries in last 7 days (Metabase application DB)
SELECT card_id, card_name,
ROUND(AVG(running_time) / 1000.0, 2) AS avg_seconds,
COUNT(*) AS execution_count
FROM query_execution
WHERE started_at > NOW() - INTERVAL '7 days' AND card_id IS NOT NULL
GROUP BY card_id, card_name ORDER BY avg_seconds DESC LIMIT 10;---
Common Metabase SQL Patterns
Pattern: Date Spine with Metrics
- Generate date spine with
generate_series(PG) or recursive CTE (MySQL) - LEFT JOIN daily aggregations onto spine
- COALESCE nulls to 0 for days with no data
Pattern: Running Total
- Use window function:
SUM(SUM(amount)) OVER (ORDER BY month)for cumulative - Combine with
DATE_TRUNCfor monthly/weekly granularity
Pattern: Top N with "Other"
WITH ranked AS (
SELECT category, SUM(amount) AS revenue,
ROW_NUMBER() OVER (ORDER BY SUM(amount) DESC) AS rn
FROM orders WHERE {{created_at_filter}} GROUP BY 1
)
SELECT CASE WHEN rn <= 10 THEN category ELSE 'Other' END AS category,
SUM(revenue) AS revenue
FROM ranked GROUP BY 1 ORDER BY SUM(revenue) DESC---
Common Gotchas
| Gotcha | Symptom | Fix |
|---|---|---|
| Timezone mismatch | Counts differ from other tools | Use AT TIME ZONE or ::date casts |
| Field filter in CTE | Error: "invalid syntax" | Move field filter to final WHERE clause |
| Variable in ORDER BY | Error or unexpected behavior | Use column position: ORDER BY 1 |
| Text variable with apostrophe | SQL injection / syntax error | Metabase parameterizes; use prepared statements |
| Empty optional variable | Query returns nothing | Wrap in [[ ]] for optional clause |
| Snippet in wrong position | Syntax error | Ensure snippet content matches context (CTE vs WHERE) |
LIMIT in saved question reference | Subquery truncates data | Remove LIMIT from referenced question |
| JSON columns | Cannot use field filters on JSON | Extract to typed column or use regular variable |
generate_series not available | MySQL or non-PostgreSQL database | Use recursive CTE or calendar table |
---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Hardcoded dates in SQL | Dashboards go stale | Use date variables or field filters |
| Business logic duplicated across questions | Inconsistency, maintenance burden | Use snippets or saved question references |
| SELECT * in native queries | Slow performance, unnecessary data | Select only needed columns |
| No caching on frequently viewed dashboards | Unnecessary load on database | Enable caching aligned with data freshness |
| Complex SQL instead of query builder | Non-SQL users cannot modify | Use GUI builder when possible; native SQL for complex only |
| Snippet used once | Unnecessary indirection | Inline the SQL; use snippets only for reuse |
| No comments in complex queries | Hard to maintain | Add SQL comments for business logic |
| Ignoring Metabase query execution log | Performance issues go unnoticed | Review slow queries weekly |
---
Cross-References
permissions-collections.md— Who can run native queriesembedding-integration.md— Parameter passing in embedded queriespartition-strategies.md— Table partitioning for query performancemonitoring-alerting-patterns.md— Database monitoring for query performance
---
Last updated: 2026-02-10 | Next review: 2026-05-10
Metabase Permissions and Collections
Purpose: Operational guide for configuring Metabase permissions — group-based access control, collection hierarchy, data sandboxing, database/table permissions, API automation, and multi-team organization. Freshness anchor: Q1 2026.
---
Decision Tree: Permission Architecture
START: How many distinct access levels do you need?
│
├─ 1-2 (e.g., admin + viewer)
│ └─ Simple group model
│ - Administrators group (built-in)
│ - All Users group with curated access
│
├─ 3-5 (e.g., admin, analyst, viewer, per-department)
│ └─ Group-per-role model
│ - One Metabase group per access level
│ - Collection hierarchy mirrors org structure
│
├─ 5-20 (e.g., per-customer or per-team sandboxing)
│ └─ Sandboxed groups model
│ - Data sandbox for row/column filtering
│ - Per-tenant groups with sandboxed permissions
│ - Requires Metabase Pro/Enterprise
│
└─ 20+ or dynamic
└─ API-managed groups
- Automated group creation via Metabase API
- SSO group sync (SAML/JWT attributes)
- Requires Metabase Pro/Enterprise---
Quick Reference: Permission Levels
Data Permissions (Database/Schema/Table)
| Level | Can Query | Can See Native SQL | Can See Raw Data |
|---|---|---|---|
| Unrestricted | Yes (any query) | Yes | Yes |
| Granular | Per-table control | Per-table control | Per-table control |
| No self-service | Saved questions only | No | Limited |
| Block | No access | No | No |
Collection Permissions
| Level | View | Create | Edit | Delete |
|---|---|---|---|---|
| Curate | Yes | Yes | Yes | Yes |
| View | Yes | No | No | No |
| No access | No | No | No | No |
Native Query Permissions
| Level | Effect |
|---|---|
| Query builder and native | Full SQL access |
| Query builder only | GUI query builder, no raw SQL |
| No | Cannot create questions |
---
Group-Based Permission Model
Core Concept
- Every user belongs to one or more groups
- Permissions are assigned to groups, never to individual users
- The All Users group sets the baseline (most restrictive)
- Additional groups add permissions (additive model)
Recommended Group Structure
| Group | Data Access | Collection Access | Use Case |
|---|---|---|---|
| All Users | Block all databases | View "Public Dashboards" only | Baseline |
| Data Analysts | Unrestricted on analytics DB | Curate "Analytics" collection | Power users |
| SQL Analysts | Native query on analytics DB | Curate "Analytics" collection | SQL users |
| Marketing Team | Granular: marketing tables only | View "Marketing" collection | Department |
| Finance Team | Granular: finance tables only | Curate "Finance" collection | Department |
| Executive Viewers | No self-service | View "Executive" collection | Read-only |
| External: ACME | Sandboxed (customer_id=ACME) | View "ACME Portal" collection | Customer |
Setup via Admin UI
Admin → People → Groups
1. Create group
2. Add members (or sync from SSO)
Admin → Permissions → Data
1. Select group
2. Set database/schema/table access level
Admin → Permissions → Collections
1. Select collection
2. Set group permission (Curate / View / No access)---
Collection Hierarchy
Design Principles
- Collections are like folders — nest for organization
- Permission inheritance flows down (child inherits parent unless overridden)
- Keep hierarchy shallow (max 3-4 levels)
- Use naming conventions for discoverability
Recommended Structure
Our Analytics (root)
├── Public Dashboards/ (All Users: View)
│ ├── Company KPIs
│ └── Product Metrics
│
├── Marketing/ (Marketing Team: Curate, Others: No access)
│ ├── Campaigns/
│ ├── Attribution/
│ └── [WIP]/ (Working drafts, same permissions)
│
├── Finance/ (Finance Team: Curate, Exec: View)
│ ├── Revenue/
│ ├── Forecasting/
│ └── Board Reporting/
│
├── Analytics Team/ (Data Analysts: Curate, Others: No access)
│ ├── Explorations/
│ ├── Data Quality/
│ └── Templates/
│
├── Customer Portals/ (No access for internal; per-customer groups)
│ ├── ACME Corp/ (External:ACME: View)
│ ├── Globex Inc/ (External:Globex: View)
│ └── Initech/ (External:Initech: View)
│
└── Archive/ (Admins only)Collection Permission Override
Parent collection: "Finance" — Finance Team: Curate
└── Child collection: "Board Reporting" — Finance Team: View (override)
Exec group: View
Effect: Finance team can curate most finance content
but only view (not edit) board reporting dashboards---
Data Sandboxing
What It Does
- Filters rows and/or hides columns based on user's group
- User sees full dashboard but data is scoped to their permissions
- Requires Metabase Pro/Enterprise
Row-Level Sandboxing
Admin → Permissions → Data → [Database] → [Table]
Select: "Sandboxed" for the group
Filter type: "Filter by a column in the table"
Column: customer_id
User attribute: customer_id (from SSO attributes)How It Works
User logs in via SSO with attribute: customer_id = "acme-123"
↓
Metabase appends WHERE customer_id = 'acme-123' to all queries
↓
Dashboard shows only ACME data
↓
User cannot override or see the filterColumn-Level Sandboxing
Admin → Permissions → Data → [Database] → [Table]
Select: "Sandboxed" for the group
Filter type: "Use a saved question to limit data"
Question: pre-built question that excludes sensitive columnsSandbox Configuration Checklist
- [ ] SSO configured with user attributes (customer_id, org_id, etc.)
- [ ] Sandbox attribute mapped in Admin → People → Group → Attribute
- [ ] Test with multiple user attributes to verify filtering
- [ ] Verify sandbox applies to native SQL queries (it does NOT by default)
- [ ] For native SQL sandbox, disable native query for sandboxed groups
- [ ] Test drill-down paths — sandbox must persist across drill-throughs
Sandbox Limitations
| Limitation | Workaround |
|---|---|
| Does not filter native SQL queries | Disable native query for sandboxed groups |
| Attribute must be string type | Convert IDs to strings in SSO claims |
| One sandbox per table per group | Use saved question approach for complex filters |
| Cannot sandbox on joined tables | Create denormalized view, sandbox that |
| Performance impact on large tables | Add index on sandbox filter column |
---
Database-Level vs Table-Level Permissions
When to Use Each
| Scenario | Level | Configuration |
|---|---|---|
| Team needs access to entire analytics DB | Database | Unrestricted on database |
| Team needs specific tables only | Table (Granular) | Unrestricted on specific tables, Block on others |
| Team needs filtered view of a table | Sandbox | Sandboxed on specific tables |
| External users, strict isolation | Database + Sandbox | Block all, sandbox specific tables |
Granular Table Permissions
Admin → Permissions → Data → [Database]
Select "Granular" for group
Table: fct_orders → Unrestricted
Table: fct_payments → Unrestricted
Table: dim_customers → Sandboxed (filter by region)
Table: stg_* → Block (staging tables hidden)
Table: raw_* → Block (raw tables hidden)---
Admin API for Permission Automation
List Groups
# GET all groups
curl -s -H "x-api-key: ${MB_API_KEY}" \
https://metabase.company.com/api/permissions/group | jq '.'Create Group
# POST new group
curl -s -X POST \
-H "x-api-key: ${MB_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name": "External: NewClient"}' \
https://metabase.company.com/api/permissions/groupSet Data Permissions
# PUT data permissions for a group
curl -s -X PUT \
-H "x-api-key: ${MB_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"groups": {
"7": {
"1": {
"data": {
"schemas": {
"analytics": {
"fct_orders": {"query": "all", "read": "all"},
"dim_customers": {"query": "none", "read": "none"}
}
}
}
}
}
}
}' \
https://metabase.company.com/api/permissions/graphSet Collection Permissions
# PUT collection permissions
curl -s -X PUT \
-H "x-api-key: ${MB_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"groups": {
"7": {
"15": "read",
"16": "none"
}
}
}' \
https://metabase.company.com/api/collection/graphAutomated Customer Onboarding
- Script flow: create group (
POST /api/permissions/group), create collection (POST /api/collection), set permissions via graph API, set sandbox - Automate via SSO group sync for scale
---
Multi-Team Organization Patterns
Pattern 1: Department-Based
- Use when: Teams have distinct data domains
- One group per department
- Collections mirror department structure
- Shared "Public" collection for cross-team dashboards
Pattern 2: Role-Based
- Use when: Access correlates with role, not department
- Groups: Admin, Analyst, Viewer, External
- Collections organized by topic, not team
- Role determines depth of access
Pattern 3: Hybrid (Department + Role)
- Use when: Both department and role matter
- User belongs to department group AND role group
- Department group controls data access (which tables)
- Role group controls capability (SQL vs GUI vs view-only)
---
Audit Logging
What Metabase Logs
| Event | Logged | Location |
|---|---|---|
| User login | Yes | login_history table |
| Question viewed | Yes | view_log table |
| Dashboard viewed | Yes | view_log table |
| Query executed | Yes | query_execution table |
| Permission changed | Yes | activity table |
| Content created/modified | Yes | activity table |
| Failed login attempts | Yes | login_history table |
Audit Queries (Application Database)
- Who accessed what: JOIN
view_logwithcore_user, filter by timestamp - Most active users: GROUP BY
core_user.emailonquery_execution, count queries - Failed logins:
login_historyWHEREsession_id IS NULL(failed attempts have no session)
---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Permissive "All Users" group | Everyone sees everything by default | Set All Users to Block; add access via specific groups |
| Individual user permissions | Unmanageable at scale | Always use groups; never per-user permissions |
| Flat collection structure | Hard to navigate, hard to permission | Nest collections by domain (max 3-4 levels) |
| Sandbox without disabling native SQL | Users can bypass sandbox with raw SQL | Disable native query for sandboxed groups |
| No naming convention for groups | Confusion between roles and clients | Prefix: Role: Analyst, Dept: Marketing, External: ACME |
| Manual group management for customers | Slow onboarding, error-prone | Automate via API or SSO group sync |
| No audit review | Permission drift goes unnoticed | Monthly audit of groups, members, and permissions |
| Shared admin credentials | No accountability | Individual admin accounts; review admin list quarterly |
---
Cross-References
embedding-integration.md— Permissions model for embedded dashboardsnative-query-patterns.md— SQL access governed by permission groupssecurity-access-patterns.md— Data lake security underlying Metabase permissions
---
Last updated: 2026-02-10 | Next review: 2026-05-10
Reports in Metabase API (Cards)
In Metabase, the UI "Question" is a card in the API.
Contents
- Core endpoints (most common)
- Card payload shape (practical subset)
- Query types
- Native SQL card example
- Editing workflow (recommended)
- Query Builder (MBQL) card example
- Parameters and safe query templating
- ID discovery cheatsheet
- Executing queries and exporting results
Core endpoints (most common)
- Create a card:
POST /api/card - Update a card:
PUT /api/card/:id - Read a card:
GET /api/card/:id
Dashboards are separate objects:
- Create a dashboard:
POST /api/dashboard - Read/update a dashboard:
GET|PUT /api/dashboard/:id
Note: Exact endpoints and response shapes can vary by Metabase version. Prefer to confirm on your instance by exporting an existing card via GET /api/card/:id and editing that JSON.
Card payload shape (practical subset)
When creating/updating a card, these fields cover most automation cases:
name(string, REQUIRED)description(string, optional)collection_id(int, where to store the card)display(string, chart type; examples:table,bar,line,pie)visualization_settings(object; seereferences/charts-settings.md)dataset_query(object, REQUIRED)
Query types
Metabase supports two query types in dataset_query:
| Type | type value | Best for |
|---|---|---|
| Native SQL | "native" | Full SQL control, stable automation |
| Query Builder (MBQL) | "query" | Replicating UI-built questions |
---
Native SQL card example
This is the most stable automation pattern: you control SQL directly.
dataset_query skeleton:
{
"database": 2,
"type": "native",
"native": {
"query": "select date_trunc('day', created_at) as day, count(*) as signups from users group by 1 order by 1"
}
}Minimal create payload:
{
"name": "Daily signups",
"collection_id": 10,
"display": "line",
"dataset_query": {
"database": 2,
"type": "native",
"native": {
"query": "select date_trunc('day', created_at) as day, count(*) as signups from users group by 1 order by 1"
}
},
"visualization_settings": {}
}Editing workflow (recommended)
1. Build a report in the Metabase UI until it looks correct. 2. Export it via GET /api/card/:id. 3. Treat that JSON as the source-of-truth template. 4. Apply small, targeted edits:
- SQL text
collection_iddisplayvisualization_settings
5. PUT the updated JSON back to PUT /api/card/:id.
This approach avoids guessing version-specific defaults and visualization keys.
---
Query Builder (MBQL) card example
For questions created via the UI query builder, Metabase uses MBQL (Metabase Query Language), a JSON-based format.
dataset_query skeleton for MBQL:
{
"database": 2,
"type": "query",
"query": {
"source-table": 5,
"aggregation": [["count"]],
"breakout": [["field", 12, {"temporal-unit": "day"}]]
}
}Minimal create payload (MBQL):
{
"name": "Daily order count",
"collection_id": 10,
"display": "line",
"dataset_query": {
"database": 2,
"type": "query",
"query": {
"source-table": 5,
"aggregation": [["count"]],
"breakout": [["field", 12, {"temporal-unit": "day"}]]
}
},
"visualization_settings": {}
}Tip: Build a question in the Metabase UI, then use browser DevTools (Network tab) to inspect the request payload. This shows the exact MBQL structure for your query.
Converting MBQL to native SQL
Use POST /api/dataset/native to convert an MBQL query to native SQL:
curl -X POST "$METABASE_URL/api/dataset/native" \
-H "X-API-KEY: $METABASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": {"source-table": 5, "aggregation": [["count"]]}}'---
Parameters and safe query templating
Avoid string-interpolating untrusted input into SQL. If you need runtime parameters:
1. Build the question in the Metabase UI with filters/variables. 2. Export the card JSON (GET /api/card/:id). 3. Reuse the exported dataset_query (including any parameter/template-tag structures) as your template.
Metabase query templating shapes vary by version and by whether the question is SQL vs MBQL. Export-first is the most reliable way to keep parameters compatible with your instance.
ID discovery cheatsheet
Prefer discovery over hardcoding numeric IDs across environments.
Common endpoints (version/edition dependent):
- Collections:
GET /api/collection,GET /api/collection/tree - Databases:
GET /api/database - Tables and fields:
GET /api/database/:id/metadata,GET /api/table,GET /api/field
Tip: When in doubt, use browser DevTools (Network tab) while saving a question in the UI to see the exact payload and IDs your instance uses.
Executing queries and exporting results
Use POST /api/dataset to run a query and get results:
curl -X POST "$METABASE_URL/api/dataset" \
-H "X-API-KEY: $METABASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"database": 2,
"type": "native",
"native": {"query": "SELECT COUNT(*) FROM orders"}
}'Export formats via POST /api/card/:id/query/:format:
| Format | Endpoint suffix |
|---|---|
| JSON | /json |
| CSV | /csv |
| XLSX | /xlsx |
#!/usr/bin/env python3
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
def _require_env(key: str) -> str:
value = os.getenv(key)
if not value:
raise SystemExit(f"Missing required env var: {key}")
return value
def _base_url() -> str:
url = _require_env("METABASE_URL").rstrip("/")
return url
def _request(method: str, path: str, headers: dict[str, str], body: object | None = None) -> tuple[int, dict, bytes]:
url = f"{_base_url()}{path}"
data = None
final_headers = {"Accept": "application/json", "User-Agent": "data-metabase-skill/1.0", **headers}
if body is not None:
data = json.dumps(body).encode("utf-8")
final_headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=final_headers, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
content_type = resp.headers.get("Content-Type", "")
if "application/json" in content_type and raw:
try:
return resp.status, json.loads(raw.decode("utf-8")), raw
except json.JSONDecodeError:
return resp.status, {}, raw
return resp.status, {}, raw
except urllib.error.HTTPError as e:
raw = e.read() if hasattr(e, "read") else b""
payload = {}
if raw:
try:
payload = json.loads(raw.decode("utf-8"))
except json.JSONDecodeError:
payload = {}
return e.code, payload, raw
def _auth_candidates() -> list[tuple[str, dict[str, str]]]:
candidates: list[tuple[str, dict[str, str]]] = []
api_key = os.getenv("METABASE_API_KEY")
if api_key:
candidates.append(("api_key_x_api_key", {"X-API-KEY": api_key}))
candidates.append(("api_key_bearer", {"Authorization": f"Bearer {api_key}"}))
session = os.getenv("METABASE_SESSION")
if session:
candidates.append(("session_env", {"X-Metabase-Session": session}))
username = os.getenv("METABASE_USERNAME")
password = os.getenv("METABASE_PASSWORD")
if username and password:
status, payload, _ = _request("POST", "/api/session", {}, {"username": username, "password": password})
if status == 200 and isinstance(payload, dict) and payload.get("id"):
candidates.append(("session_login", {"X-Metabase-Session": str(payload["id"])}))
return candidates
def _pick_auth_headers() -> tuple[str, dict[str, str]]:
for name, headers in _auth_candidates():
status, payload, _ = _request("GET", "/api/user/current", headers)
if status == 200 and isinstance(payload, dict) and payload.get("id"):
return name, headers
raise SystemExit(
"Authentication failed. Set METABASE_URL and either METABASE_API_KEY or METABASE_USERNAME+METABASE_PASSWORD."
)
def cmd_whoami(_: argparse.Namespace) -> None:
method, headers = _pick_auth_headers()
status, payload, _ = _request("GET", "/api/user/current", headers)
if status != 200:
raise SystemExit(f"whoami failed with status {status}: {json.dumps(payload)[:500]}")
print(json.dumps({"auth_method": method, "user": payload}, ensure_ascii=False, indent=2))
def cmd_health(_: argparse.Namespace) -> None:
status, payload, raw = _request("GET", "/api/util/health", {})
if status != 200:
raise SystemExit(f"health failed with status {status}: {raw[:500]!r}")
if payload:
print(json.dumps(payload, ensure_ascii=False, indent=2))
return
print(raw.decode("utf-8", errors="replace"))
def cmd_export_card(args: argparse.Namespace) -> None:
_, headers = _pick_auth_headers()
status, payload, raw = _request("GET", f"/api/card/{args.id}", headers)
if status != 200:
raise SystemExit(f"export-card failed with status {status}: {raw[:500]!r}")
with open(args.out, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(args.out)
def cmd_export_dashboard(args: argparse.Namespace) -> None:
_, headers = _pick_auth_headers()
status, payload, raw = _request("GET", f"/api/dashboard/{args.id}", headers)
if status != 200:
raise SystemExit(f"export-dashboard failed with status {status}: {raw[:500]!r}")
with open(args.out, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(args.out)
def _load_json(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
value = json.load(f)
if not isinstance(value, dict):
raise SystemExit(f"Spec must be a JSON object: {path}")
return value
def cmd_upsert_card(args: argparse.Namespace) -> None:
_, headers = _pick_auth_headers()
spec = _load_json(args.spec)
card_id = spec.get("id")
if card_id:
payload = {k: v for k, v in spec.items() if k != "id"}
status, updated, raw = _request("PUT", f"/api/card/{card_id}", headers, payload)
if status not in (200, 202):
raise SystemExit(f"upsert-card update failed with status {status}: {raw[:500]!r}")
print(json.dumps({"action": "updated", "id": card_id, "result": updated}, ensure_ascii=False))
return
status, created, raw = _request("POST", "/api/card", headers, spec)
if status not in (200, 201):
raise SystemExit(f"upsert-card create failed with status {status}: {raw[:500]!r}")
print(json.dumps({"action": "created", "result": created}, ensure_ascii=False))
def cmd_upsert_dashboard(args: argparse.Namespace) -> None:
_, headers = _pick_auth_headers()
spec = _load_json(args.spec)
dashboard_id = spec.get("id")
if dashboard_id:
payload = {k: v for k, v in spec.items() if k != "id"}
status, updated, raw = _request("PUT", f"/api/dashboard/{dashboard_id}", headers, payload)
if status not in (200, 202):
raise SystemExit(f"upsert-dashboard update failed with status {status}: {raw[:500]!r}")
print(json.dumps({"action": "updated", "id": dashboard_id, "result": updated}, ensure_ascii=False))
return
status, created, raw = _request("POST", "/api/dashboard", headers, spec)
if status not in (200, 201):
raise SystemExit(f"upsert-dashboard create failed with status {status}: {raw[:500]!r}")
print(json.dumps({"action": "created", "result": created}, ensure_ascii=False))
def main() -> None:
parser = argparse.ArgumentParser(description="Minimal Metabase API helper (health, auth, cards, dashboards).")
sub = parser.add_subparsers(dest="cmd", required=True)
health = sub.add_parser("health", help="Check API health endpoint.")
health.set_defaults(func=cmd_health)
whoami = sub.add_parser("whoami", help="Print current authenticated user.")
whoami.set_defaults(func=cmd_whoami)
export_card = sub.add_parser("export-card", help="Export a card JSON by id.")
export_card.add_argument("--id", type=int, required=True)
export_card.add_argument("--out", required=True)
export_card.set_defaults(func=cmd_export_card)
export_dashboard = sub.add_parser("export-dashboard", help="Export a dashboard JSON by id.")
export_dashboard.add_argument("--id", type=int, required=True)
export_dashboard.add_argument("--out", required=True)
export_dashboard.set_defaults(func=cmd_export_dashboard)
upsert_card = sub.add_parser("upsert-card", help="Create/update a card from a JSON spec.")
upsert_card.add_argument("--spec", required=True, help="Path to card JSON; include 'id' to update.")
upsert_card.set_defaults(func=cmd_upsert_card)
upsert_dashboard = sub.add_parser("upsert-dashboard", help="Create/update a dashboard from a JSON spec.")
upsert_dashboard.add_argument("--spec", required=True, help="Path to dashboard JSON; include 'id' to update.")
upsert_dashboard.set_defaults(func=cmd_upsert_dashboard)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)