Can each semantic layer express and run the same questions?

A capability comparison, not a performance benchmark. We ran 24 Jaffle-shop questions through Semantic Rails, MetricFlow, Cube, Malloy, Snowflake Semantic Views, and KtX, and recorded whether each layer could express and run each question, and how. We wrote a model for the first 16 in every layer; the other 8 are variants of those metrics, answered with every model frozen. We don't compare latency, token use, or cost.

Read this first.

With each layer's model frozen, answered out of 8: Semantic Rails 6, MetricFlow 2, Cube 7 (5 SQL API workarounds), Malloy 8, and KtX 3 (1 precomputed). The 8 frozen-model questions (q17–q24) each vary a metric that q01–q16 already use, and every assessed layer answers them with its model exactly as written for q01–q16, through its query-time interface only. Every other assessed answer needs a model change; the reasons are below. We chose the variants, knowing which parameters Semantic Rails composes at query time, so they aren't a ranking. Snowflake Semantic Views isn't assessed: its capture can't be re-run.

On all 16 questions (q01–q16), the 5 layers checked on the current data return the outputs of an independent answer key, with numbers matching within 1e-6 after normalization. They are Semantic Rails, MetricFlow, Cube, Malloy, and KtX.

Snowflake Semantic Views is a stale capture from 2026-04-07, on an earlier dataset. It hasn't been re-run and isn't counted above. It matches the answer key on 14 of 16 questions and differs on q07 and q16.

9 of those 16 questions target Semantic Rails features. We chose q08–q16 to exercise primitives Semantic Rails ships. They're a capability showcase, not a ranking, so the 7 shared questions (q01–q07) are scored separately below.

The answer key is SQL written directly against the shared data. An AI agent wrote it from the question text and the raw data without seeing any layer's models or outputs, and a second agent reviewed it (the rule for each question). Every layer reads the same shared views. The Snowflake Semantic Views capture ran when the shared lifecycle view held only 11 hand-authored rows, and re-running it needs a live Snowflake account.

This data can't test every rule. Delivered time never moves an order into another month, no customer orders at two stores, all 10 sessions are at one store on one day, and customer history covers 4 customers. Only Cube and Malloy apply q09's and q15's 7-day window boundaries exactly as the rule states; Semantic Rails, MetricFlow, and KtX count from the session start up to, not including, 7 days later (details below), and no answer on the current data depends on it. On q07, q09, q14, q15, and q16, matching the answer key is weak evidence that a layer implements the intended rule. So is q17: every session converts within an hour, so its 14-day rate equals q09's 7-day one.

One rubric assigns every label. A script applies the same rules to every layer, Semantic Rails included, and publishes the evidence behind each label (the rubric). q11, q12, and q24 ask for customer rollups that the shared data already holds (lifetime_order_count, lifetime_spend_cents), so a layer whose answer reads those columns is labeled precomputed there. On the frozen-model questions, two labels come first: not assessed, for a capture we can't re-run, and requires model change, with the reason and a documentation link for each. The first rule that applies gives the label:

  1. not_assessed: Frozen-model questions only: the layer isn't listed in frozen_model.yml, because this pack can't run it on them.
  2. requires_model_change: Frozen-model questions only: the layer's documented query-time interface can't express the question with its model unchanged; frozen_model.yml gives the reason and the documentation.
  3. unsupported: The layer didn't execute the question.
  4. precomputed: The answer reads a cross-row rollup column that the question declares in bypass_columns, in the executed SQL or in SQL it depends on.
  5. workaround: The answer depends on SQL written by hand for this pack: a derived table, a SQL-defined source or cube, or a statement outside the layer's semantic interface.
  6. native: None of the above: the layer's semantic constructs only.

We wrote every layer's models, with q01–q16 in view. So on q01–q16 every layer answers with metrics written for those questions, and a native label there says where the answer's logic lives, not whether the layer could answer a question its model wasn't written for. The frozen-model questions test that. MetricFlow, Cube, Malloy, and KtX are modeled on their current versions with the features they ship: MetricFlow conversion metrics and metric filters; Cube multi-fact queries, multi-stage measures, and subquery dimensions; Malloy arbitrary-condition joins and query-derived sources. KtX's Python semantic layer (ktx-sl) was reviewed for native alternatives: its joins are equality-only and it has no query-derived sources, so its workaround labels stand. Snowflake range joins aren't modeled, because the capture can't be re-run. A workaround label describes how we modeled a layer, not the layer itself. Nothing on this page claims that any layer, Semantic Rails included, is better than another.

Metric variants with every model frozen: q17–q24.

Each question varies a metric that q01–q16 already use: q09's conversion at 14 days (q17), q15's same-store conversion at 50 minutes (q18), trailing 3-month revenue across all stores (q19), revenue beside the prior month's (q20), revenue beside large-order revenue (q21), the average and maximum of item revenue, which every model sums (q22), and new thresholds for q10 and q12 (q23, q24). No layer's model defines the variant. Every assessed layer answers with its model exactly as written for q01–q16, checked by hash, through its documented query-time interface only. q17 and q18 also count their windows as a duration, from the session start up to, not including, 14 days or 50 minutes later, where q09's and q15's rule counts orders after the session start up to and including 7 days later; no order in this data falls on either boundary.

Output check: all 26 answers the layers executed match the answer key.

Semantic Rails 0.3.1, not a release (engine tree dd85761)
6 / 8answered
MetricFlow dbt-metricflow 0.15.0 / dbt-duckdb 1.11.0
2 / 8answered
Cube 1.7.45
7 / 8answered
5 SQL API workarounds
Malloy 0.0.57
8 / 8answered
Snowflake Semantic Views Snowflake CLI + semantic view trial account
–not assessed
KtX @kaelio/ktx 0.16.0 (ktx-sl)
3 / 8answered
1 precomputed
  • Semantic Rails, q19 and q20 need a model change: rolling and prior_period are query-time expressions, but they run over a dense calendar, and this package declares no calendar entity. The engine refuses the query ("time.fill requires a calendar entity in the package"; the refused query and its validation errors are in the Semantic Rails results), so the package needs a calendar entity first. (documentation)
  • MetricFlow, q17 needs a model change: the conversion window is part of the conversion metric's definition (window: 7 days). No query option changes it, so a 14-day window needs a new conversion metric. (documentation)
  • MetricFlow, q18 needs a model change: the window and the same-store constant property are part of the conversion metric's definition. No query option changes the window, so a 50-minute window needs a new conversion metric. (documentation)
  • MetricFlow, q19 needs a model change: a trailing window is a cumulative metric's window, set in its definition. The query interface has no window option, so it needs a new cumulative metric. (documentation)
  • MetricFlow, q20 needs a model change: a prior-period value is a derived metric with an offset_window, set in its definition. The query interface has no offset option, so it needs a new derived metric. (documentation)
  • MetricFlow, q21 needs a model change: a query's --where filters every metric in the query, so one query can't return total revenue beside the revenue of large orders; that needs a metric with its own filter:. (documentation)
  • MetricFlow, q22 needs a model change: a simple metric's aggregation is part of its definition (agg: sum for item_revenue_usd). The query interface can't choose another aggregation, so the average and the maximum each need a new simple metric. (documentation)
  • Cube, q17 needs a model change: the model's session-to-order join carries the 7-day window, and a query can't change a join's condition. The SQL API joins cubes only along the model's declared joins (__cubeJoinField or CROSS JOIN), so it can narrow that join (q18) but can't reach orders more than 7 days after a session. A 14-day window needs a new join in the model. (documentation)
  • Cube, q18–q21 and q23 are workarounds: an SQL API query answers it by wrapping a Cube query in SQL of its own, a derived table: with SUM OVER for q19, LAG for q20, a filter on each customer-month's order count for q23, a CASE over each order's revenue (an ungrouped query of the revenue measure) for q21, and for q18 a 50-minute flag over the declared 7-day same-store join, weighted by each group's session count. (Cube's join excludes an order at the session's start minute, which the rule counts, and Cube post-processes q18 over a Cube query result it caps at 50,000 rows; no order in this data falls on the start minute, and that result holds only the 10 sessions' same-store orders within 7 days. It has no ORDER BY, because Cube refuses to post-process the sorted query.) The rubric counts SQL written around a layer's members as hand-written, as it does SQL outside SEMANTIC_VIEW(...), so the label is workaround rather than native. A REST query has no such option: its filters apply to the whole query, and compareDateRange returns each date range as a separate result set, not the prior month beside each month. In the model these would be a rolling_window or time_shift measure, a measure with filters, or a multi-stage measure with the new threshold. (documentation)
  • KtX, q17 and q18 need a model change: the 7-day window is inside the pack's conversion SQL sources. ktx-sl joins are equality-only, and a semantic query has no join or window option, so a different window needs a new SQL source. (documentation)
  • KtX, q19 and q20 need a model change: ktx-sl rejects window functions in measures, inline query measures included ("Window functions (OVER clause) are not supported in measures"; the refused queries are in the KtX results), so a trailing or prior-period value needs a SQL source. (documentation)
  • KtX, q23 needs a model change: the more-than-10 threshold is inside the pack's customer-month SQL source. A query filter is row-level or applies at the query's own grain, so it can't count each customer-month's orders; a new threshold needs a new SQL source. (documentation)
native precomputed workaround requires model change or not assessed
  • The label says how, not only whether. Malloy answers q17 and q18 by declaring a windowed join in the query itself, which is Malloy syntax rather than SQL, so it is native. Cube's SQL API answers to q18–q21 and q23 wrap a Cube query in SQL of its own, so they are workarounds, as SQL outside SEMANTIC_VIEW(...) is; its q22 query only aggregates a measure, so it is native. Malloy's and Cube's q19 and q20 step over month rows, which equals the calendar rule here only because every month has orders.
  • Some variants don't discriminate on this data. q17 equals q09, q18's same-store condition removes nothing (every session and candidate order is at one store), and q22's maxima are each product type's top price in every month, so a layer returning the base metric would still match there. The labels don't depend on it: each layer's query for a variant is published with the pack.
  • We chose the set, knowing which parameters Semantic Rails composes at query time and which some other layers set in the model. It probes where each layer's frozen-model boundary lies. It isn't a ranking, and it doesn't weigh what a model change costs in each layer.

What we tested.

The version each runner recorded, and when it ran. Dates are UTC.

Layer Version Captured (UTC) Data
Semantic Rails 0.3.1, not a release (engine tree dd85761) 2026-09-26 Current dataset
MetricFlow dbt-metricflow 0.15.0 / dbt-duckdb 1.11.0 2026-09-26 Current dataset
Cube 1.7.45 2026-09-26 Current dataset
Malloy 0.0.57 2026-09-26 Current dataset
Snowflake Semantic Views Snowflake CLI + semantic view trial account 2026-04-07 Earlier dataset; not re-run
KtX @kaelio/ktx 0.16.0 (ktx-sl) 2026-09-26 Current dataset

The output checks, the question split, the rubric, this table, the scoreboards, the model-change reasons and the label tables are generated from the comparison pack's capability_matrix.json at engine commit 9059fba. The rest of the page is written by hand.

Shared questions: q01–q07.

Count, sum, and group-by-month: the surface every layer here was built to answer.

Output check: 7 of 7 match the answer key across the 5 layers checked on the current data. The stale Snowflake Semantic Views capture differs on q07.

Semantic Rails 0.3.1, not a release (engine tree dd85761)
7 / 7native
MetricFlow dbt-metricflow 0.15.0 / dbt-duckdb 1.11.0
7 / 7native
Cube 1.7.45
7 / 7native
Malloy 0.0.57
7 / 7native
Snowflake Semantic Views Snowflake CLI + semantic view trial account
7 / 7native
KtX @kaelio/ktx 0.16.0 (ktx-sl)
7 / 7native
native precomputed workaround or unsupported

Semantic-Rails-targeted questions: q08–q16.

We chose these 9 to exercise primitives Semantic Rails ships: metric predicates, temporal validity, same-store conversion, and contextual entity-graph inheritance. The labels record how this comparison models each layer today. They aren't a ranking.

Output check: 9 of 9 match the answer key across the 5 layers checked on the current data. The stale Snowflake Semantic Views capture differs on q16.

LayerLabels
Semantic Rails7 native · 2 precomputed (q11 and q12)
MetricFlow9 native
Cube9 native
Malloy9 native
Snowflake Semantic Views7 workaround (q08–q10 and q13–q16) · 2 precomputed (q11 and q12)
KtX7 workaround (q08–q10 and q13–q16) · 2 precomputed (q11 and q12)

How each layer is modeled

  • Semantic Rails: every question runs through the Semantic Rails runtime without helper views or hand-written SQL; q11 and q12 read the precomputed customer lifetime columns. Its q09 and q15 window runs from the session start up to, not including, 7 days later, where the rule counts orders after the session start, up to and including 7 days later; no order falls on either boundary.
  • MetricFlow: validity-windowed semantic models, conversion metrics, and metric filters; q10, q13, and q14 group their metric filters by surrogate entities defined with expr. Its conversion attribution is last-touch, and its 7-day window runs from the session's minute up to, not including, 7 days later. Neither difference shows on this data: no customer has two sessions, and no order falls on a window boundary.
  • Cube: declared joins carrying validity and event-window conditions, subquery dimensions, and multi-stage measures, and q05 as one multi-fact query. q11 counts a duplicate orders.order_count measure, because Cube 1.7.45's Tesseract planner renders invalid SQL when a query selects a measure and filters on a subquery dimension over that same measure.
  • Malloy: arbitrary-condition joins and query-derived sources joined back to orders.
  • Snowflake Semantic Views: q01–q07 through SEMANTIC_VIEW(...), and q08–q16 as SQL on the same tables; range joins haven't been modeled. Our q09 and q15 SQL counts orders from the session start up to, not including, 7 days later; its earlier dataset wasn't checked for orders on those boundaries.
  • KtX: q01–q07 through its Python semantic layer (ktx-sl), and q08–q16 through SQL-backed sources or query-level filters; the ktx-sl bundled in @kaelio/ktx 0.16.0 has equality-only joins and no query-derived sources. The SQL sources are standalone per-question fact tables rather than bridge sources joined to orders, a known gap in our KtX model. Their q09 and q15 window runs from the session start up to, not including, 7 days later; no order falls on either boundary.

Every question.

The rubric's label for each layer, and whether the normalized outputs of the layers checked on the current data match the answer key.

QuestionOutputsSemantic RailsMetricFlowCubeMalloySnowflake Semantic ViewsKtX
Orders By Month
q01 · shared
match native native native native native native
Revenue By Store By Month
q02 · shared
match native native native native native native
Item Revenue By Product Type By Month
q03 · shared
match native native native native native native
Average Order Value By Store
q04 · shared
match native native native native native native
Orders And Item Revenue In One Query
q05 · shared
match native native native native native native
New Customer Orders By Month
q06 · shared
match native native native native native native
Delivered Revenue By Month
q07 · shared
match native native native native native † native
Revenue By Historical Customer Segment
q08 · SR-targeted
match native native native native workaround workaround
Session To Order Conversion Within 7 Days
q09 · SR-targeted
match native native native native workaround workaround
Orders From Customers With 10 Plus Orders In Month
q10 · SR-targeted
match native native native native workaround workaround
Repeat Customer Orders By Store By Month
q11 · SR-targeted
match precomputed native native native precomputed precomputed
Orders By Month With Lifetime Spend Filter
q12 · SR-targeted
match precomputed native native native precomputed precomputed
Daily Orders From Customers With 10 Plus Orders In Month
q13 · SR-targeted
match native native native native workaround workaround
Revenue From Customers With 10 Plus Orders In Same Store Month
q14 · SR-targeted
match native native native native workaround workaround
Same Store Session To Order Conversion Within 7 Days
q15 · SR-targeted
match native native native native workaround workaround
Revenue By Historical Customer Segment As Of Delivered Time
q16 · SR-targeted
match native native native native workaround † workaround
Session To Order Conversion Within 14 Days
q17 · frozen model
match native requires_model_change requires_model_change native not_assessed requires_model_change
Same Store Session To Order Conversion Within 50 Minutes
q18 · frozen model
match native requires_model_change workaround native not_assessed requires_model_change
Trailing 3 Month Revenue By Month
q19 · frozen model
match requires_model_change requires_model_change workaround native not_assessed requires_model_change
Revenue And Prior Month Revenue By Month
q20 · frozen model
match requires_model_change requires_model_change workaround native not_assessed requires_model_change
Revenue And Large Order Revenue By Month
q21 · frozen model
match native requires_model_change workaround native not_assessed native
Average And Largest Item Revenue By Product Type By Month
q22 · frozen model
match native requires_model_change native native not_assessed native
Orders From Customers With 5 Plus Orders In Month
q23 · frozen model
match native native workaround native not_assessed requires_model_change
Orders By Month With Lifetime Spend 1000 Filter
q24 · frozen model
match native native native native not_assessed precomputed

The outputs column checks the 5 layers on the current data against the answer key; on a frozen-model question, the layers that answered it.
† The stale Snowflake Semantic Views capture differs from the answer key on q07 and q16.

native precomputed workaround unsupported requires model change not assessed

What this comparison doesn't measure.

  • Performance: latency, token use, and cost.
  • Compiler-surface controls. Other layers have real advantages here, for example MetricFlow's metric-time-only planning, distinct-values planning, and duplicate-alias rejection.
  • Warehouse coverage, maturity, BI integrations, and agent-workflow fit. The warehouse table below comes from each product's documentation, not from runs.
  • Whether a model is idiomatic. The rubric checks where an answer's logic lives, not whether an expert in that layer would model it the same way.
  • KtX's broader context product: this comparison runs its Python semantic layer (ktx-sl) only.

Warehouse coverage.

Your semantic layer needs to work with your data. This table comes from each product's documentation, not from runs, and wasn't re-checked for this revision. "dbt Semantic Layer Cloud" is dbt's hosted product; MetricFlow itself ran on DuckDB in the comparison above. The Semantic Rails engine is Apache-2.0 licensed, so adding a dialect or connector is a pull request.

Backend Semantic Rails OSS adapter dbt Semantic Layer Cloud Cube Malloy Snowflake Semantic Views KtX primary source / ktx-sl
Snowflake native connector native platform native connector native connector warehouse-native native connector
BigQuery native connector native platform native connector native connector no native connector
Databricks native connector native platform native connector native connector no dialect-only
Amazon Redshift no native platform native connector no no no
SQL Server no no native connector bridge no native connector
Microsoft Fabric no no native connector no no no
PostgreSQL native connector native platform native connector native connector no native connector
MySQL no no native connector native connector no native connector
ClickHouse native connector no native connector no no native connector
Amazon Athena native connector no native connector no no no
Trino / Presto no Trino only native connector native connector no no
DuckDB native connector no native connector native connector no dialect-only
MotherDuck native connector no native connector native connector no no
DuckLake native connector no no no no no
SQLite no no native connector no no native connector

Found a better model for a layer?

Every configuration, query, normalized output, answer-key query, and label's evidence is in the repo, with a runner for each layer except Snowflake Semantic Views, which needs a live account. If a layer can answer a question more idiomatically than we modeled it, open an issue or a pull request. We'll update the model, re-run every layer we can, and update this page.

Review the comparison pack on GitHub Methodology rules Question source (YAML) Labeling rubric Answer key Output check report