Star schema example (retail sales)
A star schema is the classic design for analytics: one fact table of measurements in the middle, surrounded by dimension tables that describe them. This example is a retail sales star in the Kimball style, the kind of model that sits in a Snowflake, Databricks or BigQuery warehouse behind BI dashboards.
Design notes
- Declare the grain first.
fact_saleshas one row per order line. Every measure (quantity, sales, cost, margin) is true at that grain and adds up across any dimension. - Surrogate keys everywhere. Dimensions have integer
*_keycolumns independent of source system IDs, which keep their natural keys (sku,customer_id) as ordinary attributes. - The date dimension is a table, not a function.
dim_datehas a row per day with calendar and fiscal attributes, keyedYYYYMMDD. - Customer history is kept (SCD type 2). When a customer's segment or city changes, a new row with new
valid_from/valid_todates is added; facts point at the version current at the time of sale. - No empty foreign keys. Sales without a promotion point at a "No promotion" row (key 0), so joins never drop rows.
- The order number is a degenerate dimension: kept on the fact for drill-through, with no table of its own.
- Dimensions are denormalized: the product carries its category and subcategory directly, so reports need one join per dimension.
To extend it: add fact_inventory_snapshot (a periodic snapshot fact), fact_returns, or a dim_employee for the cashier. Model the normalized source system first; see Conceptual, logical and physical data models.
Diagram
Retail Sales Star Schema: 6 entities, 0 reference lists and 5 relationships. Each box is an entity with its attributes (PK marks the primary key); solid lines are relationships in crow's foot notation, dashed lines point to reference lists.
Open this model in EntityModel.com to explore it, change it and export it. Nothing is uploaded: the model travels in the link. Or download model.json.
Entities
| Entity | Type | Description | Primary Key | Display Attribute | Attributes |
|---|---|---|---|---|---|
| Customer | Entity | Customers as a type 2 slowly changing dimension: a change to segment or city adds a new row, so history reports use the values at the time of sale. | customer_key | customer_name | 9 |
| Date | Entity | One row per calendar day, with every calendar and fiscal attribute reports filter on. | date_key | full_date | 10 |
| Product | Entity | Products, flattened with their category hierarchy so reports need no joins beyond the star. | product_key | product_name | 9 |
| Promotion | Entity | Promotions applied to a sale, with a "No promotion" row (key 0) so the fact never has empty keys. | promotion_key | promotion_name | 6 |
| Sales (fact) | Entity | One row per order line: the grain of the star. Measures are additive across every dimension. | sales_key | order_number | 14 |
| Store | Entity | Stores and the online channel, with their geography. | store_key | store_name | 8 |
Attributes
Customer
Customers as a type 2 slowly changing dimension: a change to segment or city adds a new row, so history reports use the values at the time of sale.
| Attribute | Data Type | Flags | Reference | Example | Description |
|---|---|---|---|---|---|
| customer_key | INT | PK, Required | 77310 | Surrogate key of this version. | |
| customer_id | STRING | Required | CUS-104233 | Natural key; the same across versions. | |
| customer_name | STRING | Label, Required, PII | Ana Silva | ||
| segment | STRING | Required | Loyalty Gold | ||
| city | STRING | Portland | |||
| country | STRING | United States | |||
| valid_from | DATE | Required | 2026-02-01 | ||
| valid_to | DATE | Required | 9999-12-31 | 9999-12-31 for the current version. | |
| is_current | BOOLEAN | Required | true |
Date
One row per calendar day, with every calendar and fiscal attribute reports filter on.
| Attribute | Data Type | Flags | Reference | Example | Description |
|---|---|---|---|---|---|
| date_key | INT | PK, Required | 20261103 | YYYYMMDD. | |
| full_date | DATE | Label, Required | 2026-11-03 | ||
| day_of_week | STRING | Required | Tuesday | ||
| month_name | STRING | Required | November | ||
| calendar_month | INT | Required | 11 | ||
| calendar_quarter | INT | Required | 4 | ||
| calendar_year | INT | Required | 2026 | ||
| fiscal_period | STRING | Required | FY27-P04 | ||
| is_weekend | BOOLEAN | Required | false | ||
| is_holiday | BOOLEAN | Required | false |
Product
Products, flattened with their category hierarchy so reports need no joins beyond the star.
| Attribute | Data Type | Flags | Reference | Example | Description |
|---|---|---|---|---|---|
| product_key | INT | PK, Required | 1201 | Surrogate key. | |
| sku | STRING | Required | TEE-ORG-M-BLU | Natural key from the source system. | |
| product_name | STRING | Label, Required | Organic Tee | ||
| brand | STRING | Northwind | |||
| category | STRING | Required | Apparel | ||
| subcategory | STRING | Required | T-shirts | ||
| size | STRING | M | |||
| color | STRING | Blue | |||
| list_price | DECIMAL(12,2) | 24.99 |
Promotion
Promotions applied to a sale, with a "No promotion" row (key 0) so the fact never has empty keys.
| Attribute | Data Type | Flags | Reference | Example | Description |
|---|---|---|---|---|---|
| promotion_key | INT | PK, Required | 12 | ||
| promotion_code | STRING | Required | FALL25 | ||
| promotion_name | STRING | Label, Required | Fall 25% off | ||
| discount_type | STRING | Required | Percent | Percent, amount or none. | |
| start_date | DATE | 2026-10-01 | |||
| end_date | DATE | 2026-10-31 |
Sales (fact)
One row per order line: the grain of the star. Measures are additive across every dimension.
| Attribute | Data Type | Flags | Reference | Example | Description |
|---|---|---|---|---|---|
| sales_key | BIGINT | PK, Required | 900000123 | Surrogate key of the fact row. | |
| date_key | INT | Required | 20261103 | Order date, as YYYYMMDD. | |
| product_key | INT | Required | 1201 | ||
| store_key | INT | Required | 45 | ||
| customer_key | INT | Required | 77310 | The customer version current at the time of sale. | |
| promotion_key | INT | Required | 0 | 0 = no promotion. | |
| order_number | STRING | Label, Required | SO-2026-000123 | Degenerate dimension: the source order, with no table of its own. | |
| line_number | INT | Required | 1 | ||
| quantity | INT | Required | 2 | ||
| unit_price | DECIMAL(12,2) | Required | 24.99 | ||
| discount_amount | DECIMAL(12,2) | Required | 0.00 | ||
| sales_amount | DECIMAL(12,2) | Required | 49.98 | quantity × unit_price − discount. | |
| cost_amount | DECIMAL(12,2) | Required | 21.40 | ||
| margin_amount | DECIMAL(12,2) | Required | 28.58 | sales_amount − cost_amount. |
Store
Stores and the online channel, with their geography.
| Attribute | Data Type | Flags | Reference | Example | Description |
|---|---|---|---|---|---|
| store_key | INT | PK, Required | 45 | ||
| store_code | STRING | Required | PDX-03 | ||
| store_name | STRING | Label, Required | Portland Pearl | ||
| channel | STRING | Required | Store | Store or online. | |
| city | STRING | Portland | |||
| region | STRING | Required | West | ||
| country | STRING | Required | United States | ||
| opened_on | DATE | 2019-05-01 |
Relationships
| Relationship | Source | Target | Cardinality | Forward Label | Reverse Label | Key | Description |
|---|---|---|---|---|---|---|---|
| sold_on | Sales (fact) | Date | N:1 | Sold On | Day Of | fact_sales.date_key | |
| sale_of | Sales (fact) | Product | N:1 | Of Product | Sold In | fact_sales.product_key | |
| sold_at | Sales (fact) | Store | N:1 | Sold At | Sells | fact_sales.store_key | |
| sold_to | Sales (fact) | Customer | N:1 | Sold To | Bought | fact_sales.customer_key | |
| under_promotion | Sales (fact) | Promotion | N:1 | Under | Applied To | fact_sales.promotion_key |
SQL DDL (PostgreSQL)
Generated from the model. In the app, Export → Code also writes MySQL, Snowflake and Databricks DDL, DBML, dbt schema.yml and data contracts.
-- Retail Sales Star Schema — data model
-- PostgreSQL DDL, generated 2026-11-03
-- Made with EntityModel.com
CREATE TABLE fact_sales (
sales_key BIGINT NOT NULL,
date_key INTEGER NOT NULL,
product_key INTEGER NOT NULL,
store_key INTEGER NOT NULL,
customer_key INTEGER NOT NULL,
promotion_key INTEGER NOT NULL,
order_number TEXT NOT NULL,
line_number INTEGER NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(12,2) NOT NULL,
discount_amount NUMERIC(12,2) NOT NULL,
sales_amount NUMERIC(12,2) NOT NULL,
cost_amount NUMERIC(12,2) NOT NULL,
margin_amount NUMERIC(12,2) NOT NULL,
PRIMARY KEY (sales_key)
);
COMMENT ON TABLE fact_sales IS 'One row per order line: the grain of the star. Measures are additive across every dimension.';
COMMENT ON COLUMN fact_sales.sales_key IS 'Surrogate key of the fact row.';
COMMENT ON COLUMN fact_sales.date_key IS 'Order date, as YYYYMMDD.';
COMMENT ON COLUMN fact_sales.customer_key IS 'The customer version current at the time of sale.';
COMMENT ON COLUMN fact_sales.promotion_key IS '0 = no promotion.';
COMMENT ON COLUMN fact_sales.order_number IS 'Degenerate dimension: the source order, with no table of its own.';
COMMENT ON COLUMN fact_sales.sales_amount IS 'quantity × unit_price − discount.';
COMMENT ON COLUMN fact_sales.margin_amount IS 'sales_amount − cost_amount.';
CREATE TABLE dim_date (
date_key INTEGER NOT NULL,
full_date DATE NOT NULL,
day_of_week TEXT NOT NULL,
month_name TEXT NOT NULL,
calendar_month INTEGER NOT NULL,
calendar_quarter INTEGER NOT NULL,
calendar_year INTEGER NOT NULL,
fiscal_period TEXT NOT NULL,
is_weekend BOOLEAN NOT NULL,
is_holiday BOOLEAN NOT NULL,
PRIMARY KEY (date_key)
);
COMMENT ON TABLE dim_date IS 'One row per calendar day, with every calendar and fiscal attribute reports filter on.';
COMMENT ON COLUMN dim_date.date_key IS 'YYYYMMDD.';
CREATE TABLE dim_product (
product_key INTEGER NOT NULL,
sku TEXT NOT NULL,
product_name TEXT NOT NULL,
brand TEXT,
category TEXT NOT NULL,
subcategory TEXT NOT NULL,
size TEXT,
color TEXT,
list_price NUMERIC(12,2),
PRIMARY KEY (product_key)
);
COMMENT ON TABLE dim_product IS 'Products, flattened with their category hierarchy so reports need no joins beyond the star.';
COMMENT ON COLUMN dim_product.product_key IS 'Surrogate key.';
COMMENT ON COLUMN dim_product.sku IS 'Natural key from the source system.';
CREATE TABLE dim_store (
store_key INTEGER NOT NULL,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
channel TEXT NOT NULL,
city TEXT,
region TEXT NOT NULL,
country TEXT NOT NULL,
opened_on DATE,
PRIMARY KEY (store_key)
);
COMMENT ON TABLE dim_store IS 'Stores and the online channel, with their geography.';
COMMENT ON COLUMN dim_store.channel IS 'Store or online.';
CREATE TABLE dim_customer (
customer_key INTEGER NOT NULL,
customer_id TEXT NOT NULL,
customer_name TEXT NOT NULL,
segment TEXT NOT NULL,
city TEXT,
country TEXT,
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
is_current BOOLEAN NOT NULL,
PRIMARY KEY (customer_key)
);
COMMENT ON TABLE dim_customer IS 'Customers as a type 2 slowly changing dimension: a change to segment or city adds a new row, so history reports use the values at the time of sale.';
COMMENT ON COLUMN dim_customer.customer_key IS 'Surrogate key of this version.';
COMMENT ON COLUMN dim_customer.customer_id IS 'Natural key; the same across versions.';
COMMENT ON COLUMN dim_customer.valid_to IS '9999-12-31 for the current version.';
CREATE TABLE dim_promotion (
promotion_key INTEGER NOT NULL,
promotion_code TEXT NOT NULL,
promotion_name TEXT NOT NULL,
discount_type TEXT NOT NULL,
start_date DATE,
end_date DATE,
PRIMARY KEY (promotion_key)
);
COMMENT ON TABLE dim_promotion IS 'Promotions applied to a sale, with a "No promotion" row (key 0) so the fact never has empty keys.';
COMMENT ON COLUMN dim_promotion.discount_type IS 'Percent, amount or none.';
-- Keys between tables
ALTER TABLE dim_date ADD CONSTRAINT uq_dim_date_full_date UNIQUE (full_date);
ALTER TABLE fact_sales ADD CONSTRAINT fk_sold_on FOREIGN KEY (date_key) REFERENCES dim_date (date_key);
ALTER TABLE fact_sales ADD CONSTRAINT fk_sale_of FOREIGN KEY (product_key) REFERENCES dim_product (product_key);
ALTER TABLE fact_sales ADD CONSTRAINT fk_sold_at FOREIGN KEY (store_key) REFERENCES dim_store (store_key);
ALTER TABLE fact_sales ADD CONSTRAINT fk_sold_to FOREIGN KEY (customer_key) REFERENCES dim_customer (customer_key);
ALTER TABLE fact_sales ADD CONSTRAINT fk_under_promotion FOREIGN KEY (promotion_key) REFERENCES dim_promotion (promotion_key);
Mermaid
Paste into a mermaid code block on GitHub, GitLab or Notion to show the diagram there.
---
title: Retail Sales Star Schema
---
%% Made with EntityModel.com
erDiagram
fact_sales {
bigint sales_key PK "Surrogate key of the fact row."
int date_key FK "Order date, as YYYYMMDD."
int product_key FK "Product Key"
int store_key FK "Store Key"
int customer_key FK "The customer version current at the time of sale."
int promotion_key FK "0 = no promotion."
string order_number "Degenerate dimension: the source order, with no table of its own."
int line_number "Line Number"
int quantity "Quantity"
decimal unit_price "Unit Price"
decimal discount_amount "Discount Amount"
decimal sales_amount "quantity × unit_price − discount."
decimal cost_amount "Cost Amount"
decimal margin_amount "sales_amount − cost_amount."
}
dim_date {
int date_key PK "YYYYMMDD."
date full_date "Full Date"
string day_of_week "Day Of Week"
string month_name "Month Name"
int calendar_month "Calendar Month"
int calendar_quarter "Calendar Quarter"
int calendar_year "Calendar Year"
string fiscal_period "Fiscal Period"
boolean is_weekend "Is Weekend"
boolean is_holiday "Is Holiday"
}
dim_product {
int product_key PK "Surrogate key."
string sku "Natural key from the source system."
string product_name "Product Name"
string brand "Brand"
string category "Category"
string subcategory "Subcategory"
string size "Size"
string color "Color"
decimal list_price "List Price"
}
dim_store {
int store_key PK "Store Key"
string store_code "Store Code"
string store_name "Store Name"
string channel "Store or online."
string city "City"
string region "Region"
string country "Country"
date opened_on "Opened On"
}
dim_customer {
int customer_key PK "Surrogate key of this version."
string customer_id "Natural key; the same across versions."
string customer_name "Customer Name"
string segment "Segment"
string city "City"
string country "Country"
date valid_from "Valid From"
date valid_to "9999-12-31 for the current version."
boolean is_current "Is Current"
}
dim_promotion {
int promotion_key PK "Promotion Key"
string promotion_code "Promotion Code"
string promotion_name "Promotion Name"
string discount_type "Percent, amount or none."
date start_date "Start Date"
date end_date "End Date"
}
fact_sales }o--|| dim_date : "Sold On"
fact_sales }o--|| dim_product : "Of Product"
fact_sales }o--|| dim_store : "Sold At"
fact_sales }o--|| dim_customer : "Sold To"
fact_sales }o--|| dim_promotion : "Under"
Frequently asked questions
What is a star schema?
A star schema is a dimensional data model with a central fact table holding numeric measurements at a declared grain, joined by foreign keys to denormalized dimension tables (date, product, customer, store) that describe the context. Drawn as a diagram, the dimensions surround the fact like the points of a star.
What is the difference between a star schema and a snowflake schema?
In a star schema each dimension is a single denormalized table. In a snowflake schema dimensions are normalized into several tables (product, then subcategory, then category). Stars are simpler and faster to query; snowflakes save some storage and enforce hierarchies.
What is a slowly changing dimension (SCD) type 2?
A way to keep history in a dimension: instead of overwriting a changed attribute, add a new row with its own surrogate key and validity dates, and mark it current. Facts keep pointing at the version that was true when they happened.