How to define models

The Sales class in Your first query is about the smallest model that works. A SemanticView subclass can declare rather more: three kinds of field, a type subscript your IDE reads, and the descriptors the query builder sees.

Create a model

Subclass SemanticView and pass the warehouse view name via view=:

from semolina import SemanticView, Metric, Dimension, Fact


class Sales(SemanticView, view="sales"):
    revenue = Metric[float]()
    cost = Metric[float]()
    country = Dimension[str]()
    region = Dimension[str]()
    unit_price = Fact[float]()

The type parameter ([float], [str], etc.) is covered under Type the subscript.

Note

float is used for brevity here; codegen writes Decimal | None

The examples on this page annotate money metrics Metric[float]() to keep the focus on model structure. A real DECIMAL column arrives as a decimal.Decimal, and semolina codegen writes Metric[decimal.Decimal | None]() for it: Decimal because that is the value you get, and | None because a metric over an empty group is null.

This matters if you run semolina codegen --check. That command compares the type your committed annotation names with the one the result schema implies, so a hand-written Metric[float]() on a decimal column is reported as drift. Prefer the annotation codegen would write for models you intend to check.

.into(DTO) is a separate question and behaves differently: a hand-written float there is honoured as a deliberate narrowing under validate=True. See How to map results into Pydantic models with .into().

The view= parameter is required – it identifies the semantic view in your warehouse. Omitting it raises a TypeError at class creation time. The one exception is a base class that only shares fields; see Share fields between models.

Name a view in another schema

If your view lives in a specific schema, pass the schema-qualified name:

class Sales(
    SemanticView,
    view="analytics.sales",
):
    revenue = Metric[float]()

For Databricks Unity Catalog, use three-part names:

class Sales(
    SemanticView,
    view="catalog.schema.sales",
):
    revenue = Metric[float]()

Note

How the view name is quoted

Semolina folds the view name to your warehouse’s default identifier case – the same folding it applies to field names: uppercase on Snowflake, lowercase on Databricks and DuckDB. So view="sales" resolves against a view created the ordinary (unquoted) way, whatever case you write here. Schema-qualified names are split on . and quoted per part, so on Snowflake view="analytics.sales" becomes "ANALYTICS"."SALES".

If a view was created with a quoted, case-sensitive name, quote that segment yourself to keep it verbatim:

class Sales(
    SemanticView,
    view='analytics."Sales"',  # -> "ANALYTICS"."Sales"
):
    revenue = Metric[float]()

Semolina escapes the name between the quotes for you, so a quote inside it can be written once, view='analytics."My"View"', or doubled the standard SQL way, view='analytics."My""View"'. Both name the view My"View on Snowflake.

On Databricks, quote the segment with backticks instead: view="analytics.`My View`". A backtick inside it is handled the same way, written once or doubled. On DuckDB, Semolina passes the view name to semantic_view() exactly as you write it, and DuckDB matches names case-insensitively anyway, so none of this quoting applies there.

Choose field types

Each field type corresponds to a role in your semantic view query:

Field

Use for

Accepted by

Metric

Aggregated measures: revenue totals, counts, averages

.metrics()

Dimension

Categorical grouping: country, product category, date

.dimensions()

Fact

Raw event-level numeric columns (unit_price, quantity). Signals semantic intent, as against a categorical Dimension.

.dimensions()

Metric fields

A Metric represents an aggregatable measure. In generated SQL, metrics are wrapped in the backend’s aggregation function:

class Orders(SemanticView, view="orders"):
    total_revenue = Metric[float]()
    order_count = Metric[int]()
SELECT AGG("TOTAL_REVENUE") AS "total_revenue", AGG("ORDER_COUNT") AS "order_count"
FROM "ORDERS"
SELECT MEASURE(`total_revenue`) AS `total_revenue`, MEASURE(`order_count`) AS `order_count`
FROM `orders`
SELECT "total_revenue" AS "total_revenue", "order_count" AS "order_count"
FROM semantic_view('orders', metrics := ['total_revenue', 'order_count'])

Passing a Metric to .dimensions() raises a TypeError. Pass it to .metrics() instead.

Dimension fields

A Dimension represents a categorical attribute used for grouping:

class Orders(SemanticView, view="orders"):
    country = Dimension[str]()
    product_category = Dimension[str]()
SELECT "COUNTRY" AS "country", "PRODUCT_CATEGORY" AS "product_category"
FROM "ORDERS"
GROUP BY ALL
SELECT `country` AS `country`, `product_category` AS `product_category`
FROM `orders`
GROUP BY ALL
SELECT "country" AS "country", "product_category" AS "product_category"
FROM semantic_view('orders', dimensions := ['country', 'product_category'])

Fact fields

A Fact represents a raw numeric value that has not been pre-aggregated.

Snowflake users: a column declared in the FACTS clause of CREATE SEMANTIC VIEW reports kind=FACT when you introspect with SHOW COLUMNS IN VIEW, and semolina codegen emits Fact() for it automatically. For hand-written models, use Fact for raw numeric columns you want to distinguish semantically from categorical dimensions.

Databricks users: Databricks metric views have no native fact concept. Every non-aggregate field is a dimension: in the metric view YAML. Use Fact in your Semolina model for raw numeric columns you want to distinguish semantically from categorical grouping attributes – the warehouse does not enforce the distinction, but your teammates and future readers will see the intent.

On Snowflake and Databricks, Fact and Dimension produce identical SQL (SELECT "COL" AS "col" FROM ... GROUP BY ALL), so the distinction is semantic rather than a difference in execution. DuckDB’s semantic_view() takes facts in their own facts := argument and returns them row by row, so Semolina adds SELECT DISTINCT to a fact query there. You get the same rows on all three: one per distinct combination of values. DuckDB can’t select facts and metrics in the same query, so Semolina raises ValueError if you pass a Fact to .dimensions() alongside .metrics() on a DuckDB engine.

Default to Dimension. Use Fact as an intentional opt-in for two situations:

  1. Semantic precision – the column is a raw event-level numeric (unit_price, quantity, line_amount) you want to distinguish from categorical attributes like country or product_category.

  2. Snowflake alignment – semolina codegen introspected the column as kind=FACT from your warehouse. Match that designation in Semolina.

class Orders(SemanticView, view="orders"):
    # raw price column, not aggregated
    unit_price = Fact[float]()
    quantity = Fact[int]()
    # categorical grouping attribute
    country = Dimension[str]()
    product_category = Dimension[str]()
SELECT "UNIT_PRICE" AS "unit_price", "QUANTITY" AS "quantity"
FROM "ORDERS"
GROUP BY ALL
SELECT `unit_price` AS `unit_price`, `quantity` AS `quantity`
FROM `orders`
GROUP BY ALL
SELECT DISTINCT "unit_price" AS "unit_price", "quantity" AS "quantity"
FROM semantic_view('orders', facts := ['unit_price', 'quantity'])

Type the subscript

Annotate each field with the Python type of the underlying warehouse column. The subscript is optional but recommended – it lets your IDE infer the type when you access query results:

revenue = Metric[float]()
order_id = Metric[int]()
country = Dimension[str]()
# date column; requires: import datetime
created_at = Dimension[datetime.date]()

When the column type has no clean Python equivalent (GEOGRAPHY, ARRAY, STRUCT), use Metric[Any]() and import Any from typing. The semolina codegen command emits a # TODO: comment for these cases. A VARIANT column takes JsonValue; see How to generate Semolina model classes from warehouse views.

You can also omit the subscript entirely. Metric() is shorthand for Metric[Any]() – valid Python, just without type narrowing.

Put it together

A complete model with all three field types:

from semolina import SemanticView, Metric, Dimension, Fact


class Orders(SemanticView, view="orders"):
    """Order-level metrics and dimensions."""

    total_revenue = Metric[float]()
    order_count = Metric[int]()
    country = Dimension[str]()
    product_category = Dimension[str]()
    unit_price = Fact[float]()  # raw price, not aggregated

Access field descriptors

Fields use Python’s descriptor protocol. Accessing Orders.total_revenue at the class level returns the Metric descriptor itself – the same object you pass into query methods:

# Class-level access: returns the descriptor
field = Orders.total_revenue
print(type(field))
# <class 'semolina.fields.Metric'>

# Pass directly into query methods
query = Orders.query().metrics(
    Orders.total_revenue,
    Orders.order_count,
)

Accessing a field on an instance raises AttributeError. Semolina models are never instantiated – the class is the query target.

Model immutability

Models are frozen immediately after the class body executes. Attempting to modify a model attribute after class creation raises AttributeError:

class Sales(SemanticView, view="sales"):
    revenue = Metric[float]()


# AttributeError: Cannot modify after creation
Sales.revenue = Metric[float]()

A model therefore cannot change shape while a query built from it is in flight.

Share fields between models

When several views have the same shape, declare the shared fields once on an abstract=True base and subclass it for each view:

class Commerce(SemanticView, abstract=True):
    revenue = Metric[decimal.Decimal | None]()
    country = Dimension[str]()


class Sales(Commerce, view="sales"):
    pass


class Returns(Commerce, view="returns"):
    refunds = Metric[decimal.Decimal | None]()

Sales and Returns each have revenue and country, and Returns adds refunds. Each subclass gets its own copy of every inherited field, so Sales.query().metrics(Sales.revenue) reads from sales and never from another view. Using Sales.revenue in a Returns query raises TypeError, as mixing fields from two unrelated models does.

An abstract model names no view, so it cannot be queried: Commerce.query() raises TypeError. You can still list its fields with Commerce.metrics() and Commerce.dimensions(). Passing view= together with abstract=True is refused, and abstractness is not inherited, so each subclass needs its own view=. Several abstract bases combine the way mixins do, in method resolution order, and a plain class that is not a model can contribute fields the same way.

You can also extend a concrete model. The subclass names its own view and inherits the parent’s fields; the parent is unchanged:

class SalesV2(Sales, view="sales_v2"):
    revenue = Metric[decimal.Decimal | None](
        source="net_revenue"
    )
    discount = Metric[decimal.Decimal | None]()

Redeclaring revenue replaces the inherited field. It keeps its place in SalesV2.metrics(), ahead of discount. To drop an inherited field, assign the name something that is not a field, such as country = None.

Note

codegen --check follows base classes

semolina codegen --check counts an inherited field as the subclass’s own, so SalesV2 above is checked with country from Sales and its own revenue in place of the inherited one. The base can sit in the same file or in another module of your project; --check parses that module without importing it. A base it can’t find, such as one from an installed package, is named in a warning, and the fields it would have supplied report as absent. See Check a committed model for drift.

Carry a field description from your warehouse

Where your warehouse records a comment on a column, semolina codegen writes it as a docstring under the generated field:

class Orders(SemanticView, view="orders"):
    total_revenue = Metric[decimal.Decimal | None]()
    """Sum of revenue, tax excluded."""

The description travels one way. All three backends read the column comment out of the catalogue during introspection, and codegen renders it into the model it writes. Nothing reads a docstring you attach to a field yourself, so a description you want in a regenerated model belongs in the warehouse rather than in your Python.

See How to generate Semolina model classes from warehouse views for the rest of the generated output.

See also