écociel · data modelling demo

The Kimball star schema, with a live database in your browser

A small retail data mart: one sales fact table and four dimensions with 47,290 sales lines from 2024–2025. The data is synthetic. You can query it below with SQL, running locally in your browser through DuckDB-WASM. No server, no tracking.

The structure

fact_sales transaction_id (degenerate)date_key → dim_dateproduct_key → dim_productcustomer_key → dim_customerstore_key → dim_storequantity, unit_price,discount_amount, net_amount,cost_amount dim_datedate_key (yyyymmdd)month, quarter, year, iso_weekweekday, is_holidayfiscal_year, fiscal_period dim_product · SCD2product_key (SK), sku (NK)category › subcategory, brandlist_price, unit_costeffective_from/to, is_current dim_customer · SCD2customer_key (SK), customer_idsegment, loyalty_tiercity, canton_state, countryeffective_from/to, is_current dim_storestore_key (SK), store_codestore_name, city, countryformat (incl. Online), regionopened_date

Grain: one row per product per sales transaction (one receipt line). Every dimension has an Unknown member with key -1. Anonymous walk-in sales point to it, so they never drop out of inner joins.

Concepts in 60 seconds

Fact vs dimension. Facts hold the numbers you measure: business events and their measures. Dimensions give the context you filter and group by: who, what, where, when. Facts are long and narrow; dimensions are short, wide and descriptive.
Grain first. Before choosing columns, state exactly what one fact row means. Every measure and foreign key must be true at that grain. Here: one receipt line.
Additive · semi · non-additive. quantity and net_amount can be summed across every dimension. A semi-additive measure, such as an inventory balance, can't be summed across time. Ratios and unit_price are non-additive: store the parts and divide after aggregating.
Surrogate vs natural key. sku and customer_id are source-system natural keys. The warehouse adds its own integer surrogate key, which keeps joins fast and stable, separates you from source changes and makes SCD2 possible.
Unknown members. Instead of NULL foreign keys, point to a -1 row labelled "Unknown". Totals stay correct, inner joins are safe, and data quality issues stay visible.
SCD Type 1 / 2. Type 1 overwrites the attribute, losing history (fine for typo fixes). Type 2 adds a new row with a new surrogate key and effective_from/to + is_current, so old facts keep the attributes that were valid when they happened.
Conformed dimensions. The same dim_date, dim_product and so on are shared by every fact table (sales, returns, inventory). That lets you compare different processes side by side. This shared set is Kimball's "bus matrix".
Degenerate dimension. transaction_id is a dimension key with no attributes, so it lives in the fact table. You still use it to group lines into baskets.
Role-playing dates. A single dim_date can play several roles, such as order date, ship date and delivery date, through separate foreign keys or views like dim_order_date.
Why a star? 3NF is great for transaction systems but needs many joins for analysis. Data Vault 2.0 (hubs, links, satellites) is excellent for auditable, source-agnostic integration, but nobody reports on it directly. The common pattern is a vault or staging layer underneath and star-schema information marts on top for BI.

Tables & columns

fact_sales 47,290 rows

transaction_idBIGINTDD
date_keyBIGINTFK
product_keyBIGINTFK
customer_keyBIGINTFK
store_keyBIGINTFK
quantityBIGINTmeasure · additive
unit_priceDOUBLEmeasure · non-additive
discount_amountDOUBLEmeasure · additive
net_amountDOUBLEmeasure · additive
cost_amountDOUBLEmeasure · additive
csv · parquet

dim_date 732 rows

date_keyBIGINTPK · SK
full_dateDATENK
day_of_monthBIGINT
iso_weekBIGINT
monthBIGINT
quarterBIGINT
yearBIGINT
weekday_nameVARCHAR
weekday_numBIGINT
is_holidayBOOLEAN
fiscal_yearBIGINT
fiscal_periodBIGINT
year_monthVARCHAR
csv · parquet

dim_product 62 rows

product_keyBIGINTPK · SK
skuVARCHARNK
product_nameVARCHAR
categoryVARCHAR
subcategoryVARCHAR
brandVARCHAR
list_priceDOUBLE
unit_costDOUBLE
effective_fromDATESCD2
effective_toDATESCD2
is_currentBOOLEANSCD2
csv · parquet

dim_customer 306 rows

customer_keyBIGINTPK · SK
customer_idVARCHARNK
customer_nameVARCHAR
segmentVARCHAR
cityVARCHAR
canton_stateVARCHAR
countryVARCHAR
loyalty_tierVARCHAR
effective_fromDATESCD2
effective_toDATESCD2
is_currentBOOLEANSCD2
csv · parquet

dim_store 11 rows

store_keyBIGINTPK · SK
store_codeVARCHARNK
store_nameVARCHAR
cityVARCHAR
countryVARCHAR
formatVARCHAR
regionVARCHAR
opened_dateDATE
csv · parquet

Try it: SQL console

DuckDB runs inside this page and reads the Parquet files above. Pick an example or write your own query. Tables: fact_sales, dim_date, dim_product, dim_customer, dim_store.

loading DuckDB…

In a BI tool or Grafana

BI tools like Power BI, Tableau, Looker or Superset are built for stars. You import the fact and dimension tables, define relationships on the surrogate keys and write measures such as SUM(net_amount) once. Users then drag attributes from the dimensions.

Grafana has no data source that understands star schemas. You point a SQL data source (PostgreSQL, ClickHouse, Snowflake, BigQuery, DuckDB/MotherDuck…) at the mart and write the join yourself. For time series, select d.full_date AS time, a metric and an optional series column, and use $__timeFilter(d.full_date) and template variables, such as WHERE p.category IN ($category), fed from SELECT DISTINCT category FROM dim_product. A tidy pattern is to create a flat view joining the fact to its dimensions, then build panels on that view. For reusable, governed metrics, put a semantic layer like Cube or dbt Semantic Layer between Grafana and the star.