Skip to content

Instantly share code, notes, and snippets.

@camsaul
Created May 14, 2026 19:31
Show Gist options
  • Select an option

  • Save camsaul/cb22c306332a8e81e342efce353da026 to your computer and use it in GitHub Desktop.

Select an option

Save camsaul/cb22c306332a8e81e342efce353da026 to your computer and use it in GitHub Desktop.
Models in Metabase
How Models Work in Metabase: QP & Lib
What is a Model?
A Model is a Card with :type :model (legacy: dataset = true), stored in the report_card table. The key distinguishing fields beyond :dataset-query are:
- :result_metadata — an array of column metadata maps (user-editable: display names, semantic types, FK targets, visibility, etc.)
- :type :model — distinguishes from :question and :metric
---
Result Metadata: Storage and Merging
result_metadata is the core mechanism that makes Models distinct from plain saved questions. It stores per-column annotations that the user can edit in the Model editor.
When a Model's query runs, results_metadata middleware (src/metabase/query_processor/middleware/results_metadata.clj) records fresh metadata via record-metadata!. For Models specifically, this is merged with existing user edits rather than overwriting them —
implemented in infer-metadata-with-model-overrides in the card metadata namespace.
Lib-side, lib.card/merge-model-metadata (src/metabase/lib/card.cljc:224) controls which Model-authored keys survive a re-inference:
;; Keys preserved from the Model's result_metadata when merging
(def model-preserved-keys
[:description :display-name :semantic-type :fk-target-field-id :settings :visibility-type])
The :native-model? flag also preserves :id for native SQL models, enabling FK remapping even on native queries.
---
Using a Model as a Source: Source Card Resolution
When a query stage has :source-card <id>, the fetch-source-query middleware (src/metabase/query_processor/middleware/fetch_source_query.clj) resolves it:
1. Fetches the Card — retrieves it from the metadata provider, validates it's the same database
2. Splices the Card's query stages into the parent query (resolve-source-cards-in-stage)
3. Attaches :lib/stage-metadata — the Model's result_metadata becomes the column metadata for the spliced-in stage, so downstream stages see the Model's curated column types/names rather than raw DB types
4. Sets flags on the stage:
- :source-query/model? true
- :source-query/native-model? true (if the Model's underlying query is native SQL)
5. Recursively resolves nested Models (Models using other Models as sources), with circular dependency detection via weavejester.dependency and a max depth of 50
The net effect: a query using a Model as its source treats the Model's result_metadata as the schema, not the underlying table schema.
---
Lib (MLv2) Handling
Lib handles Models in a few places:
lib.card/card-returned-columns is the main entry point for getting what columns a Card exposes. For Models, it calls merge-model-metadata to layer the user-curated metadata on top of the query-computed columns.
lib.metadata.result_metadata/merge-model-metadata (src/metabase/lib/metadata/result_metadata.cljc:367) is the QP-side counterpart, used during result annotation. When :metadata/model-metadata is present in the query info, it merges the Model's column metadata into the
returned columns.
Metadata providers (src/metabase/lib/metadata.cljc) cache Card metadata and expose it to Lib query building. Models are retrieved the same way as Questions here — the :type :model distinction matters more at the annotation and source-card-resolution layers.
---
Running a Model's Own Query
When executing a Model directly (not as a source for another query), query-for-card (src/metabase/query_processor/card.clj:90) does Model-specific work:
- Parameter insertion: Parameters added by the frontend are pointed to the last stage of the Model's query (via point-parameters-to-last-stage). This prevents parameters from leaking into intermediate stages of a multi-stage Model query. Questions and Metrics pass
parameters through transparently.
- :metadata/model-metadata attachment: process-query-for-card attaches the Card's result_metadata to the query's :info map, along with :metadata/own-model-query? true — this flag tells the annotate middleware that it is processing the Model's own output, so
user-edited column names/types should be preserved.
---
Model Persistence (Materialized Views)
Models can optionally be materialized into physical tables. The persisted_cache utility (src/metabase/query_processor/util/persisted_cache.clj) checks whether a persisted version is usable:
- persisted-info.active == true and state == "persisted"
- The stored query hash matches the current query (no changes since last materialization)
- Metadata definition matches (no user edits to result_metadata since last run)
If valid, fetch-source-query inserts :persisted-info/native into the stage metadata, and the downstream persistence middleware substitutes the Model's query with a SELECT * FROM <persisted_table> native query. This is transparent to the rest of the pipeline.
---
QP Middleware Order (Relevant Pieces)
fetch-source-query ; resolves :source-card → splices stages + attaches Model metadata
└─ persistence ; substitutes persisted table if valid
parameters ; inserts parameters (Model-aware: targets last stage)
annotate ; expected-cols → merges :metadata/model-metadata
results-metadata ; records metadata back; blends with user edits for Models
---
Key Files
┌──────────────────────────────┬────────────────────────────────────────────────────────────────┐
│ Purpose │ File │
├──────────────────────────────┼────────────────────────────────────────────────────────────────┤
│ Source card resolution │ src/metabase/query_processor/middleware/fetch_source_query.clj │
├──────────────────────────────┼────────────────────────────────────────────────────────────────┤
│ Model metadata merging (Lib) │ src/metabase/lib/card.cljc │
├──────────────────────────────┼────────────────────────────────────────────────────────────────┤
│ Result metadata recording │ src/metabase/query_processor/middleware/results_metadata.clj │
├──────────────────────────────┼────────────────────────────────────────────────────────────────┤
│ Card QP entry point │ src/metabase/query_processor/card.clj │
├──────────────────────────────┼────────────────────────────────────────────────────────────────┤
│ Result annotation │ src/metabase/query_processor/middleware/annotate.clj │
├──────────────────────────────┼────────────────────────────────────────────────────────────────┤
│ Persistence check │ src/metabase/query_processor/util/persisted_cache.clj │
├──────────────────────────────┼────────────────────────────────────────────────────────────────┤
│ QP-side metadata merging │ src/metabase/lib/metadata/result_metadata.cljc │
└──────────────────────────────┴────────────────────────────────────────────────────────────────┘
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment