apache superset and Metabase are the two open-source BI tools that every data team has to compare the moment a SaaS BI bill crosses the per-viewer wall at 500+ users — and the pair most teams get wrong on the first try because they treat both engines as "OSS BI" instead of as fundamentally different products aimed at different audiences. Superset is an analyst power tool with a full SQL Lab, 75+ chart types, and a real semantic layer. Metabase is a self-serve dashboard for business users with a no-code Question Builder, X-Ray auto-insights, and the smoothest embedded-analytics story in the OSS world. The category label is the same; the deployment shape, the target user, and the extensibility surface could not be more different.
This guide is the senior-DE comparison you wished existed the first time a stakeholder asked "should we self-host superset vs metabase for the new internal dashboard portal?" or "how does metabase vs superset change once we need multi-tenant embedded analytics for our SaaS product?" It walks through the Superset architecture (Flask + React + Celery workers + SQLAlchemy connectors, SQL Lab, calculated metrics), the Metabase runtime (Clojure single-JAR + H2/Postgres metadata + Question Builder + JWT signed embeds), state and caching on both sides (Redis + Celery result cache in Superset vs HikariCP + query cache in Metabase), governance surface (Superset RBAC + row-level security vs Metabase sandboxing + Enterprise SSO), and the 5-question decision tree senior engineers use to pick the right open source bi tool for a new team. Each section pairs a teaching block with a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.
When you want hands-on reps immediately after reading, drill the SQL practice library →, rehearse on aggregation problems →, and sharpen your joins with the SQL joins drills →.
On this page
- Why open-source BI matters in 2026
- Apache Superset — analyst-first, Preset-backed
- Metabase — self-serve, Question Builder
- Deployment + operations comparison
- Feature parity + gaps + when to pick which
- Cheat sheet — Superset vs Metabase recipes
- Frequently asked questions
- Practice on PipeCode
1. Why open-source BI matters in 2026
SaaS BI hits a per-viewer wall around 500 users — self-hosting Superset or Metabase turns the pricing line into a hardware line
The one-sentence invariant: modern SaaS BI (Tableau Cloud, Looker, Power BI Pro) is priced per named or per active viewer, so the marginal cost of the 501st user is exactly the same as the 5th — while self-hosting an OSS BI tool turns the cost into a fixed fleet of compute plus an engineering budget. Every other reason to look at apache superset or Metabase — data residency, air-gap deploys, plugin extensibility, embedded analytics for a product surface — is a consequence of the same "the vendor's pricing curve does not match our curve" observation. Once you internalise "OSS BI is a curve-shape change, not a feature checkboxes", the whole superset vs metabase interview surface collapses to a sequence of trade-offs about which curve you want on the compute side.
Three axes interviewers and buyers actually probe.
- Audience. Who is the primary user? An analyst who reads plans and writes SQL will thrive in Superset's SQL Lab. A non-technical product manager who wants "show me MRR by cohort" without opening a query editor is Metabase's target user. If your audience is mixed, the answer is usually "both tools, each pointed at the audience that fits", but that doubles ops surface.
- Governance. Row-level security, column-level masking, and audit logs are first-class in Superset (RBAC roles + row-level filters + Alerts). Metabase ships sandboxing + Enterprise SSO but the free tier's governance surface is thinner; anything past "read-only viewer role" pushes teams toward Metabase Enterprise or a wrapper.
- Distribution. How does the dashboard reach the reader? An internal Slack link or web app is fine for either. An embedded analytics surface inside a SaaS product (iframe with per-tenant filters + white-labelled UI) is Metabase's home turf — signed JWT URLs and sandboxing were designed for it, and it is where Metabase has clearly out-executed Superset for four years running.
The two-audience split — same category, different product.
- Superset ships a Java/Python analyst UX. The unit of work is a chart built on top of a dataset built on top of a database connection. You think in terms of "this dataset has these calculated metrics; this chart pins those metrics to a viz; this dashboard pins those charts to a grid". SQL Lab is the escape hatch when the point-and-click UI is not enough — and analysts use it constantly.
- Metabase ships a business-user UX. The unit of work is a question — a saved query result — grouped into a collection. You think in terms of "click through the Question Builder, pick a chart type, save, drop into a dashboard". Native SQL editor exists but is a second-class citizen for the target user.
The 2026 reality — what changed since 2022.
- Apache Superset 4.x graduated as a top-level Apache project (out of incubation), stabilised the semantic-layer datasets, added dashboard-native cross-filters, and shipped a first-class embedded analytics SDK via the Preset team.
- Metabase 0.50+ shipped Metabase Actions (write-back to source database), model layer for reusable metrics, and vastly improved the JWT-signed embedding story with per-user sandboxing and multi-tenant row filters.
- Cloud offerings matured — Preset Cloud for Superset and Metabase Cloud for Metabase — both offer managed hosting at $20–$40 per active user per month, positioning them as the "buy vs build" cousin of the self-hosted OSS versions.
- dbt integration is now table stakes for both: Superset reads dbt manifests for column lineage; Metabase reads dbt exposures for auto-populated model descriptions.
What interviewers and buyers listen for.
- Do you say "Superset is analyst-first, Metabase is business-user-first" in the first sentence? — senior signal.
- Do you describe the pricing shape as "SaaS is per-viewer marginal, self-hosted is fixed compute + engineering time"? — required answer.
- Do you mention "embedded analytics is Metabase's home turf, governance is Superset's" unprompted? — senior signal.
- Do you push back on "Metabase replaces Superset" with "they target different audiences"? — senior signal.
Worked example — same MRR dashboard, two tools
Detailed explanation. The classic OSS BI Hello World — build a monthly recurring revenue (MRR) dashboard sliced by plan tier, sourced from a Postgres subscriptions table — looks similar on the surface in both tools. The mental model is different: in Superset you write the SQL in SQL Lab, save it as a virtual dataset, then build charts from the dataset; in Metabase you point Question Builder at the subscriptions table, drag plan_tier to columns and monthly_amount to a sum aggregation, and save. Analysts feel at home in the first flow; business users feel at home in the second.
Question. Build a monthly MRR-by-plan-tier dashboard on a subscriptions(customer_id, plan_tier, monthly_amount, active) Postgres table in both Superset (SQL Lab + dataset) and Metabase (Question Builder). Highlight the runtime path and the target user.
Input.
| customer_id | plan_tier | monthly_amount | active |
|---|---|---|---|
| c1 | pro | 49 | true |
| c2 | free | 0 | true |
| c3 | pro | 49 | true |
| c4 | enterprise | 499 | true |
| c5 | pro | 49 | false |
Code.
-- Superset — SQL Lab query saved as a "virtual dataset"
-- (dataset name: mrr_by_plan; owned by analytics team)
SELECT
plan_tier,
DATE_TRUNC('month', CURRENT_DATE) AS month,
SUM(monthly_amount) FILTER (WHERE active) AS mrr
FROM subscriptions
GROUP BY plan_tier
ORDER BY mrr DESC;
;; Metabase — Question Builder equivalent (no SQL required)
;; Point at the subscriptions table, then:
;; filter: active = true
;; group by: plan_tier
;; summarise by: sum(monthly_amount)
;; visualise: bar chart, sort desc
;; The underlying MBQL (Metabase Query Language) is generated:
{:source-table "subscriptions"
:filter [:= [:field "active"] true]
:breakout [[:field "plan_tier"]]
:aggregation [[:sum [:field "monthly_amount"]]]
:order-by [[:desc [:aggregation 0]]]}
Step-by-step explanation.
- The Superset flow starts in SQL Lab — an analyst writes the SQL, runs it against the warehouse, saves the result as a virtual dataset. The dataset now shows up in the "Add chart" flow as a data source; every chart built on top of it inherits the SQL.
- Superset compiles chart-time filters (dashboard filters, cross-filters, RLS) into a wrapper
WHEREon top of the virtual dataset SQL. The final SQL sent to Postgres is the analyst's SQL wrapped by the chart-time predicate. - The Metabase flow starts in Question Builder — the user clicks the
subscriptionstable, filtersactive = true, groups byplan_tier, summarisessum(monthly_amount). Metabase generates the SQL server-side (SELECT plan_tier, sum(monthly_amount) FROM subscriptions WHERE active = true GROUP BY plan_tier ORDER BY 2 DESC) and runs it. - Both flows end in a bar chart pinned to a dashboard. The end-user experience is nearly identical. The producer experience diverged the moment we chose the tool: the Superset producer needs SQL, the Metabase producer does not.
- The operational difference: Superset caches the result in Redis (via Celery workers) keyed by the SQL + parameters; Metabase caches the result in its own query cache keyed by the MBQL + parameters. Cache TTL is configurable per dataset (Superset) or per question (Metabase).
Output.
| plan_tier | mrr |
|---|---|
| enterprise | 499 |
| pro | 98 |
| free | 0 |
Rule of thumb. If the primary producer of dashboards is an analyst who already writes SQL, Superset's SQL Lab + dataset flow rewards their skills. If the primary producer is a business user who wants "no SQL required", Metabase's Question Builder wins by a landslide. Match the tool to the producer, not the reader.
Worked example — the pricing curve break-even at 500 users
Detailed explanation. The most common reason teams look at OSS BI in the first place is the SaaS pricing wall. A quick TCO math on a 500-viewer BI deployment shows exactly why the self-hosted OSS option becomes attractive at that scale — and why the answer flips back to SaaS at very small scale.
Question. Compare the annual cost of Tableau Cloud (100/500/2000 viewers at $15/viewer/month) vs a self-hosted Superset cluster (a t3.large + Redis + Postgres metadata, 1 platform engineer at 15% allocation). Show where the curves cross.
Input.
| Component | Cost model |
|---|---|
| Tableau Cloud Viewer | $15 / viewer / month = $180 / viewer / year |
| Self-hosted Superset compute | 3 × t3.large + Redis + RDS Postgres ≈ $6K / year |
| Platform engineer time | $200K / year × 15% ≈ $30K / year |
Code.
# Break-even math — SaaS vs self-hosted OSS BI
viewers = [50, 100, 250, 500, 1000, 2000]
# SaaS: linear per-viewer
def saas_cost(n_viewers, per_viewer_year=180):
return n_viewers * per_viewer_year
# Self-hosted: fixed compute + fixed engineering time
def selfhost_cost(n_viewers, compute=6000, engineer=30000):
# engineer allocation grows modestly with fleet size
growth = 1 + max(0, (n_viewers - 500) / 2000)
return compute * growth + engineer * growth
for n in viewers:
saas = saas_cost(n)
oss = selfhost_cost(n)
print(f"{n:>5} viewers -> SaaS ${saas:>7,} vs OSS ${oss:>7,.0f}")
Step-by-step explanation.
- At 50 viewers the SaaS bill is $9K/year; the self-hosted OSS bill is dominated by the $30K platform-engineer allocation. SaaS wins by a wide margin.
- At 250 viewers the SaaS bill is $45K/year; the self-hosted OSS bill is ~$36K. The curves start converging but the operational risk of self-hosting (upgrades, on-call) still tilts toward SaaS.
- At 500 viewers the SaaS bill is $90K/year; the self-hosted OSS bill is ~$36K — the OSS bill is 40% of the SaaS bill and the pricing wall is now impossible to ignore.
- At 2000 viewers the SaaS bill is $360K/year; the self-hosted OSS bill is ~$54K (engineer allocation grows to compensate for larger fleet). OSS is 15% of SaaS.
- The crossover point is somewhere between 250 and 500 viewers, depending on how much engineering time you can genuinely allocate. Above 500, self-hosted OSS is dramatically cheaper on a rate basis but only if you actually have the engineer to run it.
Output.
| Viewers | SaaS annual | Self-hosted OSS annual | OSS as % of SaaS |
|---|---|---|---|
| 50 | $9,000 | $36,000 | 400% |
| 100 | $18,000 | $36,000 | 200% |
| 250 | $45,000 | $36,000 | 80% |
| 500 | $90,000 | $36,000 | 40% |
| 1000 | $180,000 | $45,000 | 25% |
| 2000 | $360,000 | $54,000 | 15% |
Rule of thumb. Below 250 viewers, SaaS BI is almost always cheaper once you account for platform engineer time. Above 500 viewers, self-hosted OSS is dramatically cheaper if you have the engineering budget to operate it. The break-even is not "cost of licences" — it is "cost of licences minus cost of engineer allocation".
Worked example — embedded analytics inside a SaaS product
Detailed explanation. A B2B SaaS company wants to ship an in-product analytics tab so their customers can see their own usage data. Every customer must only see their own rows; the iframe must look like the product (white-label, custom fonts). Metabase's signed-JWT embed with sandboxing was designed exactly for this pattern; Superset can do it but the story is thinner and involves more custom code.
Question. Write the server-side signed-JWT flow that generates a per-tenant Metabase embed URL for a dashboard filtered to customer_id = <requesting tenant>. Show how it would look in Superset for the same use case.
Input.
| Requirement | Value |
|---|---|
| Metabase dashboard id | 42 |
| Filter to enforce | customer_id = <tenant> |
| Session TTL | 10 minutes |
| Auth secret |
METABASE_JWT_KEY env var |
Code.
# Metabase — signed JWT embed (Python server-side)
import jwt, time
METABASE_SITE_URL = "https://bi.example.com"
METABASE_JWT_KEY = os.environ["METABASE_JWT_KEY"]
def metabase_embed_url(dashboard_id: int, tenant_id: str) -> str:
payload = {
"resource": {"dashboard": dashboard_id},
"params": {"customer_id": tenant_id}, # enforced server-side
"exp": round(time.time()) + 60 * 10,
}
token = jwt.encode(payload, METABASE_JWT_KEY, algorithm="HS256")
return f"{METABASE_SITE_URL}/embed/dashboard/{token}#bordered=false&titled=false"
# Client-side: iframe src = metabase_embed_url(42, current_tenant.id)
# Superset — embedded SDK equivalent (Python + JS)
# Superset requires a guest-token flow via the /api/v1/security/guest_token endpoint
import requests
def superset_guest_token(dashboard_uuid: str, tenant_id: str) -> str:
r = requests.post(
f"{SUPERSET_URL}/api/v1/security/guest_token/",
headers={"Authorization": f"Bearer {SUPERSET_SERVICE_TOKEN}"},
json={
"user": {"username": f"tenant-{tenant_id}", "first_name": "tenant"},
"resources": [{"type": "dashboard", "id": dashboard_uuid}],
"rls": [{"clause": f"customer_id = '{tenant_id}'"}],
},
)
return r.json()["token"]
# Client-side: use @superset-ui/embedded-sdk to mount the iframe with the guest token
Step-by-step explanation.
- The Metabase JWT flow encodes
resource+params+expinto a short-lived JWT signed with the shared secret. The Metabase server verifies the JWT and enforces theparams.customer_idfilter server-side — the client cannot tamper with it. - The iframe URL is
/embed/dashboard/<token>with URL fragment options for chrome, borders, titles. The whole flow is one server round-trip; no extra SDK is required. - The Superset flow requires a two-hop dance: the backend calls
/api/v1/security/guest_token/with a service token, receives a guest token, and passes it to the client. The client then loads the Superset embedded SDK and callsembedDashboard({ id, supersetDomain, mountPoint, fetchGuestToken })to render. - The Superset flow supports RLS clauses (like
customer_id = '<tenant>') inside the guest-token payload — same enforcement guarantee, more code to wire up. - Both flows require a reverse proxy so the iframe origin matches your product domain (or an explicit
X-Frame-Options: ALLOW-FROMheader). In practice, teams put both BI tools behind their own nginx or Cloudfront.
Output.
| Concern | Metabase | Superset |
|---|---|---|
| Server-side signing | JWT with HS256 + shared secret |
Guest token via REST API |
| Client integration | plain iframe with <token> in URL |
@superset-ui/embedded-sdk |
| Per-tenant filter enforcement |
params.customer_id in JWT |
rls clause in guest token |
| Lines of code (server) | ~10 | ~15 + service-token setup |
| Lines of code (client) | 1 (iframe) | ~30 (SDK + mount) |
Rule of thumb. For embedded analytics inside a customer-facing SaaS product, Metabase's signed-JWT flow is the lowest-friction path and it is what most teams pick. Superset can do it too, and the RLS integration is real, but expect more integration code and a heavier client SDK.
Senior interview question on OSS BI selection
A senior interviewer often opens with: "Walk me through how you would choose between Apache Superset and Metabase for a new team's BI stack. What are the three or four questions you ask, in order, and what answer to each one pushes you to one tool over the other?"
Solution Using a 4-question selection framework
Decision framework — Apache Superset vs Metabase
================================================
1. Who is the primary dashboard producer?
- Analyst who writes SQL → Superset SQL Lab wins
- Business user who wants no-code → Metabase Question Builder wins
2. Is embedded analytics inside a product a first-class requirement?
- Yes, ship dashboards inside our SaaS UI → Metabase JWT embed wins
- No, only internal viewers → either works
3. What governance surface is required?
- RBAC + row-level security + column masking → Superset RBAC wins
- Read-only + Enterprise SSO is enough → Metabase Enterprise is enough
4. What deployment story do we operate?
- Kubernetes cluster + platform team → Superset helm chart fits
- Docker on a VM, no platform team → Metabase single-JAR wins
Step-by-step trace.
| Scenario | Q1 producer | Q2 embed | Q3 governance | Q4 deploy | Picked |
|---|---|---|---|---|---|
| Internal analytics team, 30 analysts | analyst | no | RBAC | K8s | Superset |
| B2B SaaS product tab, 5K tenants | dev | yes | sandbox | K8s | Metabase |
| PM self-serve dashboards, 200 users | business | no | read-only | Docker | Metabase |
| Finance close reporting, 12 users | analyst | no | column mask | K8s | Superset |
| Marketing campaign dashboards, 40 users | mixed | no | read-only | Docker | Metabase |
After the 4-question pass, the tool choice is usually unambiguous. The remaining 10% — where both tools work — defaults to whichever the platform team already knows how to operate.
Output:
| Tool | When it wins |
|---|---|
| Apache Superset | analyst producers, deep governance, K8s ops, extensible viz plugins |
| Metabase | business-user producers, embedded analytics, low-ops single-JAR, Enterprise sandboxing |
Why this works — concept by concept:
- Producer vs reader — the audience axis — most teams optimise for the reader ("who sees the dashboard") and get the answer wrong. The producer's tool-comfort dominates the choice: an analyst will drown in Metabase's UI restrictions, a business user will drown in Superset's SQL Lab.
- Embedded analytics is a category-defining requirement — if you ship dashboards inside a product surface, Metabase wins by a wide margin. If you only ship internal dashboards, the question is moot.
- Governance is Superset's moat — Superset's RBAC + row-level security predates Metabase's sandboxing by three years and is more granular. Column masking and audit logs are first-class.
-
Ops shape matches team shape — Superset's Kubernetes helm chart assumes a platform team. Metabase's single-JAR assumes one engineer who wants to
docker runand move on. Mismatched ops shape doubles the operational cost. - Cost — the framework converges in 4 questions for 90% of teams. The 10% where both work defaults to whatever the team already operates; migration between the two tools costs 3–6 months of engineering time.
SQL
Topic — SQL
SQL practice for BI dashboard queries
2. Apache Superset — analyst-first, Preset-backed
apache superset is a Flask + React + Celery cluster — one dataset, N charts, one dashboard is the whole authoring model
The mental model in one line: a Superset installation is a Flask backend + a React frontend + a Celery worker fleet + a Redis broker + a Postgres metadata database, all wired to warehouse targets via SQLAlchemy connectors — and the authoring flow is connection → dataset → chart → dashboard. Once you say that out loud, every apache superset architecture interview question becomes a deduction from "Celery does the heavy lifting, SQLAlchemy speaks the dialect, and the metadata db stores everything about the UI state."
The five core components.
-
Flask backend (
supersetPython package) — serves the REST API, renders the React SPA shell, handles auth, dispatches queries. Runs in gunicorn behind an nginx reverse proxy in production. - React frontend — the SPA that runs in the browser. Renders the SQL Lab IDE, the chart explore view, the dashboard grid, and the admin UI. Talks to the backend via REST + WebSocket for query progress.
- Celery workers — a fleet of Python worker processes that pick up long-running query jobs from a Redis broker, run them against the warehouse, and write results back to the Redis result cache. Async queries in SQL Lab go through this path.
-
Redis — dual role: message broker for Celery + result cache for chart data. Chart-time cache keys are
(query_sql_hash, param_hash, tenant_id). - Metadata database (Postgres) — stores every Superset object: users, roles, database connections, datasets, charts, dashboards, saved queries, alerts. Not the warehouse data itself — just the "state of the Superset UI".
The authoring model.
- Database — a SQLAlchemy connection string pointing at the warehouse (Postgres, Snowflake, BigQuery, etc.). Admin creates it once.
- Dataset — a table or a saved SQL query registered as a queryable object. Two flavours: physical dataset (a real table) or virtual dataset (a saved SQL Lab query). Analysts define calculated metrics + calculated columns on the dataset.
- Chart — a viz (bar, line, heatmap, pivot table, etc.) bound to a dataset with dimensions, metrics, and filters. Superset ships 75+ chart types out of the box.
- Dashboard — a grid of charts with cross-filters, tab groups, and dashboard-level filters that flow down to the charts.
The semantic layer — datasets + calculated metrics.
- Every dataset has a schema of columns (physical or calculated) and metrics (SUM(x), AVG(y), or a fully custom SQL expression like
COUNT(DISTINCT customer_id) FILTER (WHERE active)). - Metrics are defined once on the dataset and re-used across every chart built on that dataset. Change the definition on the dataset → every downstream chart picks it up.
- Superset's semantic layer is real but not as deep as Looker's LookML — you cannot express joins as metric-level primitives, so complex joined datasets tend to live in the warehouse as materialised views.
SQL Lab — the analyst's IDE.
- Full SQL editor with autocomplete, schema browser, query history, saved queries, and async execution via Celery.
- Query results can be exported (CSV, JSON) or saved as a virtual dataset for use in charts.
- Every query runs as the logged-in user against the database connection — Superset supports impersonation (the query runs as the Superset user's identity on the warehouse via
impersonate_user=Trueon the connection).
RBAC + row-level security — Superset's governance moat.
- Ships five default roles: Admin, Alpha (can create datasets), Gamma (dashboard viewer), granter, sql_lab. Roles are extensible — teams write custom roles like "finance_read_only".
-
Row-level security (RLS) — attach a SQL predicate to a dataset that is auto-injected into every query built against it. Example:
region = current_user_region()— every viewer only sees rows for their own region. -
Column-level security via the
data_permissionsextension — hide sensitive columns from specific roles without changing the dataset. - Every dashboard, chart, dataset, and database connection has its own ACL — the granularity is per-object, not just per-role.
Alerts & Reports — first-class Slack + email.
- Attach an alert to a SQL query: "run every 15 minutes; if row count > 0, ping #on-call Slack channel with the results".
- Reports are scheduled dashboard snapshots — a Celery worker renders the dashboard headless (via a Playwright or Selenium worker), attaches the PNG + PDF to an email, and sends it on cron.
Common interview probes on Superset.
- "What is a virtual dataset vs a physical dataset?" — virtual = a saved SQL query registered as a queryable object; physical = a real warehouse table.
- "How does Superset cache chart data?" — Redis result cache keyed by SQL hash + params; TTL configurable per dataset.
- "How do you enforce row-level security?" — attach an RLS clause to the dataset; Superset injects it into every query built on the dataset.
- "How does Superset run async queries?" — SQL Lab dispatches long queries to Celery workers via the Redis broker; results are written back to Redis and polled from the browser.
- "What is Preset Cloud?" — the managed-hosting company started by the Superset creators; runs Superset multi-tenant on their K8s.
Worked example — creating a virtual dataset from a SQL Lab query
Detailed explanation. An analyst runs an ad-hoc query in SQL Lab that combines two warehouse tables (orders + customers) with a calculated revenue_bucket column. They save it as a virtual dataset called orders_enriched so the rest of the team can build charts on it without re-writing the SQL.
Question. Show the SQL, the "save as dataset" flow, and a calculated metric added on top. Explain what the metadata database now stores.
Input.
| Table | Purpose |
|---|---|
orders |
one row per order with customer_id, amount, placed_at
|
customers |
one row per customer with tier, signup_date
|
Code.
-- SQL Lab query — join + enrich
SELECT
o.order_id,
o.customer_id,
c.tier,
o.amount,
CASE
WHEN o.amount < 50 THEN 'small'
WHEN o.amount < 200 THEN 'medium'
ELSE 'large'
END AS revenue_bucket,
o.placed_at
FROM orders o
JOIN customers c USING (customer_id);
-- Save as virtual dataset: name = orders_enriched
# On the orders_enriched dataset, add a calculated metric:
metric_name: avg_order_value_by_tier
metric_label: AOV by tier
expression: SUM(amount) / NULLIF(COUNT(DISTINCT customer_id), 0)
d3_format: $,.2f
Step-by-step explanation.
- Superset executes the SQL Lab query against the warehouse and returns the result to the analyst. If the query is long-running (
> async threshold), it is dispatched to a Celery worker instead of running inline. - "Save as dataset" writes a row to the metadata database's
sqlatablestable with the SQL as the dataset definition. The row also stores the column schema (name + type + expression) inferred from the query result. - The
avg_order_value_by_tiermetric is stored in the metadata database'ssql_metricstable with the SQL expression. Any chart built onorders_enrichedcan now drag this metric into the "Metrics" slot without knowing the SQL. - When a chart on
orders_enrichedis opened, Superset compiles the final SQL asSELECT tier, SUM(amount) / NULLIF(COUNT(DISTINCT customer_id), 0) AS aov FROM (<virtual dataset SQL>) t WHERE <chart filters> GROUP BY tierand sends it to the warehouse. - Every chart built on
orders_enrichedbenefits from the dataset's column definitions, metrics, and any RLS applied to the dataset. Change the SQL on the dataset — every downstream chart updates on next refresh.
Output.
| tier | aov |
|---|---|
| gold | $185.20 |
| silver | $95.40 |
| free | $12.75 |
Rule of thumb. Virtual datasets are the escape hatch for "the warehouse does not have exactly the shape we want". Use them sparingly — every virtual dataset SQL runs at chart-time, so a heavy join in a virtual dataset multiplies its cost by the chart-count. For hot paths, materialise the join upstream in the warehouse.
Worked example — row-level security on a multi-tenant dataset
Detailed explanation. A shared orders dataset is used by 12 sales-team dashboards. Each sales rep should only see orders for their own region. Superset's RLS lets you attach a per-role predicate to the dataset without changing the underlying SQL.
Question. Configure a row-level security rule on the orders_enriched dataset that restricts each user to their assigned region. Show the resulting SQL sent to the warehouse.
Input.
| user | assigned_region |
|---|---|
| alice | us-east |
| bob | eu-west |
| admin | * (no filter) |
Code.
# Superset UI: Settings → Row Level Security → +Rule
# Rule name: sales_region_filter
# Filter type: Regular
# Datasets: [orders_enriched]
# Roles: [sales_rep] # applies to this role only
# Group Key: sales_region_filter # groups combine per user
# Clause:
region = '{{ current_user_region() }}'
-- Chart-time SQL for alice on orders_enriched:
SELECT tier, SUM(amount) AS revenue
FROM (
SELECT o.order_id, o.customer_id, c.tier, o.amount, ...
FROM orders o JOIN customers c USING (customer_id)
) t
WHERE region = 'us-east' -- injected by RLS
AND placed_at >= '2026-06-01' -- chart filter
GROUP BY tier;
Step-by-step explanation.
- Superset RLS rules live in the metadata database. Each rule targets one or more datasets, applies to one or more roles, and carries a SQL predicate (may reference Jinja macros like
current_user_region()). - When a chart is opened, Superset resolves every RLS rule matching the current user's roles for the chart's dataset and joins their clauses with
ANDinside the query wrapper. Admin role bypasses RLS by default. - The final SQL sent to the warehouse contains the RLS predicate as an outer
WHERE, guaranteeing that no rows outside the region ever reach the browser. The user cannot see or modify the predicate — it is injected server-side. - Multiple rules with the same
Group Keycombine withOR(union of allowed rows); differentGroup Keys combine withAND(intersection). This lets you compose "region OR product-line" gates cleanly. - RLS runs at query time, so the warehouse still executes the extra predicate — for large fact tables, add a matching index on
regionor partition byregionto keep the RLS filter cheap.
Output.
| Viewer | Rows visible in "Revenue by tier" chart |
|---|---|
| alice (sales_rep, us-east) | orders where region = 'us-east' |
| bob (sales_rep, eu-west) | orders where region = 'eu-west' |
| admin | all rows |
Rule of thumb. Use RLS for the common case ("this viewer sees only their region"), and reserve column-level masking for the rare case ("this viewer sees the row but not the SSN column"). RLS is cheap; column masking involves an extension.
Worked example — a Celery-backed async SQL Lab query
Detailed explanation. A data engineer runs a heavy SQL Lab query that scans 400M rows and takes 3 minutes to complete. If the query ran synchronously, the browser would time out. Superset dispatches it to a Celery worker and streams progress back over WebSocket.
Question. Configure Superset to run SQL Lab queries asynchronously via Celery, and trace what happens from "Run" click to result display.
Input.
| Query | Rows scanned | Duration |
|---|---|---|
Heavy scan of events
|
400M | ~3 min |
Code.
# superset_config.py — enable async SQL Lab
from celery.schedules import crontab
CELERY_CONFIG = {
"broker_url": "redis://redis:6379/0",
"result_backend": "redis://redis:6379/0",
"task_annotations": {"tasks.get_sql_results": {"rate_limit": "100/s"}},
}
RESULTS_BACKEND = "redis" # cache the query result
SQLLAB_ASYNC_TIME_LIMIT_SEC = 60 * 30
# start the Celery worker fleet
celery --app=superset.tasks.celery_app:app worker \
--concurrency=8 \
--loglevel=INFO
Step-by-step explanation.
- The analyst clicks "Run" on a query in SQL Lab. The React client detects the "async" flag on the database connection and calls
POST /api/v1/sqllab/execute/with anasyncpayload. - The Flask backend enqueues a Celery task
get_sql_resultsinto the Redis broker with the query text, database id, and user id. Returns aquery_idimmediately. - A Celery worker picks up the task from the broker queue, opens a warehouse connection as the impersonated user, runs the query, and writes the result to Redis under
sqllab:results:<query_id>. - The React client polls
GET /api/v1/sqllab/results/<query_id>(or subscribes via WebSocket) every 2 seconds. Whilestatus = 'running', the UI shows a spinner; onstatus = 'success', it fetches the payload. - If the query exceeds
SQLLAB_ASYNC_TIME_LIMIT_SEC, the worker times out and marks the queryfailed. The user sees a "Query timed out" error and can retry with a smaller scan.
Output.
| Stage | Actor | Storage |
|---|---|---|
| dispatch | Flask backend | Redis broker |
| execute | Celery worker | Warehouse |
| cache | Celery worker | Redis result cache |
| poll | React client | Redis result cache |
| render | React client | browser DOM |
Rule of thumb. Enable async SQL Lab on any Superset deployment used by analysts who write exploratory queries. Size the Celery worker pool to ~1 worker per concurrent heavy query; use rate limits on the worker task to prevent one heavy user from monopolising the fleet.
Senior interview question on Superset chart caching
A senior interviewer might ask: "Your Superset dashboard has 20 charts, each with a 30-minute cache. During peak hours, dashboard load takes 40 seconds because half the charts are cache-missing at once. Design a cache eviction and warming strategy."
Solution Using dataset-scoped cache TTL + Celery warm-up beat
# superset_config.py — per-dataset cache TTL + warm-up
CACHE_CONFIG = {
"CACHE_TYPE": "RedisCache",
"CACHE_REDIS_URL": "redis://redis:6379/1",
"CACHE_DEFAULT_TIMEOUT": 60 * 30, # 30 min default
}
DATA_CACHE_CONFIG = CACHE_CONFIG # chart data cache
FILTER_STATE_CACHE_CONFIG = CACHE_CONFIG
# Per-dataset TTL is set in the dataset UI:
# /datasets/list/ -> Edit dataset -> Advanced -> Cache Timeout
# set slow datasets to 4 hours, fast ones to 5 minutes
# Celery Beat schedule to warm the cache before peak hours
from celery.schedules import crontab
CELERYBEAT_SCHEDULE = {
"warm_up_hot_dashboards": {
"task": "cache-warmup",
"schedule": crontab(minute=45, hour=8), # 15 min before 9am peak
"kwargs": {
"strategy_name": "top_n_dashboards",
"top_n": 10,
},
},
}
Step-by-step trace.
| Step | Before | After |
|---|---|---|
| Cache TTL model | one global 30-min TTL | per-dataset TTL (5m fast, 4h slow) |
| Cache warm-up | none — cold on first hit | Celery Beat warms top-10 dashboards at 08:45 |
| Peak-hour dashboard load | 40s (half charts cache-miss) | ~2s (all charts warm) |
| Cache footprint | ~200 MB | ~350 MB (extra warm entries) |
| Worker load | bursty at peak | smoothed by pre-warm |
After the fix, the top-10 dashboards are pre-warmed by 08:45 every weekday. Users hitting them at 09:00 hit the Redis cache and get a sub-2-second load. Cold dashboards still cache-miss on first hit but that is acceptable because peak load is dominated by the top-10.
Output:
| Metric | Before | After |
|---|---|---|
| p95 dashboard load (peak) | 40s | 2s |
| Redis cache size | 200 MB | 350 MB |
| Cache-hit rate | 55% | 92% |
| Extra warehouse cost | — | 10 min of Celery + warehouse per morning |
Why this works — concept by concept:
- Per-dataset TTL matches data cadence — a "daily MRR" dataset does not need 30-second freshness; give it a 4-hour TTL. A "real-time errors" dataset needs 5 minutes. One global TTL is always wrong for both.
- Cache warm-up beats cold cache-miss latency — Celery Beat pre-warms the top-N dashboards during off-peak time. First user in the morning hits Redis instead of the warehouse; the cost of warming is amortised over hundreds of hits.
-
Result cache is Redis, keyed by SQL hash + params — two identical charts with different filters have different cache keys. This is why RLS makes the cache-per-user; add
X-Cache-Key-Userto differentiate per viewer. - Worker pool sizing dominates burstiness — under a burst of cache-miss requests, Celery workers become the bottleneck. Autoscale worker replicas on Kubernetes with an HPA on queue depth.
- Cost — the warm-up strategy costs one warehouse query per top-N dashboard chart per day (~ 200 queries/day for 10 dashboards × 20 charts). Cheap compared to the p95 latency win.
SQL
Topic — aggregation
Aggregation problems for dashboard queries
3. Metabase — self-serve, Question Builder
metabase is a Clojure single-JAR + Question Builder — one question, N filters, one collection is the whole authoring model
The mental model in one line: a Metabase installation is a single Clojure JAR + a small metadata database (H2 by default, Postgres in production) + optional Elasticsearch for search, and the authoring flow is data source → question → dashboard, where a question is a saved query result built via a no-code Question Builder or the Native SQL editor. Once you say that out loud, every "why does Metabase feel so different from Superset" question becomes a deduction from "one JAR, one metadata db, one no-code UI as the primary authoring surface."
The four core components.
-
Clojure backend (one JAR) — serves the REST API, renders the React SPA shell, handles auth, dispatches queries, runs the Question Builder → SQL compiler, hosts the query cache. Runs on any JVM; typical deployment is
java -jar metabase.jarbehind an nginx. - React frontend — the SPA served by the backend. Renders Question Builder, Native SQL editor, dashboards, collections, admin UI. Talks to the backend via REST + WebSocket for query progress.
- Metadata database — H2 by default (fine for dev), Postgres or MySQL for production. Stores users, groups, dashboards, questions, collections, settings, and the "field metadata" (semantic types, foreign keys, aggregations) that Question Builder needs to render sensibly.
-
Query cache — in-JVM (Caffeine) by default, with configurable per-question TTL. Cache key is
(MBQL hash, params, user context).
The authoring model.
- Data source — a JDBC connection to the warehouse (Postgres, MySQL, Snowflake, BigQuery, Redshift, and 20+ others). Admin creates it once; Metabase auto-syncs the schema and infers "semantic types" (email, currency, timestamp, foreign key) from column names + samples.
- Question — a saved query result. Two flavours: GUI question (built via Question Builder, compiled to SQL server-side) or Native question (raw SQL written by the analyst).
- Collection — a folder of questions + dashboards. Collections have their own permissions, so you can share a "Marketing" collection with the marketing team and keep it invisible to everyone else.
- Dashboard — a grid of question tiles with dashboard-level filters that map onto question filters.
Question Builder — the no-code SQL compiler.
- Point at a table → filter → group by → summarise → visualise. Each step generates a fragment of MBQL (Metabase Query Language, a JSON DSL) that the backend compiles to warehouse-specific SQL.
- The generated SQL is inspectable — click "View the SQL" in the Question Builder to see exactly what runs against the warehouse. This is important for debugging and for RLS-style audit.
- MBQL is expressive enough for 80% of dashboard queries. The 20% that need
WITHCTEs, window functions, or vendor-specific SQL drop into the Native SQL editor. - Question Builder shines because it uses the field metadata: foreign keys are auto-navigated, currency columns get money formatting, timestamps get "hour of day" / "day of week" bucket options.
X-Ray — one-click auto-insights.
- Point Metabase at a table or a question and click "X-Ray" — the backend runs a battery of exploratory queries (distinct values, distributions, correlations, time-series decompositions) and renders a mini-dashboard of insights.
- Not a replacement for actual analysis but a decent first-pass for "I have no idea where to start with this dataset."
- Rendering happens server-side; queries are all cached with the standard per-question TTL.
Metabase Actions — write-back to the warehouse.
- Attach an "action" to a question — a button in a dashboard that lets the viewer INSERT / UPDATE / DELETE rows in the source database.
- Requires the data source to be writeable (Metabase does not enforce write permissions itself — the underlying database's grants do).
- Common pattern: an ops dashboard with an "Approve refund" button that writes to a
refunds_approvedtable which downstream systems consume.
Embedded analytics — the Metabase moat.
- Signed JWT URLs for static embeds — encode the dashboard id + params + expiry into a JWT, iframe the resulting URL. Enforced server-side.
- Full-app embedding (Enterprise) — embed the whole Metabase app with theming, custom fonts, and per-tenant sandboxing.
-
Sandboxing — a per-group data filter on a table ("this group only sees
customer_id = <group.customer_id>"), enforced whether the user reaches the data via a dashboard, X-Ray, or ad-hoc question. - Per-question or per-dashboard params, forwarded from the JWT — the parent SaaS app knows which tenant is viewing, encodes the tenant id into the JWT, and Metabase enforces the filter.
Common interview probes on Metabase.
- "What is MBQL?" — Metabase's JSON query language, compiled to SQL server-side; the output of Question Builder.
- "How does the query cache work?" — in-JVM Caffeine cache keyed by MBQL + params + user; TTL configurable per question.
- "How does embedded analytics work?" — signed JWT URLs with server-side param enforcement; iframe the resulting URL from the parent app.
- "What is a sandbox?" — Enterprise feature that attaches a per-group filter to a data source; enforced across every access path.
- "What is Metabase Cloud?" — the vendor's managed-hosting offering; runs the same OSS core on their infra.
Worked example — building a question in Question Builder
Detailed explanation. A product manager wants "how many active users this month by plan tier?" without writing SQL. Question Builder walks them through it click by click.
Question. Build a Metabase question that returns active users by plan tier for the current month. Show the click sequence, the generated MBQL, and the compiled SQL.
Input.
| users | plan_tier | active | last_login_at |
|---|---|---|---|
| u1 | pro | true | 2026-07-14 |
| u2 | free | true | 2026-07-10 |
| u3 | pro | true | 2026-06-30 |
Code.
;; Question Builder click sequence (produces this MBQL):
{:database 5
:type :query
:query {:source-table 42 ;; users
:filter [:and
[:= [:field 101] true] ;; active = true
[:time-interval [:field 105] :current :month]]
:breakout [[:field 106]] ;; plan_tier
:aggregation [[:distinct [:field 100]]] ;; count(distinct users)
:order-by [[:desc [:aggregation 0]]]}}
-- Compiled SQL sent to Postgres:
SELECT
"plan_tier",
COUNT(DISTINCT "id") AS "count"
FROM "public"."users"
WHERE "active" = true
AND "last_login_at" >= date_trunc('month', now())
AND "last_login_at" < date_trunc('month', now()) + interval '1 month'
GROUP BY "plan_tier"
ORDER BY "count" DESC;
Step-by-step explanation.
- The PM clicks "New question" → picks the
usersdata source → picks theuserstable. Metabase loads the field metadata (semantic types, foreign keys) from the metadata database. - The PM adds a filter: click
active→ is → true. Metabase generates the MBQL filter clause[:= [:field 101] true]where101is the internal id of theactivecolumn. - The PM adds a second filter: click
last_login_at→ previous → current month. Metabase generates[:time-interval [:field 105] :current :month]— a relative time filter that will re-compute on every question run. - The PM adds a breakout on
plan_tierand a summarisationcount(distinct users). Metabase generates thebreakoutandaggregationMBQL clauses. - Metabase compiles the MBQL to warehouse-specific SQL — the exact SQL depends on the target dialect (Postgres shown above; Snowflake, BigQuery emit similar but not identical SQL). The compiled SQL is cached in the JVM cache under
(mbql-hash, param-hash).
Output.
| plan_tier | count |
|---|---|
| pro | 2 |
| free | 1 |
Rule of thumb. Question Builder covers 80% of BI queries with zero SQL. Native SQL is the escape hatch for the 20% that need window functions, CTEs, or vendor-specific SQL. Encourage business users to start in Question Builder; drop to Native only when the MBQL cannot express the query.
Worked example — Enterprise sandboxing on a multi-tenant table
Detailed explanation. A SaaS app has a shared events table with tenant_id column. Every embedded dashboard viewer belongs to one tenant and must only see their own rows. Metabase's sandboxing (Enterprise) attaches a per-group filter to the events table that is enforced across every question and dashboard.
Question. Configure a Metabase sandbox on the events table so that group tenant_abc only sees rows where tenant_id = 'abc'. Show the resulting SQL.
Input.
| Group | tenant_id filter |
|---|---|
| tenant_abc | 'abc' |
| tenant_xyz | 'xyz' |
| admin | * (no filter) |
Code.
// Metabase Admin → Permissions → Sandbox: events
{
"table_id": 84,
"group_id": 12,
"attribute_remappings": {
"tenant_id": ["variable", ["template-tag", "tenant_id"]]
},
"card_id": null // filter at the table level, not via a custom question
}
-- Any question a tenant_abc user opens against events becomes:
SELECT ...
FROM (
SELECT * FROM "public"."events" WHERE "tenant_id" = 'abc'
) sandbox
WHERE ...
Step-by-step explanation.
- Sandboxes live in the Metabase metadata database. Each sandbox targets one table and one group, with a filter that references a user attribute (a per-user JWT claim or SSO attribute).
- When a user in group
tenant_abcopens any question that touchesevents, Metabase rewrites the query to substitute theeventstable with the sandboxed subquery — the tenant filter is applied at the innermost source-table level. - The user's attribute (e.g.
tenant_id = 'abc') is read from either the JWT payload (embedded flow) or the SSO assertion (Enterprise SAML/OIDC). The user cannot see or modify the attribute. - Sandboxing is enforced across every access path: Question Builder, Native SQL, X-Ray, dashboards, exports. There is no way for a viewer in
tenant_abcto reach rows outsidetenant_id = 'abc'without an explicit permission change. - For the admin group with no sandbox, queries hit the raw table. This is why the admin group must be tightly scoped in production.
Output.
| Viewer | Rows visible to events
|
|---|---|
| tenant_abc user | WHERE tenant_id = 'abc' |
| tenant_xyz user | WHERE tenant_id = 'xyz' |
| admin | all rows |
Rule of thumb. For multi-tenant embedded analytics, sandbox at the source table level. Doing the filter at the question level is fragile — any user who bypasses the question (via Native SQL) escapes the filter. Sandboxing is enforced at the table level, so every access path inherits the filter.
Worked example — Metabase Actions for an ops dashboard
Detailed explanation. A customer-success team wants an ops dashboard where they can click "Approve refund" on any row and have Metabase write an update back to the source Postgres. Metabase Actions turns this into a two-step setup: enable actions on the data source, then attach an action to a dashboard button.
Question. Configure a Metabase Action that updates refunds.status = 'approved' for a selected row and expose it as a dashboard button.
Input.
| refunds | refund_id | order_id | status |
|---|---|---|---|
| r1 | o1 | pending | |
| r2 | o2 | pending |
Code.
-- Custom action (native SQL) — attached to the refunds data source
-- Action name: approve_refund
UPDATE refunds
SET status = 'approved', approved_at = now()
WHERE refund_id = {{refund_id}};
// Dashboard button binding — passes selected row's refund_id to the action
{
"action_id": 17,
"trigger": "click",
"parameter_mappings": {
"refund_id": {
"source": "row_field",
"field_ref": ["field", 42, null]
}
}
}
Step-by-step explanation.
- On the data source config, the admin enables "Model actions" — this grants Metabase write access via the connection's DB user (which must have UPDATE permission on
refunds). - The admin defines a custom action with a parameterised UPDATE. The
{{refund_id}}template tag is bound to a dashboard parameter at click time. - On the dashboard, the admin adds a "Button" element and binds it to the action. The button's parameter mapping pulls
refund_idfrom the currently selected row in the dashboard's refunds question. - When a CS agent clicks the button, Metabase substitutes the selected
refund_idinto the SQL and executes it via the source-database connection. Success/failure toast is shown in the UI. - Actions are audited in the Metabase activity log — every write is stamped with the acting user, timestamp, and parameters. Combined with row-level security on the underlying
refundstable, this gives a reasonable audit trail without a separate write-back service.
Output.
| refunds (after click on r1) | refund_id | status | approved_at |
|---|---|---|---|
| r1 | approved | 2026-07-15 14:22 | |
| r2 | pending | null |
Rule of thumb. Metabase Actions are great for low-frequency writes tied to a specific dashboard workflow (approvals, corrections, tag edits). For high-frequency writes or complex validation, keep the write path in a dedicated backend service — Metabase Actions are a lightweight convenience, not a replacement for a real app.
Senior interview question on Metabase embedded analytics
A senior interviewer might ask: "You need to embed a Metabase dashboard inside your SaaS product with per-tenant row isolation. Walk me through the end-to-end flow — auth, JWT payload, iframe, sandbox, expiry."
Solution Using signed JWT + attribute remap + sandbox
End-to-end embedded flow — Metabase Enterprise
==============================================
1. Parent app (your SaaS UI) knows the current tenant id from its session.
2. On page load, the parent app's backend calls its own function:
embed_url = build_metabase_embed(dashboard_id=42, tenant_id=user.tenant_id)
which:
- constructs a JWT payload:
{ resource: { dashboard: 42 },
params: { tenant_id: <tenant> },
exp: now + 10 min }
- signs with the shared JWT_KEY (HS256)
- returns f"{METABASE_URL}/embed/dashboard/{token}#..."
3. Parent app renders <iframe src="{embed_url}"> in the tenant's page.
4. Metabase receives the iframe request:
- verifies JWT signature with JWT_KEY
- extracts params.tenant_id
- loads user attribute tenant_id from the JWT
- applies the sandbox on the events table for this session:
WHERE tenant_id = '<tenant>'
- renders the dashboard with the sandbox in place
5. Every question on the dashboard runs against events with the sandbox filter
applied at the innermost source-table level. The tenant cannot escape it.
6. When the 10-minute JWT expiry passes, the next request re-signs a new JWT.
Step-by-step trace.
| Step | Actor | Action |
|---|---|---|
| 1 | Parent app | reads session, gets tenant_id |
| 2 | Parent app backend | signs JWT with tenant_id + dashboard_id + exp |
| 3 | Parent app frontend | renders <iframe src=embed_url>
|
| 4 | Metabase | verifies JWT, applies sandbox for tenant_id |
| 5 | Metabase | runs dashboard questions with WHERE tenant_id = '...'
|
| 6 | Both | on expiry, parent app signs new JWT |
The flow requires no per-user Metabase accounts — the tenants are anonymous to Metabase; only the JWT payload identifies them. This is why the model scales cleanly to 10K+ tenants without a per-tenant seat cost.
Output:
| Component | Responsibility |
|---|---|
| Parent app backend | own the tenant identity, sign JWT with tenant claim |
| Metabase | verify JWT, apply sandbox, render dashboard |
| Warehouse | execute the sandbox-wrapped query |
| Parent app frontend | iframe the returned URL, refresh on JWT expiry |
Why this works — concept by concept:
- Signed JWT is the trust boundary — Metabase does not know or care about your users. It trusts the JWT signature and the claims inside. If the JWT is valid, the claims are enforced server-side.
- Sandbox is applied at source table level — every question that touches the sandboxed table inherits the filter, regardless of how the user reaches the question. Filter at question level would leak via Native SQL.
-
Attribute remap is the params-to-user-attribute bridge — the JWT
paramsare only enforced if the sandbox is configured with an attribute remap. Skip the remap and the params become "just hints" that Native SQL can ignore. - Short expiry limits blast radius — a 10-minute JWT means a stolen token has a 10-minute lifetime. Combined with HTTPS + secure iframe, this closes most practical attack paths.
- Cost — the entire flow costs one JWT sign per page load (microseconds) and one sandbox rewrite per query (negligible). The engineering cost is ~1 day to set up + ongoing maintenance of the sandbox rules.
SQL
Topic — SQL
SQL practice for Metabase dashboards
4. Deployment + operations comparison
Superset ships a Kubernetes helm chart with 8+ pods; Metabase ships a single JAR — the deployment gap is the biggest hidden line item
The mental model in one line: a production Superset install runs 8+ pods (Flask, worker, beat, node, Redis, Postgres, alerts-worker, reports-worker) on Kubernetes via the official helm chart, while a production Metabase install runs a single JAR + a Postgres metadata db + a reverse proxy — the operational surface between the two differs by nearly an order of magnitude, and that gap is the biggest hidden line item in the TCO comparison. Once you say "helm chart vs single JAR", every "which is easier to operate" question becomes a deduction from the pod count.
Superset deployment — the K8s helm chart shape.
- The official helm chart deploys 8+ services:
superset-webserver(Flask),superset-worker(Celery),superset-worker-beat(Celery Beat scheduler),superset-node(front-end asset builder, dev only), Redis, Postgres metadata,superset-init(schema migrations),superset-alerts-reports(async worker for alerts + reports). - Every service is horizontally scalable. In production, teams typically run 2–3 replicas of webserver, 4–8 replicas of worker, 1 replica of beat (leader-election prevents duplicate schedules).
- The chart supports HPA on webserver + worker CPU/queue-depth. Persistent volumes are needed for the metadata Postgres and Redis snapshots.
- Config is a mix of
superset_config.py(Python file, mounted into the pods) + Kubernetes secrets (for DB passwords, JWT keys). Every non-trivial config change requires a helm upgrade. - Upgrades — helm upgrade → schema migration runs via
superset-initjob → rolling replacement of pods → downtime is minutes if migrations are simple, longer if they touch big metadata tables.
Metabase deployment — the single-JAR shape.
- One JVM process running
metabase.jar. Backed by a Postgres metadata database (H2 is fine for dev, do not use in prod — H2 loses data on JAR restart if not carefully handled). - HA is achieved by running 2–3 replicas of the JAR pointing at the same Postgres metadata. No leader election needed; the app is stateless (query cache is per-JVM but repopulates on cache-miss).
- The default deployment is
docker run metabase/metabasebehind an nginx. Kubernetes deployment is a simpleDeployment+Service+Ingress— no helm chart complexity. - Config is via env vars (
MB_DB_TYPE,MB_DB_CONNECTION_URI,MB_JETTY_HOST,MB_SITE_URL,MB_ENCRYPTION_SECRET_KEY). Change env → restart JAR → done. - Upgrades — pull new image tag → rolling restart. Schema migrations are auto-applied on startup by the JAR. Downtime is seconds.
Database backends — both use Postgres in production.
- Superset — Postgres for metadata (SQLAlchemy). Redis for cache + broker. Both required.
- Metabase — Postgres for metadata (Toucan ORM). Optional H2 for dev; not recommended for prod.
- Both auto-migrate their schema on startup. Both should run their metadata Postgres on a managed service (RDS, Cloud SQL) with backups.
Caching layer — Redis vs in-JVM.
- Superset uses Redis for two things: chart result cache (data cache) + Celery broker/results. Cache keys are per-SQL + per-params + optionally per-user.
- Metabase uses an in-JVM Caffeine cache for query results. TTL is per-question (configurable). Because the cache is per-JVM, scaling horizontally means each replica has its own cache — cache hit rate scales sub-linearly with replicas.
- Superset's Redis cache is shared across all webserver + worker pods → cache hit rate is closer to 100% of the theoretical maximum.
Horizontal scaling — worker fleet vs replica count.
- Superset scales in two dimensions: webserver replicas (for concurrent HTTP requests) and Celery worker replicas (for concurrent heavy queries). HPA both independently.
- Metabase scales by JAR replica count. Each replica handles both HTTP and query work in the same process. No worker fleet to size separately.
- For a 500-user deployment, Superset typically runs 3 webserver + 6 worker + 1 beat pods. Metabase runs 3 JAR replicas. The Metabase footprint is dramatically smaller.
Upgrade cadence + support.
- Superset — quarterly major releases + patch releases. Community support via Slack + GitHub. Paid support via Preset Cloud / Preset Enterprise.
- Metabase — quarterly major releases + patch releases. Community support via Discourse + GitHub. Paid support via Metabase Cloud / Metabase Enterprise.
- Both projects are well-maintained. Metabase has historically been faster to ship polish-level features; Superset has historically been faster to add analytical depth (new chart types, RBAC granularity).
TCO — the hidden line items.
- Superset ops — 3 webserver pods × 2 CPU + 6 workers × 2 CPU + Redis + Postgres ≈ 20 vCPU + 40 GB RAM. Platform engineer: 25% allocation for a mature install.
- Metabase ops — 3 JAR replicas × 4 CPU + Postgres ≈ 12 vCPU + 24 GB RAM. Platform engineer: 10% allocation for a mature install.
- The compute delta is real but small (~$500/month at cloud rates). The engineer allocation delta is large (~$30K/year). This is why Metabase's TCO is dramatically lower for teams without a dedicated platform team.
Common interview probes on deployment.
- "Why does Superset need Celery + Redis?" — Celery does async SQL Lab + alerts + reports; Redis is the broker + result cache.
- "Can you run Superset without Redis?" — technically yes (in-memory cache backends exist), but you lose async SQL Lab and horizontal cache sharing.
- "Can you run Metabase without an external Postgres?" — technically yes (H2), but never do this in prod because H2 is per-JVM and data loss is one restart away.
- "How do you HA Superset webserver?" — 3+ replicas behind a K8s Service, sticky sessions via cookie-based session store (not in-memory).
- "How do you HA Metabase?" — 3+ JAR replicas pointing at the same Postgres metadata; each JVM keeps its own cache.
Worked example — Superset helm chart install on Kubernetes
Detailed explanation. A platform team stands up a fresh Superset install on their existing EKS cluster. The helm chart takes care of the pods, but the team must configure the ingress, TLS, secrets, and a per-org superset_config.py mount.
Question. Show a minimal but production-ready helm values file that deploys Superset with 3 web pods, 6 worker pods, Redis, and an external RDS Postgres as the metadata database.
Input.
| Requirement | Value |
|---|---|
| K8s cluster | EKS 1.29 |
| Metadata db | RDS Postgres 15 |
| Ingress | AWS ALB with ACM cert |
| Replicas | 3 web + 6 worker + 1 beat |
Code.
# values.yaml — Superset helm chart
supersetNode:
replicaCount: 3
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { cpu: 2, memory: 4Gi }
supersetWorker:
replicaCount: 6
resources:
requests: { cpu: 500m, memory: 1Gi }
limits: { cpu: 2, memory: 4Gi }
supersetCeleryBeat:
enabled: true
replicaCount: 1 # leader-election prevents duplicates
postgresql:
enabled: false # use external RDS
# external Postgres wired via env vars below
redis:
enabled: true
master:
persistence: { enabled: true, size: 10Gi }
configOverrides:
secrets:
SECRET_KEY: "${SUPERSET_SECRET_KEY}"
overrides: |
SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://superset:${DB_PW}@rds.example.com:5432/superset_meta"
CACHE_CONFIG = {"CACHE_TYPE": "RedisCache", "CACHE_REDIS_URL": "redis://superset-redis:6379/1"}
RESULTS_BACKEND = "redis"
FEATURE_FLAGS = {"EMBEDDED_SUPERSET": True, "ALERT_REPORTS": True}
ingress:
enabled: true
ingressClassName: alb
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:...
hosts:
- superset.example.com
Step-by-step explanation.
-
helm install superset superset/superset -f values.yamldeploys 3 webserver + 6 worker + 1 beat pods + Redis. Thesuperset-initjob runs schema migrations against the external RDS. Thepostgresql: enabled: falseflag turns off the bundled Postgres. - Every pod mounts the
configOverrides.overridesblock assuperset_config.py. This is where the RDS connection string, cache config, and feature flags live.SECRET_KEYis sourced from a Kubernetes secret. - The ALB ingress terminates TLS via ACM and routes to the
supersetservice, which round-robins to the 3 webserver pods. Sticky sessions are handled by Superset's cookie-based session store. - HPA on
supersetWorkerscales the worker fleet based on queue depth (measured via a custom metric exporter). On peak hours, workers autoscale from 6 to 12; off-peak, they scale back down. - Rolling upgrades: bump the chart's
image.tag,helm upgrade. The init job re-runs the schema migration if the version bump includes one; if it fails,helm rollbackreverts.
Output.
| Pod | Count | Purpose |
|---|---|---|
| supersetNode | 3 | Flask webserver |
| supersetWorker | 6 | Celery workers |
| supersetCeleryBeat | 1 | schedule alerts + reports |
| redis-master | 1 | broker + cache |
| superset-init | 0-1 (job) | schema migrations |
Rule of thumb. The Superset helm chart is production-ready but assumes you already run K8s. If you do not, standing up EKS + ingress + ACM + RDS just to host Superset is over-engineered for < 200 viewers. Metabase's single JAR is dramatically less operational surface at that scale.
Worked example — Metabase single-JAR docker install
Detailed explanation. A startup with one backend engineer stands up Metabase on their existing Docker host with an external Postgres for metadata. The whole install is one docker run + an nginx config.
Question. Show a minimal but production-ready docker-compose.yml that deploys Metabase with an external Postgres for metadata and an nginx reverse proxy with TLS termination.
Input.
| Requirement | Value |
|---|---|
| Docker host | one 4-CPU / 8-GB VM |
| Metadata db | external RDS Postgres |
| Reverse proxy | nginx with Let's Encrypt |
| Replicas | 1 (single-node for now) |
Code.
# docker-compose.yml
version: "3.9"
services:
metabase:
image: metabase/metabase:v0.51.0
environment:
MB_DB_TYPE: postgres
MB_DB_HOST: rds.example.com
MB_DB_PORT: 5432
MB_DB_DBNAME: metabase_meta
MB_DB_USER: metabase
MB_DB_PASS: ${MB_DB_PASS}
MB_ENCRYPTION_SECRET_KEY: ${MB_ENCRYPTION_SECRET_KEY}
MB_SITE_URL: https://bi.example.com
JAVA_OPTS: "-Xmx4g -Xms1g"
ports:
- "127.0.0.1:3000:3000"
restart: unless-stopped
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
ports:
- "80:80"
- "443:443"
depends_on: [metabase]
restart: unless-stopped
# nginx.conf — TLS termination + reverse proxy to Metabase
server {
listen 443 ssl http2;
server_name bi.example.com;
ssl_certificate /etc/letsencrypt/live/bi.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bi.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
Step-by-step explanation.
-
docker compose up -dstarts the Metabase JAR container. On first boot, it reads theMB_DB_*env vars, connects to RDS, and runs schema migrations. Setup wizard is available athttps://bi.example.com/setup. - The Metabase container exposes port 3000 only on localhost. Nginx terminates TLS with Let's Encrypt certs and reverse-proxies to
http://127.0.0.1:3000. - To scale, add more replicas:
docker compose up --scale metabase=3behind an nginx load balancer, all replicas pointing at the same RDS. Each replica keeps its own in-JVM cache; cache-hit rate is per-replica. - Upgrades: bump the image tag →
docker compose pull → docker compose up -d. Metabase auto-applies any schema migrations on startup. Zero-downtime rolling upgrades require an nginx that can drain one replica at a time. - Config changes: edit env vars → restart the container. There is no
metabase_config.py— everything is via env or via the admin UI.
Output.
| Container | Count | Purpose |
|---|---|---|
| metabase | 1 (or N with LB) | serves UI + API + runs queries |
| nginx | 1 | TLS + reverse proxy |
| external RDS | 1 | metadata db |
Rule of thumb. For teams without a K8s platform, Metabase's docker run install is the fastest path to a working BI tool. Move to K8s only when replica count exceeds 3 or when you need a shared platform for other services.
Worked example — TCO comparison at 500 viewers
Detailed explanation. A finance team wants a straightforward TCO comparison for the two OSS tools at their target scale (500 viewers, moderate query volume). The math is worth doing explicitly because "compute cost" and "engineer time" are usually invisible in the vendor pitches.
Question. Compare 1-year TCO for Superset and Metabase at 500 viewers on AWS EKS + RDS. Include compute, storage, engineer allocation, and licence (both are $0 for OSS but include managed cloud options as reference).
Input.
| Line item | Superset | Metabase |
|---|---|---|
| Web/app pods | 3 × t3.large ($540/yr each) | 3 × t3.large ($540/yr each) |
| Worker pods | 6 × t3.large ($540/yr each) | n/a |
| Redis | ElastiCache small ($700/yr) | n/a |
| Metadata RDS | db.t3.small ($700/yr) | db.t3.small ($700/yr) |
| Engineer allocation | 25% × $200K = $50K | 10% × $200K = $20K |
Code.
def superset_tco():
web = 3 * 540
worker = 6 * 540
redis = 700
meta = 700
eng = 50_000
return dict(web=web, worker=worker, redis=redis, meta=meta, eng=eng,
total=web + worker + redis + meta + eng)
def metabase_tco():
app = 3 * 540
meta = 700
eng = 20_000
return dict(app=app, meta=meta, eng=eng,
total=app + meta + eng)
print("Superset:", superset_tco())
print("Metabase:", metabase_tco())
Step-by-step explanation.
- Superset's compute footprint is dominated by the worker fleet (6 pods × $540/yr = $3,240/yr). Metabase has no worker fleet, so the compute delta is ~$3,900/yr in Metabase's favour.
- Both tools need a small RDS Postgres for metadata (~$700/yr). Superset additionally needs Redis (~$700/yr).
- Engineer allocation is where the real cost lives. Superset's 8+ pod topology takes real time to operate — helm upgrades, K8s troubleshooting, cache warming, worker autoscaling, alerts on Celery queue depth. A mature install eats ~25% of a platform engineer.
- Metabase's single-JAR topology takes dramatically less time. Docker restart on env change, image bump on version upgrade, occasional Postgres tuning. A mature install eats ~10% of a platform engineer.
- At 500 viewers, Metabase's total 1-year TCO is ~$22K, Superset's is ~$56K. The delta is not the compute — it is the engineer allocation.
Output.
| Line item | Superset ($) | Metabase ($) |
|---|---|---|
| Web/app pods | 1,620 | 1,620 |
| Worker pods | 3,240 | — |
| Redis | 700 | — |
| Metadata RDS | 700 | 700 |
| Engineer allocation | 50,000 | 20,000 |
| Total (1 year) | 56,260 | 22,320 |
Rule of thumb. For a green-field OSS BI install without a dedicated platform team, Metabase's TCO is ~40% of Superset's mostly because of engineer time. If you already run K8s and have platform-team capacity, the compute delta is small and the choice should be driven by features, not TCO.
Senior interview question on Superset operations
A senior interviewer might ask: "Your Superset install has 8 worker pods but Celery queue depth spikes during peak hours and users see 'query still running' for minutes. Walk me through the diagnosis and the fix."
Solution Using HPA on queue depth + worker task rate limits + slow-query timeout
# 1) HPA on Celery worker queue depth (custom metric)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: superset-worker
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: superset-worker
minReplicas: 4
maxReplicas: 20
metrics:
- type: External
external:
metric:
name: celery_queue_depth
selector:
matchLabels: { queue: default }
target:
type: Value
averageValue: "10" # keep <=10 tasks/queue on avg
# 2) superset_config.py — rate-limit heavy tasks
CELERY_CONFIG = {
"task_annotations": {
"tasks.get_sql_results": {
"rate_limit": "100/s", # cap heavy query throughput
},
},
}
SQLLAB_ASYNC_TIME_LIMIT_SEC = 60 * 10 # kill queries > 10 min
SQLLAB_TIMEOUT = 60 # sync queries capped at 60s
SUPERSET_WEBSERVER_TIMEOUT = 90 # gunicorn worker timeout
Step-by-step trace.
| Step | Before | After |
|---|---|---|
| Worker replica count | fixed 8 | HPA 4-20 on queue depth |
| Heavy-query task rate | unbounded | 100/s |
| Sync-query timeout | none | 60s |
| Async-query timeout | none | 10 min |
| Peak queue depth | 400 | ~20 |
| p95 query wait | 3 min | 15s |
The combined fix scales the worker fleet during peak hours instead of running fixed at 8 pods. The rate limit prevents a single user's runaway query from starving everyone else. The sync/async timeouts kill zombie queries that would otherwise hold worker capacity forever.
Output:
| Metric | Before | After |
|---|---|---|
| Peak worker count | 8 | 18 |
| Peak queue depth | 400 | 20 |
| p95 dashboard load | 45s | 4s |
| Zombie query count | 12/day | 0/day |
| Extra AWS cost | — | ~$400/month for burst capacity |
Why this works — concept by concept:
- HPA on queue depth beats HPA on CPU — Celery workers are I/O-bound during query execution, so CPU never spikes. Queue depth is the correct signal for "we need more workers".
- Task rate limits stop cross-user starvation — a runaway heavy query from one user cannot monopolise the fleet if the task rate is capped.
-
Timeouts kill zombies — without a hard timeout, a hung query holds a worker slot forever.
SQLLAB_ASYNC_TIME_LIMIT_SECputs a ceiling on the damage. - Sync vs async is a user contract — sync queries block the browser, so their timeout must be short (60s). Async queries can afford 10 minutes because the browser is not blocked.
- Cost — HPA cost is O(burst capacity) — you only pay for the extra pods during peak windows. Timeouts are free. Rate limits are free. The whole fix is bounded in extra spend.
SQL
Topic — aggregation
Aggregation drills to build cache-friendly queries
5. Feature parity + gaps + when to pick which
Senior engineers pick superset vs metabase from a 5-question decision tree — never from "which has more chart types"
The mental model in one line: the tool choice is an audience + embedding + governance + ops-shape + semantic-depth decision, driven by 5 questions in order — not by "which one has 78 chart types instead of 60". Once you internalise the decision tree, the metabase vs superset interview surface collapses to "what does your team actually need?"
The 5 questions in order.
- Q1 — Audience. Analyst-heavy (SQL-comfortable, wants power)? → Superset. Business-user-heavy (wants no-code, wants insights)? → Metabase. Mixed? → default to Metabase unless embedding or governance flips it.
- Q2 — Embedded analytics. Critical (dashboards inside a customer-facing SaaS product with per-tenant isolation)? → Metabase, near-unambiguously. Only internal users? → either.
-
Q3 — Ops shape. Kubernetes-comfortable ops team with platform capacity? → Superset helm chart fits. No platform team, prefer
docker run? → Metabase single JAR fits. - Q4 — Budget for managed / enterprise. Have budget for Preset Cloud or Metabase Enterprise? → both open up (governance + support). No budget, pure OSS self-host? → Metabase wins on ops simplicity, Superset wins on OSS governance depth.
- Q5 — Semantic layer + dbt. Need calculated metrics, dbt exposures integration, complex column lineage? → Superset's semantic layer is deeper. Just need "sum of amount by dimension"? → Metabase's model layer is enough.
Feature-gap map.
- Superset wins on. RBAC + row-level security depth, chart-type breadth (75+), extensibility via viz plugins, semantic layer + calculated metrics, dbt integration for column lineage, alerts + reports (via Celery), impersonated queries (per-user warehouse identity).
- Metabase wins on. Embedded analytics story (signed JWT + sandboxing), Question Builder no-code UX, X-Ray auto-insights, single-JAR ops, five-minute install, Metabase Actions (write-back), collections as a governance primitive.
- Both are weak on (vs Looker). Deep semantic modelling with joined datasets as first-class primitives, LookML-style code-first BI, mature version control of dashboards + metrics.
When to skip both — the Looker / Tableau signal.
- 10K+ viewers with deep governance and audit requirements → Looker or Tableau are still the enterprise default. OSS tools scale but the compliance story requires custom wiring.
- Need code-first BI with git-versioned metric definitions → LookML has no OSS equivalent; teams that want this either buy Looker or build a semantic layer (dbt Semantic Layer, Cube, MetricFlow) on top of Superset/Metabase.
- Air-gap deploy with paid enterprise support required → both offer paid tiers (Preset Enterprise, Metabase Enterprise) but neither has the enterprise-support surface of Tableau Server for regulated industries.
Common interview probes on selection.
- "When is Superset not an option?" — no K8s platform team, embedded analytics is the primary use case, business-user producers are the majority.
- "When is Metabase overkill?" — deep governance requirements (row+column mask + audit + Enterprise SSO) on a green-field install; Superset's OSS governance is deeper than Metabase's.
- "Metabase vs Looker?" — Metabase wins on OSS cost and self-serve; Looker wins on LookML semantic modelling and enterprise governance. Different price bands.
- "Why not pick both?" — some large teams do — Superset for the analyst power tool, Metabase for the business-user dashboards. Doubles the ops surface, so most teams pick one.
Worked example — Q1 audience forces Metabase for a self-serve PM team
Detailed explanation. A product-management team of 30 wants "self-serve dashboards for our OKR reviews" without asking the data team for every chart. None of them writes SQL comfortably. Superset's SQL-Lab-first UX would force them to either learn SQL or wait on the data team; Metabase's Question Builder turns them into their own dashboard producers.
Question. Given the audience, walk through Q1 and show why Metabase is the near-unambiguous choice.
Input.
| Audience segment | Count | SQL comfort |
|---|---|---|
| Product managers | 30 | low |
| Analysts | 4 | high |
| Engineers | 12 | medium |
Code.
# Metabase setup — matches the audience
- data source: marketing_warehouse (RDS)
- schema browsing: auto-synced by Metabase
- Question Builder: point → filter → group → summarise (no SQL)
- Native SQL editor: available for the 4 analysts as an escape hatch
- Collections: /Marketing, /Product, /Engineering
- Dashboards: 10 templates cloned per team
Step-by-step explanation.
- The 30 PMs open Metabase → click "New question" → pick a table → drag columns and filters. They produce their own dashboards in an hour without asking the data team.
- The 4 analysts drop into Native SQL when Question Builder cannot express a CTE or window function. Their questions live in the same collections as the PMs' questions, so they can help debug.
- The 12 engineers occasionally add an X-Ray to a new table to sanity-check its distributions before shipping. Metabase's auto-explore covers the "I don't know what this table looks like" case.
- Superset would have forced the same 30 PMs to file tickets with the analytics team for every chart, because SQL Lab is the primary authoring UX and the point-and-click "Explore" view is much thinner than Metabase's Question Builder.
- The Q1 answer is unambiguous: audience-heavy in the low-SQL segment → Metabase.
Output.
| Audience | Metabase UX | Superset UX |
|---|---|---|
| Low-SQL PMs | Question Builder covers 80% | SQL Lab excludes them |
| High-SQL analysts | Native SQL escape hatch | SQL Lab is home |
| Medium-SQL engineers | X-Ray + Question Builder | possible but heavier |
Rule of thumb. If the majority audience does not write SQL comfortably, Metabase wins on Q1 alone. If the majority audience is SQL-comfortable, Q1 shifts to Superset — but Q2–Q5 still get a vote.
Worked example — Q2 embed forces Metabase for a SaaS product tab
Detailed explanation. A B2B SaaS company wants to ship a "Your Analytics" tab in-product so every customer can see their own usage. The tab must be white-labelled (custom fonts, no Metabase logo) and filtered per-tenant. Metabase's JWT + sandbox flow was built for this; Superset can do it but needs more integration code.
Question. Given the embedded-analytics requirement, show why Q2 makes Metabase the near-unambiguous choice.
Input.
| Requirement | Value |
|---|---|
| Embed shape | iframe inside customer app |
| Isolation | per-tenant row filter |
| White-label | custom CSS, no BI logo |
| Concurrent tenants | 5K |
Code.
# Metabase — one JWT flow, per-tenant sandbox, done
import jwt, time
def embed_url(dashboard_id, tenant_id):
token = jwt.encode(
{"resource": {"dashboard": dashboard_id},
"params": {"tenant_id": tenant_id},
"exp": round(time.time()) + 600},
os.environ["MB_JWT_KEY"], "HS256")
return f"{MB_URL}/embed/dashboard/{token}#bordered=false&titled=false&theme=night"
# Superset — guest token flow, ~3x more code
def guest_token(dashboard_uuid, tenant_id):
r = requests.post(
f"{SS_URL}/api/v1/security/guest_token/",
headers={"Authorization": f"Bearer {SS_SERVICE_TOKEN}"},
json={
"user": {"username": f"t-{tenant_id}", "first_name": "tenant"},
"resources": [{"type": "dashboard", "id": dashboard_uuid}],
"rls": [{"clause": f"tenant_id = '{tenant_id}'"}],
})
return r.json()["token"]
# then use @superset-ui/embedded-sdk on the client to mount
Step-by-step explanation.
- Metabase's flow is one JWT sign → iframe URL. White-labelling is via a
#theme=nightURL fragment + custom CSS injected via Enterprise; no BI logo shown. - Superset's flow is two REST calls (service auth + guest-token request) + the embedded SDK on the client side. The SDK adds ~40 KB of client JS + a mount step.
- Per-tenant row isolation is enforced in both (Metabase via sandbox on the
eventstable; Superset via RLS clause in the guest-token payload). Both are server-side; both are safe. - At 5K tenants, the JWT-per-page-load cost is one HMAC sign per hit (microseconds). Neither tool blinks at 5K concurrent tenants; the bottleneck is the warehouse, not the BI layer.
- Q2 verdict: Metabase wins because the integration cost is roughly a third of Superset's. Both tools would work; the delta is engineering time.
Output.
| Concern | Metabase | Superset |
|---|---|---|
| Server-side signing | JWT (one HMAC) | REST guest token (two hops) |
| Client integration | plain <iframe>
|
embedded SDK + mount |
| Per-tenant filter | sandbox on table | RLS clause in guest token |
| Lines of code (integration) | ~15 | ~50 |
| White-label story | Enterprise CSS + URL params | Enterprise SDK theming |
Rule of thumb. For embedded analytics inside a customer-facing SaaS product, Metabase wins on Q2. The engineering delta is real (~3x code); the resulting product tab is nearly identical for the end user.
Worked example — Q3 ops shape forces Superset on a K8s platform team
Detailed explanation. A large tech company runs 200+ services on their EKS platform. They already have Prometheus + Grafana + helm-based deployment as the standard. Adding Superset via the official helm chart is a routine ticket; adding a lone Metabase JAR would be a snowflake.
Question. Given the platform-team constraint, why does Q3 tilt toward Superset? Show the deployment shape.
Input.
| Constraint | Value |
|---|---|
| Platform team | yes, 6 engineers |
| Standard deploy | helm charts on EKS |
| Monitoring | Prometheus + Grafana |
| Existing services | 200+ helm-managed |
Code.
# Superset — fits the standard workflow
helm repo add superset https://apache.github.io/superset
helm install superset superset/superset -f values.yaml --namespace bi
# Prometheus scrapes metrics via annotations; Grafana dashboards from existing library
# Metabase — one-off, not helm-native
kubectl apply -f metabase-deployment.yaml
kubectl apply -f metabase-service.yaml
kubectl apply -f metabase-ingress.yaml
# Every config change is a manual apply; not chart-managed
Step-by-step explanation.
- The Superset helm chart integrates with the platform team's existing helm workflow —
helm install,helm upgrade, GitOps sync, standard values-file review. No new tooling. - Prometheus scrapes Superset metrics via existing ServiceMonitor CRs; the platform team's Grafana dashboards for helm-managed services light up for free.
- Metabase does not ship an official helm chart. The community-maintained ones exist but are less battle-tested. A platform team ends up writing their own Deployment + Service + Ingress YAML — one-off, not the standard shape.
- Every non-trivial change to Metabase (env var, replica count, image tag) is a manual
kubectl apply— not a helm upgrade with rollback semantics. Multiplied across 200+ services, the platform-team cost of one snowflake is real. - Q3 verdict: platform teams that already run helm prefer Superset because it fits the standard workflow. Teams without a platform team feel this pain in reverse.
Output.
| Concern | Superset on K8s | Metabase on K8s |
|---|---|---|
| Deploy mechanism | official helm chart | hand-rolled YAML |
| Upgrade shape | helm upgrade | kubectl apply |
| Monitoring integration | ServiceMonitor CRs standard | manual scrape config |
| Rollback | helm rollback | manual re-apply |
| Fit with 200+ helm services | native | snowflake |
Rule of thumb. Q3 (ops shape) is the question that most often forces Superset on large platform teams and most often forces Metabase on small teams without one. Both choices are correct in their context.
Senior interview question on OSS BI selection at scale
A senior interviewer might frame this as: "You join a Series B SaaS company that already has 200 internal users and wants to ship in-product analytics to 3K customer tenants. Walk me through how you'd pick between Superset, Metabase, and just paying for Looker. What would push you to each?"
Solution Using the 5-question framework + engine-specific trade-offs
Selection — Superset vs Metabase vs Looker
==========================================
Q1 Audience → internal analysts + 3K tenant end users
→ mixed: internal skews analyst, embed skews non-technical
Q2 Embed → 3K tenant iframes is the biggest single requirement
→ Metabase wins by a wide margin
Q3 Ops shape → Series B, small platform team, k8s comfortable but stretched
→ single-JAR ops is cheaper
Q4 Budget → Series B budget → $30-50K/yr for BI is fine, not $200K+
→ Metabase Cloud or self-host is in budget; Looker is not
Q5 Semantic → dbt in place; need column lineage but not LookML
→ Metabase model layer is enough
Engine-specific trade-offs
--------------------------
Apache Superset
+ Deep RBAC, row + column governance
+ Semantic layer + calculated metrics
+ Extensible viz plugins
− 8+ pods, higher ops cost
− Thinner embed story (guest token + SDK)
Metabase
+ Signed JWT embed with sandboxing at table level
+ Question Builder no-code UX for business users
+ Single-JAR ops, low platform-team cost
− Governance surface thinner than Superset OSS
− Semantic layer is model-level, not metric-level
Looker
+ LookML code-first metric definitions
+ Deep governance, enterprise support
+ Best-in-class semantic modelling
− Enterprise pricing ($200K+/yr baseline)
− Not OSS; vendor lock-in
Step-by-step trace.
| SaaS-company requirement | Answer | Pushes to |
|---|---|---|
| Q1 — Audience | mixed (internal analyst + external tenant) | Metabase (embed audience is the driver) |
| Q2 — Embed | 3K tenants inside product tab | Metabase (JWT + sandbox) |
| Q3 — Ops | small platform team | Metabase (single JAR) |
| Q4 — Budget | Series B constrained | Metabase Cloud or self-host |
| Q5 — Semantic | dbt + model layer is enough | Metabase model layer |
Five Metabase answers → Metabase is the obvious pick. If Q1 had been "all-analyst internal team" and Q2 "no embed", the framework would have shifted to Superset. If Q4 had been "$300K budget for enterprise BI", Looker would have been on the table.
Output:
| Verdict | Reasoning |
|---|---|
| Pick Metabase for this SaaS company | Embed audience + JWT sandbox + single-JAR ops + budget-friendly |
| Don't pick Superset | Embed story is thinner + ops cost higher for a small platform team |
| Don't pick Looker | Enterprise pricing not justified at Series B scale |
Why this works — concept by concept:
- 5 questions in order — answering Q1 (audience) first eliminates options early. If the embed audience dominates (Q2), the whole framework tilts toward Metabase in one step.
- Engine-specific trade-offs — each tool has 2-3 unique strengths and 2-3 hard constraints. Memorising them lets you fall back to "what would push you to X" answers when an interviewer probes deeper.
- Embed vs governance often conflict — Metabase wins on embed; Superset wins on governance. Real deployments often pick "primary tool for one audience, second tool for the other audience" — but that doubles ops.
- Budget matters at scale — Looker's enterprise pricing is a real gate. Below $200K/yr BI budget, the OSS tools are usually the only viable options.
- Cost — tool choice is a 1-3 year commitment; migration between BI tools costs 3-6 months of engineering time. Get Q1–Q5 right the first time; the framework converges fast.
SQL
Topic — SQL
SQL practice for BI selection interviews
SQL
Topic — joins
SQL joins problems for dashboard datasets
Cheat sheet — Superset vs Metabase recipes
- When Superset is enough. Analyst-heavy producer team, deep RBAC + RLS required, K8s platform team in place, semantic layer + calculated metrics needed, viz-plugin extensibility on the roadmap. The 80% case for internal analytics teams.
- When Metabase is enough. Business-user producer team, embedded analytics inside a customer product, single-JAR ops fits the team, X-Ray + Question Builder cover 80% of dashboards, Actions cover the occasional write-back. The 80% case for SaaS embedded analytics.
- When you need Looker or Tableau instead. 10K+ viewers with deep audit + compliance, LookML-style code-first semantic modelling, enterprise-support requirement for regulated industries, $200K+/yr BI budget.
-
Superset RBAC + RLS recipe. Define custom roles (
finance_read_only,sales_rep) → attach the roles to users → create RLS rules on shared datasets with Jinja predicates (region = '{{ current_user_region() }}') → group RLS rules withGroup Keyfor OR/AND composition. Column masking viadata_permissionsextension if needed. -
Metabase JWT embed recipe. Enable "Embedding in other applications" in Admin → Enterprise → set
MB_JWT_KEYenv var → sign a JWT server-side with{resource, params, exp}→ iframe the returned/embed/dashboard/<token>URL. Enterprise: add attribute remap + sandbox for per-tenant isolation. -
Docker single-node dev setup (both). Superset:
docker run -p 8088:8088 apache/supersetthensuperset db upgrade && superset init && superset fab create-admininside the container. Metabase:docker run -p 3000:3000 metabase/metabasethen visithttp://localhost:3000/setup. Metabase is one command; Superset is three. -
K8s HA setup (Superset). 3 webserver pods + 6+ worker pods (HPA on queue depth) + 1 beat pod + Redis + external RDS. Sticky sessions via cookie-based session store; TLS termination at the ingress (ALB, GKE Ingress, or nginx-ingress). Helm chart handles all pod topology; per-org config via
configOverrides. -
Cache TTL tuning per chart. Superset: set dataset-level
Cache Timeoutin the dataset UI; useSUPERSET_CACHE_TIMEOUTas the global fallback. Metabase: setAdvanced options → Caching TTLper question; multiply by inheritance for dashboards. -
Semantic layer recipe (Superset). Every dataset → define calculated columns (
CASE WHEN amount<50 THEN 'small' ...) + calculated metrics (SUM(amount) FILTER (WHERE active)) withd3_formatfor currency; version the dataset definitions via API export + git. - Model layer recipe (Metabase). Create a "model" from a saved question (Admin → Models → +) → define semantic types + display names + descriptions → downstream questions reference the model; changing the model definition ripples into every downstream question.
-
Alerts (Superset). Configure
ALERT_REPORTSfeature flag → set up the alerts worker (superset-alerts-reportspod) → in the UI, add an alert on a SQL query with a threshold + Slack/email destination. Cron schedule via Celery Beat. -
Actions (Metabase). Enable "Model actions" on the data source → define custom actions with parameterised SQL (
{{param_name}}) → attach an action to a dashboard button → bind the button's parameters to selected row fields. Audit trail via Metabase's activity log. - When to use Metabase Enterprise. Multi-tenant embed with per-tenant sandboxing, Enterprise SSO (SAML/OIDC), advanced audit logs, official support SLA. If you self-host OSS without Enterprise, you get JWT embed but no sandboxing.
- When to use Preset Cloud. Small team wants Superset without operating the K8s cluster; Preset hosts it multi-tenant with the same OSS core plus governance add-ons; pricing per-user is between raw self-host and Looker-tier.
Frequently asked questions
Is Apache Superset better than Metabase?
Neither is universally "better" — they target different audiences and different deployment shapes. Apache Superset is an analyst power tool with SQL Lab, 75+ chart types, deep RBAC + row-level security, and a semantic layer with calculated metrics; it shines when the primary dashboard producers are analysts on a Kubernetes-comfortable platform team. Metabase is a self-serve BI for business users with the smoothest embedded-analytics story in the OSS world, single-JAR ops, and X-Ray auto-insights; it shines when the producers are non-technical and when embedded analytics inside a SaaS product is a first-class requirement. If your producer team is analyst-heavy and you need deep governance, Superset wins. If your producer team is business-user-heavy or you're shipping embedded analytics to customers, Metabase wins. The most common superset vs metabase interview mistake is treating "which has more chart types" as the deciding question — it isn't.
What is the difference between Superset and Metabase?
Both are open-source BI tools that connect to a warehouse and render dashboards, but their architecture and authoring model differ substantially. Superset is a Flask + React + Celery worker fleet + Redis broker + Postgres metadata cluster; the authoring flow is database → dataset → chart → dashboard, and SQL Lab is a first-class primary UX for analysts. The semantic layer supports calculated metrics on datasets, row-level security is granular per role, and the deployment ships as a Kubernetes helm chart. Metabase is a single Clojure JAR + Postgres metadata; the authoring flow is data source → question → dashboard, and the Question Builder is the primary UX — a no-code compiler that turns clicks into MBQL, then into warehouse SQL. Governance is thinner in the OSS tier (sandboxing is Enterprise-only), embedded analytics via signed JWT is best-in-class, and ops are dramatically simpler (docker run fits a team without a platform engineer).
Does Apache Superset support row-level security?
Yes — Superset ships first-class row-level security (RLS) as part of its core RBAC system. You attach a per-role SQL predicate to a dataset via Settings → Row Level Security → +Rule, referencing Jinja macros like region = '{{ current_user_region() }}'. Superset auto-injects the predicate into every query built on that dataset, so the user cannot bypass it via chart filters or SQL Lab. Rules can be grouped with a shared Group Key for OR-composition (union of allowed rows) or kept separate for AND-composition (intersection). Combined with Superset's five default roles (Admin, Alpha, Gamma, granter, sql_lab) and extensible custom roles, RLS covers the vast majority of multi-tenant BI use cases without a plugin. For column-level masking, the data_permissions extension adds per-role column visibility. This governance depth is one of Superset's biggest wins over Metabase's OSS tier — Metabase's equivalent (sandboxing) is Enterprise-only.
When should I use Metabase over Superset?
Use Metabase when any of the four push-to-Metabase conditions hits: (1) the majority producer audience is business users who do not write SQL comfortably — Question Builder + X-Ray dramatically outperform Superset's Explore view for this group; (2) embedded analytics inside a customer-facing SaaS product is a first-class requirement — the signed-JWT flow + Enterprise sandboxing was built for it; (3) the ops team is small and prefers docker run over a helm chart with 8+ pods — Metabase's single JAR is dramatically less operational surface; (4) the time-to-first-dashboard budget is measured in hours, not days — Metabase's setup wizard gets a working dashboard live in ~15 minutes. Use Superset for the inverse: analyst-heavy producer teams, deep governance requirements, K8s-comfortable ops teams, and semantic-layer + calculated-metric needs. The metabase vs superset question almost always reduces to "who produces the dashboards and how do they reach the readers?"
How does Superset caching work?
Superset uses Redis as a chart data cache, keyed by the compiled SQL hash + parameters + optional per-user context. When a chart is opened, Superset computes the query, checks the Redis cache under (sql_hash, param_hash, user_id_optional), and returns the cached payload if present and unexpired. On cache miss, the query is dispatched (sync or async via Celery), the warehouse runs it, and the result is written to Redis with the dataset-configured TTL. Cache TTL is configurable per dataset (Advanced → Cache Timeout) — set 4 hours for slow-moving datasets like "daily MRR" and 5 minutes for fast-moving ones like "real-time errors". A global SUPERSET_CACHE_TIMEOUT provides the fallback. For predictable peak-hour performance, add a Celery Beat schedule (cache-warmup task) that pre-warms the top-N dashboards during off-peak hours; the first user of the morning hits the warm cache instead of the warehouse. Redis is shared across all webserver + worker pods, so the cache hit rate is close to the theoretical maximum regardless of replica count.
Which OSS BI tool has the best embedded analytics story?
Metabase — by a wide margin. Its signed JWT embed flow was designed from day one for the "iframe our dashboard inside your product" pattern: your backend signs a short-lived JWT with {resource, params, exp}, iframes the resulting /embed/dashboard/<token> URL, and Metabase verifies the signature + enforces the params server-side. Combined with Enterprise sandboxing (per-group filter on a source table, enforced across every access path including Native SQL), you get per-tenant row isolation with almost no integration code — the whole flow is ~15 lines of Python on the server plus a plain <iframe> in the client. Superset can do embedded analytics too, via its guest-token flow + @superset-ui/embedded-sdk, and it supports RLS clauses inside the guest-token payload — but the integration is ~3x more code and requires the Superset SDK on the client. If shipping analytics inside a customer-facing SaaS product is a first-class requirement, Metabase is the near-unambiguous OSS choice.
Practice on PipeCode
- Drill the SQL practice library → for the query patterns that back every Superset dataset and Metabase question.
- Rehearse on aggregation problems → when building MRR, cohort, and dashboard-summary queries.
- Sharpen SQL joins → for the multi-table datasets that both tools rely on.
- Layer window function drills → for the time-series + running-total dashboards senior BI engineers ship weekly.
- Stack the aggregation medium set → for realistic dashboard-query interview prompts.
- For the broader surface, read top data engineering interview questions →.
- Stack the prerequisites with the only 5 skills you need to become a data engineer →.
- Sharpen the SQL axis with the SQL for data engineering interviews course →.
- For ETL + dashboard system design, work through the ETL system design course →.
Pipecode.ai is Leetcode for Data Engineering — every Superset and Metabase recipe above ships with hands-on practice rooms where you wire the SQL Lab dataset, the Question Builder MBQL, and the embedded-JWT sandbox against real graded inputs. PipeCode pairs every reading with 450+ DE-focused problems and a real-time scoring engine, so you never have to wonder whether your `apache superset` or `metabase vs superset` answer holds up under a senior interviewer's depth probes.





Top comments (0)