A star schema is a dimensional model for analytics: one central fact table holds numeric measurements at a declared grain, such as one row per sale line, and denormalized dimension tables around it describe who, what, where and when. Fact rows reach each dimension through a surrogate key, so a query joins one table per angle it needs.
This guide takes Ralph Kimball's design steps from a first sketch to SQL tested on PostgreSQL 18, including a Type 2 slowly changing dimension, then compares star, snowflake and one big table, and Kimball, Inmon and Data Vault. A star schema is one way to model the gold layer of a medallion architecture, and a semantic layer defines shared metrics on top of it.
What is in a star schema?
Kimball Group describes star schemas as dimensional structures in a relational database, with fact tables linked to dimension tables by primary and foreign keys. Microsoft's Power BI guidance explains why BI tools favour the shape. Each report visual sends a query that filters, groups and summarizes, so a model needs tables for filtering and grouping (dimensions) and tables for summarizing (facts). In a one-to-many relationship the "one" side is always a dimension and the "many" side a fact. Dimension tables have relatively few rows, while fact tables can be large and keep growing.
| Fact table | Dimension table | |
|---|---|---|
| Holds | Measurements from one business process event | Descriptive context: who, what, where, when, why and how |
| One row is | One measurement event, at the declared grain | One member: a product, a store, a day or one version of a customer |
| Columns | A foreign key per dimension, numeric facts, optional degenerate keys | One primary key and many text attributes |
| Shape | Long and narrow | Wide, flat and denormalized |
In the example later in this guide, fact_sales has one row per order line, with a key for each of date, store, product and customer, plus a quantity and a net amount. dim_product has one row per product, with its category and subcategory flattened into the same row. A query for sales by category and region joins the fact table to two dimensions and groups by their attributes. Microsoft's Fabric guidance calls a star schema a prerequisite for enterprise Power BI semantic models.

How do you design a star schema in four steps?
Kimball Group lists four decisions, taken in order: select the business process, declare the grain, identify the dimensions and identify the facts. The order is the point. The grain declaration is a binding contract on the design, because every dimension and fact you add must be consistent with it.
| Step | The decision | Retail example | Check before moving on |
|---|---|---|---|
| 1. Select the business process | An operational activity that produces measurements | Selling items at the till | Can you name the source tables that record it? |
| 2. Declare the grain | What exactly one fact row represents | One row per order line | Could two rows describe the same event? |
| 3. Identify the dimensions | Who, what, where, when, why and how; one value per row | Date, store, product, customer | Does each dimension have exactly one value for each fact row? |
| 4. Identify the facts | Numeric measurements consistent with the grain | Quantity and net amount | Does any fact belong to a coarser grain, like an order's freight? |
Three habits keep the steps honest:
- Start at atomic grain. That is the lowest level at which the process captures data. Roll-ups presuppose the questions the business asks today, so add them later as aggregates that Kimball says should behave like indexes.
- Never mix grains. Kimball Group's ten essential rules warn that facts at several levels of detail in one table invite overstated or otherwise wrong results.
- Listen for "by". In Microsoft's Fabric guidance, a request for sales by salesperson, by month and by product category names three dimensions.
Kimball also wants the model to unfold through interactive workshops with business representatives, not to be designed in isolation.
What does a retail star schema look like in SQL?
The example is a retailer's sales with invented rows, at a grain of one row per order line. Every statement below ran in order on PostgreSQL 18.6 on October 9, 2026. Identity columns and OVERRIDING SYSTEM VALUE are documented for version 18, and generate_series and to_char are PostgreSQL functions; other engines spell these differently, so adapt the DDL.
CREATE TABLE dim_date (
date_key integer PRIMARY KEY, -- 20261009
full_date date NOT NULL UNIQUE,
year smallint NOT NULL,
quarter smallint NOT NULL,
month_name text NOT NULL,
day_name text NOT NULL,
is_weekend boolean NOT NULL
);
CREATE TABLE dim_store (
store_key integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
store_id text NOT NULL UNIQUE, -- natural key from the till system
store_name text NOT NULL,
region text NOT NULL
);
CREATE TABLE dim_product (
product_key integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE,
product_name text NOT NULL,
category text NOT NULL, -- hierarchy flattened into the row
subcategory text NOT NULL
);
CREATE TABLE dim_customer (
customer_key integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id text NOT NULL, -- natural key; repeats across versions
full_name text NOT NULL, -- Type 1: overwritten
segment text NOT NULL, -- Type 2: a new row when it changes
valid_from date NOT NULL,
valid_to date NOT NULL DEFAULT '9999-12-31',
is_current boolean NOT NULL DEFAULT true
);
CREATE UNIQUE INDEX one_current_row ON dim_customer (customer_id) WHERE is_current;
CREATE TABLE fact_sales (
date_key integer NOT NULL REFERENCES dim_date,
store_key integer NOT NULL REFERENCES dim_store,
product_key integer NOT NULL REFERENCES dim_product,
customer_key integer NOT NULL REFERENCES dim_customer,
order_number text NOT NULL, -- degenerate dimension
line_number smallint NOT NULL,
quantity integer NOT NULL,
unit_price numeric(10,2) NOT NULL, -- not additive
net_amount numeric(12,2) NOT NULL,
PRIMARY KEY (order_number, line_number) -- the grain, enforced
);
The date dimension is generated once, not extracted. generate_series walks a timestamp range one day at a time, and the key is the date written as an integer:
INSERT INTO dim_date
SELECT to_char(d, 'YYYYMMDD')::integer, d::date, extract(year FROM d), extract(quarter FROM d),
to_char(d, 'FMMonth'), to_char(d, 'FMDay'), extract(isodow FROM d) IN (6, 7)
FROM generate_series(timestamp '2026-01-01', timestamp '2027-12-31', interval '1 day') AS g(d);
Next come the dimension rows, each dimension with an unknown member (explained below), and a few staged sales. The load looks up every surrogate key; the customer lookup is time-based, which the section on slowly changing dimensions explains.
INSERT INTO dim_store (store_id, store_name, region) VALUES
('S01', 'Queen West', 'East'), ('S02', 'Kitsilano', 'West');
INSERT INTO dim_product (sku, product_name, category, subcategory) VALUES
('SKU-100', 'Whole bean coffee 1 kg', 'Coffee', 'Beans'),
('SKU-200', 'Green tea 50 g', 'Tea', 'Loose leaf'),
('SKU-300', 'Tea infuser', 'Tea', 'Accessories');
INSERT INTO dim_customer (customer_id, full_name, segment, valid_from) VALUES
('C-1042', 'Customer 1042', 'Consumer', '2026-01-01'),
('C-2077', 'Customer 2077', 'Consumer', '2026-01-01');
-- Unknown members: key -1, allowed because the identity is GENERATED ALWAYS
INSERT INTO dim_store OVERRIDING SYSTEM VALUE VALUES (-1, 'UNKNOWN', 'Unknown', 'Unknown');
INSERT INTO dim_product OVERRIDING SYSTEM VALUE VALUES (-1, 'UNKNOWN', 'Unknown', 'Unknown', 'Unknown');
INSERT INTO dim_customer OVERRIDING SYSTEM VALUE
VALUES (-1, 'UNKNOWN', 'Unknown', 'Unknown', '1900-01-01', DEFAULT, DEFAULT);
CREATE TEMP TABLE stg_sales AS
SELECT * FROM (VALUES
('ORD-9001', 1, DATE '2026-09-28', 'S01', 'SKU-100', 'C-1042', 3, 18.50),
('ORD-9001', 2, DATE '2026-09-28', 'S01', 'SKU-200', 'C-1042', 1, 9.25),
('ORD-9001', 3, DATE '2026-09-28', 'S01', 'SKU-300', 'C-1042', 2, 7.00),
('ORD-9002', 1, DATE '2026-10-09', 'S01', 'SKU-100', NULL, 1, 18.50),
('ORD-9003', 1, DATE '2026-10-09', 'S02', 'SKU-200', 'C-2077', 5, 9.25)
) AS v(order_number, line_number, sale_date, store_id, sku, customer_id, quantity, unit_price);
INSERT INTO fact_sales
SELECT to_char(s.sale_date, 'YYYYMMDD')::integer,
COALESCE(st.store_key, -1), COALESCE(p.product_key, -1), COALESCE(c.customer_key, -1),
s.order_number, s.line_number, s.quantity, s.unit_price, s.quantity * s.unit_price
FROM stg_sales s
LEFT JOIN dim_store st ON st.store_id = s.store_id
LEFT JOIN dim_product p ON p.sku = s.sku
LEFT JOIN dim_customer c ON c.customer_id = s.customer_id
AND s.sale_date >= c.valid_from AND s.sale_date < c.valid_to;
A typical star query joins the fact table to the dimensions it needs and groups by their attributes. Average price is SUM(net_amount) / SUM(quantity), not AVG(unit_price): the table stores the additive parts and the division happens after summing.
SELECT d.year, d.quarter, p.category, s.region,
SUM(f.quantity) AS units,
SUM(f.net_amount) AS net_sales,
ROUND(SUM(f.net_amount) / NULLIF(SUM(f.quantity), 0), 2) AS avg_price
FROM fact_sales f
JOIN dim_date d ON d.date_key = f.date_key
JOIN dim_product p ON p.product_key = f.product_key
JOIN dim_store s ON s.store_key = f.store_key
WHERE d.year = 2026
GROUP BY d.year, d.quarter, p.category, s.region
ORDER BY d.quarter, p.category, s.region;
| year | quarter | category | region | units | net_sales | avg_price |
|---|---|---|---|---|---|---|
| 2026 | 3 | Coffee | East | 3 | 55.50 | 18.50 |
| 2026 | 3 | Tea | East | 3 | 23.25 | 7.75 |
| 2026 | 4 | Coffee | East | 1 | 18.50 | 18.50 |
| 2026 | 4 | Tea | West | 5 | 46.25 | 9.25 |
PostgreSQL enforces every constraint above: in the lab it rejected a duplicate order line, a NULL customer key and a key with no dimension row. Many analytical engines let you declare key constraints, with their own syntax, but enforce none of them, as of October 2026:
| Platform | Primary and foreign keys |
|---|---|
| Snowflake, standard tables | Optional and not enforced; NOT NULL and CHECK are enforced |
| BigQuery | Not enforced; the user keeps the data consistent |
| Databricks | Informational only; NOT NULL and CHECK are enforced |
| Microsoft Fabric Warehouse | Allowed only as NOT ENFORCED, added with ALTER TABLE |
Warning
On those engines a duplicated order line or an orphan key loads without an error, and BigQuery warns that queries over tables that break a declared constraint might return incorrect results. Declare the keys anyway: BigQuery can use them to optimize queries, and Power BI Desktop can read unenforced foreign keys to detect relationships. Then test the grain and the keys on every load.
Which fact table type fits, and what can you add up?
Kimball Group's technique list describes four kinds of fact table by what one row represents:
| Type | One row is | Updated after insert? | Example |
|---|---|---|---|
| Transaction | One measurement event at a point in space and time | No | A sale line, a payment |
| Periodic snapshot | A summary of a standard period; the grain is the period | No | An account balance at month end |
| Accumulating snapshot | One run of a process, with a date key for each milestone | Yes, as each milestone is reached | Order fulfilment: ordered, shipped, delivered |
| Factless | Dimension keys that come together at a moment, with no numeric fact | No | A student attending a class |
Factless tables can also show what did not happen: subtract the activity table from a coverage table of everything that could have happened. The numeric columns of the other types fall into three groups, and the group decides how a report may combine them:
- Additive facts sum across every dimension.
quantityandnet_amountare additive. - Semi-additive facts sum across some dimensions but not all. Balances are the usual case: they add across every dimension except time, so a month-end report takes the closing balance, not the sum of the daily ones.
- Non-additive facts, such as ratios and unit prices, cannot be summed. Store the additive components and divide after summing, as the average price above does. Microsoft's guidance says the same of a unit price: never sum it.
How do dimensions handle keys, dates and unknown values?
- Surrogate keys. Give each dimension a meaningless integer primary key that the warehouse assigns. The natural key cannot do the job: tracking change puts several rows on one natural key, and keys from several source systems can be incompatible or poorly administered. The date dimension is the exception. Some teams hash instead of counting; dbt-utils'
generate_surrogate_keybuilds a hashed key from the fields you name. A hash is reproducible after a full rebuild, while an identity column can hand out different numbers and orphan the keys stored in facts. Either works if every fact load goes through the same lookup. - The date dimension. Almost every fact table needs one. Kimball's example is Easter: look it up in the calendar dimension, never compute it in SQL. The key can be the date as an integer, which helps partitioning, but filter and group on the attributes, not on the key. Time of day goes in a separate time dimension, and the date table needs a row for unknown dates. Add the attributes people group by: fiscal periods, week numbers, month names and holiday flags.
- Role-playing dimensions. An order has an order date, a ship date and a delivery date, and all three point at the one physical date dimension. Give the fact table a foreign key per role and expose each role as a view with its own column names, for example
CREATE VIEW dim_ship_date AS SELECT date_key AS ship_date_key, full_date AS ship_date FROM dim_date;. - Degenerate and junk dimensions.
order_numberidentifies the order but describes nothing else, so it stays infact_salesas a degenerate dimension. Scattered low-cardinality flags, such as gift-wrapped or payment type, can share one junk dimension that holds only the combinations that occur. - Unknown members. A NULL foreign key in a fact table breaks referential integrity, so point those rows at a default dimension row instead. Microsoft's example codes are 0 for missing, -1 for unknown, -2 for not applicable and -3 for error. In the example a sale with no customer record points at
customer_key = -1. Text attributes follow the same rule: "Unknown" rather than NULL, because databases handle grouping on NULL inconsistently.
How do conformed dimensions connect several stars?
Dimensions conform when their attributes have the same names and contents wherever they appear. Kimball Group calls this the essence of integration in an enterprise warehouse, because two fact tables can then share row headers in one report. The planning tool is the bus matrix: rows are business processes, columns are dimensions, and a mark shows which dimension a process uses. Scan a row to test a process and a column to see where a dimension must conform. This one is illustrative:
| Business process | Date | Store | Product | Customer | Supplier |
|---|---|---|---|---|---|
| Retail sales | x | x | x | x | |
| Returns | x | x | x | x | |
| Inventory snapshot | x | x | x | ||
| Purchase orders | x | x | x |
Implement one row at a time, Kimball advises. Conformance has a cost: someone must own the definition of a product category before two fact tables can share it.
Conformed dimensions also make drilling across work. Two fact tables must never be joined directly across their foreign keys, because the size of the result cannot be controlled. Query each fact table separately and merge the answers on a conformed attribute. The example adds a returns fact table at the product grain:
CREATE TABLE fact_returns (
date_key integer NOT NULL REFERENCES dim_date,
product_key integer NOT NULL REFERENCES dim_product,
order_number text NOT NULL,
line_number smallint NOT NULL,
refund_amount numeric(12,2) NOT NULL,
PRIMARY KEY (order_number, line_number)
);
INSERT INTO fact_returns VALUES (20261015, 1, 'ORD-9001', 1, 55.50), (20261015, 2, 'ORD-9003', 1, 46.25);
WITH sales AS (
SELECT p.category, SUM(f.net_amount) AS net_sales
FROM fact_sales f JOIN dim_product p USING (product_key) GROUP BY p.category),
returns AS (
SELECT p.category, SUM(f.refund_amount) AS refunds
FROM fact_returns f JOIN dim_product p USING (product_key) GROUP BY p.category)
SELECT COALESCE(s.category, r.category) AS category, s.net_sales, r.refunds
FROM sales s FULL OUTER JOIN returns r ON r.category = s.category
ORDER BY 1;
| category | net_sales | refunds |
|---|---|---|
| Coffee | 74.00 | 55.50 |
| Tea | 69.50 | 46.25 |
Joining the two fact tables directly on product_key instead reported 111.00 of coffee refunds against a true 55.50 and 92.50 of tea refunds against 46.25, and it left out 14.00 of tea sales because the tea infuser was never returned.
How do slowly changing dimensions keep history?
Attributes change, and each attribute needs a policy. Kimball Group's techniques define the types, and Design Tip #152 adds the hybrids:
| Type | What happens on a change | History kept | Use it for |
|---|---|---|---|
| 0, retain original | Nothing; the value never changes | The original only | An original credit score, durable IDs |
| 1, overwrite | The old value is replaced | None; aggregates must be recomputed | Corrections, attributes whose past is moot |
| 2, add a row | A new row and surrogate key, with effective date, expiration date and current flag | All of it | Attributes reports must show as they were |
| 3, add an attribute | The old value moves to a second column | One prior value | Alternate views; used relatively infrequently |
| 4, mini-dimension | Fast-changing attributes move to a small dimension of their own; both keys sit in the fact | Per fact row | Volatile attributes of a very large dimension |
| 6, Type 1 attributes in a Type 2 row | Type 2 plus current-value columns overwritten on every row of the same durable key | As-was and as-is | Reports that need both views |
Types 5 and 7 are hybrids with the same purpose as Type 6. Choose per attribute, not per table: in the example, full_name is Type 1 and segment is Type 2.
On October 10, 2026 customer 1042 moves from the Consumer segment to the Business segment and is renamed. The load closes the current row, opens a new one and overwrites the name on every version. Run it in one transaction; the date stands for the batch date.
CREATE TEMP TABLE stg_customer AS
SELECT * FROM (VALUES
('C-1042', 'Customer 1042 Ltd', 'Business'),
('C-2077', 'Customer 2077', 'Consumer')
) AS v(customer_id, full_name, segment);
BEGIN;
-- Type 1: overwrite the name on every version
UPDATE dim_customer d SET full_name = s.full_name
FROM stg_customer s
WHERE d.customer_id = s.customer_id AND d.full_name IS DISTINCT FROM s.full_name;
-- Type 2, step 1: close the current row when the tracked attribute changed
UPDATE dim_customer d SET valid_to = DATE '2026-10-10', is_current = false
FROM stg_customer s
WHERE d.customer_id = s.customer_id AND d.is_current
AND d.segment IS DISTINCT FROM s.segment;
-- Type 2, step 2: open a new current row for every customer that has none
INSERT INTO dim_customer (customer_id, full_name, segment, valid_from)
SELECT s.customer_id, s.full_name, s.segment, DATE '2026-10-10'
FROM stg_customer s
WHERE NOT EXISTS (SELECT 1 FROM dim_customer d
WHERE d.customer_id = s.customer_id AND d.is_current);
COMMIT;
-- A sale on October 12: stage it, then rerun the INSERT INTO fact_sales statement above
TRUNCATE stg_sales;
INSERT INTO stg_sales VALUES ('ORD-9004', 1, DATE '2026-10-12', 'S01', 'SKU-100', 'C-1042', 10, 17.00);
A second run on the same staged rows changed nothing in the lab, so the load is safe to repeat. The dimension now holds two versions of customer 1042, and the surrogate key does the work the natural key cannot:
| customer_key | customer_id | segment | valid_from | valid_to | is_current |
|---|---|---|---|---|---|
| 1 | C-1042 | Consumer | 2026-01-01 | 2026-10-10 | false |
| 3 | C-1042 | Business | 2026-10-10 | 9999-12-31 | true |

Version ranges are half-open: valid_from is inclusive and valid_to is the next version's valid_from, which is why the fact load tests sale_date >= valid_from AND sale_date < valid_to. The October 12 sale resolved to key 3, while the September sales keep key 1. Grouping by the segment on each fact row gives the as-was view; joining to the customer's current row gives the as-is view:
-- as it was: the version in force on the sale date
SELECT c.segment, SUM(f.net_amount) AS net_sales
FROM fact_sales f JOIN dim_customer c ON c.customer_key = f.customer_key
GROUP BY c.segment;
-- as it is now: the current version of the same customer
SELECT cur.segment, SUM(f.net_amount) AS net_sales
FROM fact_sales f
JOIN dim_customer c ON c.customer_key = f.customer_key
JOIN dim_customer cur ON cur.customer_id = c.customer_id AND cur.is_current
GROUP BY cur.segment;
The as-was view reports 125.00 for Consumer and 170.00 for Business. The as-is view reports 46.25 and 248.75, because customer 1042's September sales of 78.75 move to Business with the customer. Each view also shows 18.50 for the unknown customer. Both are right. They answer different questions, and a report must say which one it shows.
Three cautions apply:
- Change detection is a batch job. dbt snapshots implement Type 2 over mutable source tables, but dbt calls them a batch approach to change capture and intends them to run between hourly and daily. A change made and reverted between two runs is never recorded.
- A late arrival means a restatement. When a retroactive change reaches a Type 2 attribute, Kimball says to insert a new row and restate the affected fact rows. Plan that job before the first one arrives.
- Let the database refuse overlaps where it can. PostgreSQL 18 added non-overlapping
PRIMARY KEYandUNIQUEconstraints, writtenWITHOUT OVERLAPSon a range column; the lab confirmed one rejects an overlapping version. Elsewhere, test for gaps and overlaps, as the last section shows.
Star schema, snowflake schema or one big table?
Kimball Group advises avoiding snowflakes, in which a dimension's hierarchy is normalized into secondary tables: users find them hard to navigate, they can hurt query performance, and a flattened dimension holds exactly the same information. Microsoft agrees for Power BI, where the benefits of a single table generally outweigh those of several. For a warehouse it names three cases for a snowflake: an extremely large dimension, keys to relate facts at a higher grain such as a subcategory target, and history tracked at the higher levels. Even then it recommends a view that flattens the result.
| Star | Snowflake | One big table | |
|---|---|---|---|
| Shape | A fact joined to flat dimensions | Dimensions normalized into sub-tables | Fact and dimension attributes in one wide table |
| Joins per query | One per dimension | One per dimension plus one per extra level | None |
| Attribute history | Type 2 rows in the dimension | Tracked per level | Frozen into each row when it loads |
| Fits when | Shared definitions, several facts, BI tools | A huge dimension, or facts at a higher grain | One consumer, one grain, or features for a model |
| Watch for | Design and stewardship effort | Confusing models and longer filter paths | A changed attribute touches every row; copies drift |
The one-big-table column is where the debate lives. dbt Labs' structure guide recommends wide, denormalized marts on the argument that storage is cheap and compute is expensive, and a more normalized approach when the dbt Semantic Layer is in use. ClickHouse's Star Schema Benchmark guide builds a flat table from the benchmark's star as an optional step and notes that many ClickHouse uses do this. BigQuery's documentation prefers nested and repeated fields for hierarchical data, yet says star schemas are already typically optimized for analytics, so denormalizing further might not change performance much.
Storage is part of the trade. Parquet dictionary-encodes a column: it builds a dictionary of the values it meets and stores the column as integers, so a repeated category name costs little in a wide table, until the dictionary grows too big and the encoding falls back to plain. How columnar compression works explains the mechanism.
Keep the star as the modelled source of truth and derive a flat table from it, with a view or a model, when a consumer wants one. Flattening a star is a query; unpicking a flat table into dimensions that carry history is a project. The reverse also holds: one report fed from one source, with no history to keep, does not need a warehouse. Microsoft notes that analysts can model in Power Query straight from source data, but that this approach cannot manage historical change.
Kimball, Inmon or Data Vault?
Kimball Group's 2004 comparison with Inmon's Corporate Information Factory finds common ground on enterprise integration and two fundamental differences: whether data is normalized before the dimensional models load, and whether atomic data lives in normalized or dimensional structures. William Inmon's own account of his side and the Data Vault Alliance's introduction fill in the rest. Read all three as each camp's statement of its case.
| Kimball bus architecture | Inmon Corporate Information Factory | Data Vault | |
|---|---|---|---|
| Core structure | Dimensional models by business process | A normalized enterprise data warehouse | Hubs (business keys), links (relationships), satellites (attributes) |
| Atomic data lives in | Dimensional structures | The normalized warehouse | The vault |
| Analysts query | The stars | Departmental dimensional marts fed from the warehouse | An information layer built to look like a star schema |
| Integration comes from | Conformed dimensions and the bus matrix | Recasting source data into one corporate form | Business keys held in hubs, organized by business concept |
| Stated priority | Business acceptance, one process (matrix row) at a time | A long-term infrastructure with a "single version of the truth" | An extensible platform: new data gets new hubs and satellites |
Two things stand out. First, a star schema appears in all three: Inmon puts star-schema marts on top of his warehouse, and the Data Vault Alliance says the business-facing side should look like a star schema. The real question is what sits underneath. Second, Kimball Group calls the hybrid of a normalized warehouse plus a dimensional one viable but costly, since atomic data is staged, stored and maintained twice, and worth it mainly for a team that has already built the normalized warehouse.
The Data Vault Alliance now teaches and certifies its method as version 2.1. Its statement that there is no concept of rework is the Alliance's claim, as each camp's critique of the others is theirs. A practical rule: with a handful of sources, a team that needs dashboards in weeks and one owner for conformance, model dimensionally from the start. With many changing sources, audit requirements and a platform team, a vault under the stars can pay for itself. Either way the consumption layer is a star, and in a medallion layout it lives in gold, as the medallion architecture guide describes.
Which mistakes break a star schema?
A wrong total often comes from a join, not from bad data. Order ORD-9001 has three lines and 12.00 of freight, a measure that belongs to the order, not to a line. Join an order header to the line-grain fact and the freight repeats on every line. SAP's modelling guide names this family of error a fan trap: the fanning out effect of one-to-many joins can return incorrect results, and you cannot detect it automatically. Across the four orders in the example, the join summed 59.00 of freight where the orders table holds 35.00:
CREATE TABLE order_header (order_number text PRIMARY KEY, freight numeric(10,2) NOT NULL);
INSERT INTO order_header VALUES ('ORD-9001', 12.00), ('ORD-9002', 0.00), ('ORD-9003', 8.00), ('ORD-9004', 15.00);
SELECT SUM(h.freight) FROM order_header h JOIN fact_sales f USING (order_number); -- 59.00 (wrong)
SELECT SUM(freight) FROM order_header; -- 35.00

Kimball's fix is to allocate header-level facts down to the line, with a rule agreed with the business, so they can be sliced by every dimension. Some BI tools compensate: Looker's symmetric aggregates count each order's total once, but only when a unique primary key and the correct join relationship are declared in the model. Do not count on every tool, or every query path, to rescue a mixed-grain design.
| Symptom | Likely cause | Fix |
|---|---|---|
| A total grows when another table is joined in | A fan trap, or two fact tables joined directly | Allocate to the finer grain, or query each fact separately and merge on a conformed attribute |
| Daily and monthly figures sit in one table | Mixed grain in a single fact table | One fact table per grain; roll up in queries or aggregates |
| Fewer fact rows than staged rows | An inner join in the key lookup dropped rows whose dimension row had not arrived | LEFT JOIN to an unknown member, then compare counts on every load |
| Last year's sales jump to a new region | A Type 1 overwrite on an attribute that needed history | Make the attribute Type 2 and restate the affected facts |
| A dimension has an overwhelming number of versions | Type 2 on a fast-changing attribute | A mini-dimension (Type 4), or move the attribute into the fact table, as Microsoft suggests |
| Five joins to reach a product category | A snowflaked product dimension | Flatten it into one dimension, or expose a flattening view |
| Every report formats dates its own way | No date dimension | Add one and filter on its attributes, as Kimball's rules require for every fact table |
| Every dashboard has its own revenue | Metric logic written inside each BI tool | Define each metric once in a semantic layer, on top of the star |
The last row matters most over time. Keep facts additive and at the grain, and keep ratios and filtered measures out of the tables and out of the dashboards, in a layer where each metric has one definition and one owner. The same shape suits assistants as much as people: one fact table and a few flat dimensions leave little to guess about how tables join. Text-to-SQL and its risks are covered in generative AI for databases.
How do you introduce a star schema without a big-bang project?
Build one star end to end before designing the enterprise:
-
Pick one business process whose numbers people dispute, such as sales or claims, and list the source tables behind each number.
-
Write the grain as one sentence, and add the process to a bus matrix.
-
List the dimensions from the "by" questions the business asks. Mark the ones other processes will reuse, and agree with the data steward, attribute by attribute, whether a change overwrites (Type 1) or adds a version (Type 2).
-
Build the dimensions first, with surrogate keys, unknown members and a date dimension. Then load the fact table through key lookups.
-
Test every load. Three queries cover most of it, each expected to return no rows:
-- 1. Grain: a duplicate order line SELECT order_number, line_number FROM fact_sales GROUP BY 1, 2 HAVING count(*) > 1; -- 2. Keys: a fact row with no dimension row SELECT f.order_number FROM fact_sales f LEFT JOIN dim_customer c USING (customer_key) WHERE c.customer_key IS NULL; -- 3. History: a gap or an overlap between versions of one customer SELECT customer_id FROM ( SELECT customer_id, valid_to, lead(valid_from) OVER (PARTITION BY customer_id ORDER BY valid_from) AS next_from FROM dim_customer) t WHERE next_from <> valid_to;Also compare staged rows with fact rows. In dbt, the grain test is dbt-utils'
unique_combination_of_columnsand the key test is the built-inrelationshipstest, covered in our guide to building data analytics software. -
Reconcile one figure with a report the business already trusts, and have its owner sign it off before the old report is retired.
-
Add the next process, reusing the conformed dimensions, and point dashboards and assistants at the star instead of the raw tables.
Computese does this work as part of its data platform service: dimensional and data vault modelling, dbt models that pass their tests in CI before they reach production, and new numbers reconciled with the old ones before the business signs off. A proof of concept for a bank's regulatory data on Cloudera and Iceberg kept counterparties as Type 2 versions, on synthetic data. The usual first step is a data platform assessment of your sources, reports and current pipelines.


