OLTP runs the live business. OLAP answers questions about the business. OLTP wants fast inserts and updates on current rows. OLAP wants fast reports on a lot of history.
Interview Q&A
๐ญ Data Warehousing
Star schema, SCD, grain, ETL vs ELT โ classic warehouse interviews.
77 questions ยท 55 theory ยท 22 coding
Q-DW-001 What is the difference between OLTP and OLAP?
Answer
Explanation
Think of an order system. When a customer places an order, that write hits OLTP. When a manager asks "how many orders did we sell last quarter by city?", that read hits OLAP. A warehouse is an OLAP store. It copies data from many OLTP systems and models it for analysis. In production you do not run heavy sales reports on the checkout database. You load order facts into the warehouse so the shop stays fast.
Trap
Saying OLAP is just "a faster database." Speed is not the point. The point is a different workload, a different model, and history.
Q-DW-002 What is the difference between ETL and ELT?
Answer
ETL transforms data before it lands in the warehouse. ELT loads raw data first, then transforms inside the warehouse. ELT is common now because cloud warehouses are strong at SQL transforms.
Explanation
Old sales warehouses often cleaned orders on an ETL server, then loaded a star schema. Modern stacks land raw order files in a lake or bronze layer, then build clean customer and sales tables with SQL. Production ELT still needs contracts: schema checks, dedupe, and tests before gold reports. ETL is not dead. Use it when you must mask PII or reduce volume before the data can sit in the warehouse.
Trap
Saying ELT means "no transforms." The T still happens. It just happens after the load, closer to the warehouse engine.
Q-DW-003 Kimball vs Inmon โ what is the difference?
Answer
Kimball builds dimensional marts around business processes and a bus of shared dimensions. Inmon builds a normalized enterprise warehouse first, then marts from that hub. Kimball is bottom-up for analytics speed. Inmon is top-down for one integrated 3NF hub.
Explanation
In a sales company, Kimball starts with an order fact, a customer dimension, and a product dimension that other marts can reuse. Inmon first stores 3NF customer, order, and product tables in a corporate warehouse, then publishes a sales mart. Production teams often mix both: a normalized or vault hub, plus Kimball stars for BI. Interviewers want the planning difference, not a brand fight.
Trap
Saying "Kimball is denormalized, Inmon is a data lake." Inmon is a normalized enterprise warehouse, not a lake.
Q-DW-004 What is the Data Vault idea?
Answer
Data Vault is a hub, link, and satellite model for an enterprise warehouse. Hubs hold business keys, links hold relationships, and satellites hold changing attributes. It is built to absorb source change and keep audit history.
Explanation
Customer number lives on a hub. The sale that ties a customer to a product lives on a link. Name, city, and segment live on satellites with load dates. When the CRM adds a new customer field, you add a satellite, not rewrite the sales star. Production vaults are raw vault plus business vault. BI still usually reads a Kimball star or wide gold table on top, not the raw hubs.
Trap
Saying Data Vault replaces facts and dimensions for dashboards. Vault is the integration layer. Reports still want a dimensional or wide model.
Q-DW-005 When do you choose Data Vault vs Kimball?
Answer
Choose Kimball when the grain is stable and BI needs simple stars now. Choose Data Vault when many sources change, audit history matters, and you need a durable integration hub. Many teams vault the hub and Kimball the marts.
Explanation
If sales, finance, and support all need the same customer history, a vault hub can take CRM, billing, and ticket feeds without breaking old satellites. The sales mart can still be a star with fact_sales and dim_customer. Production cost is real: vault has more tables and more joins. Do not vault a single source with a fixed order grain just because it is trendy.
Trap
Saying "Data Vault is always more modern than Kimball." It is a different job. Vault integrates. Kimball presents.
Q-DW-006 Star schema vs snowflake schema?
Answer
A star has facts in the middle and denormalized dimensions around it. A snowflake splits dimensions into normalized outrigger tables. Stars are simpler for BI. Snowflakes save some space and reuse lookup tables.
Explanation
In a sales star, dim_product has product, brand, and category on one row. In a snowflake, product points to brand, and brand points to category. Analysts join more tables in a snowflake. Production warehouses usually keep stars for hot marts. Snowflake a dimension only when a hierarchy is shared, huge, or maintained in its own system.
Trap
Saying snowflake is "always more normalized so it is always better." Extra joins hurt BI and can confuse grain.
Q-DW-007 What is a fact table?
Answer
A fact table stores measurements of a business process at a declared grain. It holds foreign keys to dimensions plus numeric facts. Sales amount, order quantity, and discount are typical facts.
Explanation
fact_sales at order-line grain has one row per order line. It stores date_sk, customer_sk, product_sk, sales_amt, and qty. Do not put customer city on the fact. City belongs on dim_customer. In production, facts are huge, partitioned by date, and append-only or merge-by-grain. If the grain is wrong, every dashboard is wrong.
Trap
Calling any wide table a fact table. If there is no clear process and no grain, it is just a dump.
Q-DW-008 What is a dimension table?
Answer
A dimension table describes the "who, what, where, when, why" of a fact. It holds attributes used to filter and group. Customer, product, store, and date are classic dimensions.
Explanation
dim_customer has customer key, name, city, and segment. A sales report groups by city because city lives on the customer dimension, not on every fact row. Dimensions are smaller than facts but get used in almost every query. In production they need surrogate keys, change policy (SCD), and an unknown row so facts can always join.
Trap
Putting measures like sales amount on the dimension. Measures belong on facts.
Q-DW-009 What is grain, and why does it matter?
Answer
Grain is the meaning of one fact row. You must say it in one sentence before you model. Wrong grain causes double counting or lost detail.
Explanation
Say it out loud: "one row per order line per day." That is not the same as "one row per order" or "one row per customer per day." If you mix order headers and order lines in one table, a customer with three lines looks three times richer than a customer with one line. In production, grain is a contract. Tests should fail when a key at that grain repeats.
Trap
Starting with columns and hoping the grain appears later. If you cannot state the grain, stop modeling.
Q-DW-010 What is a degenerate dimension?
Answer
A degenerate dimension is a source identifier stored on the fact table because it has no attributes of its own. Order number and invoice number are the usual examples. It is a dimension with no dimension table.
Explanation
Sales facts keep order_id on fact_sales. There is no dim_order if the only extra field is the order number itself. Analysts still filter "show order 88312." In production, keep that id for drill to the source system. Do not build an empty dimension table just to feel dimensional.
Trap
Creating dim_order with only order_id in it. That table adds joins and no attributes.
Q-DW-011 What is a junk dimension?
Answer
A junk dimension packs low-cardinality flags and leftover labels into one dimension. Payment method, web vs store, and gift-wrap flag often go there. It stops the fact table from filling with tiny foreign keys.
Explanation
An order may have is_gift, is_priority, and channel. Each flag has a few values. Instead of three tiny dimensions, build dim_order_flags with every useful combination and one junk_sk on fact_sales. Production junk dimensions should stay small. If a flag becomes a real business entity with its own history, promote it to a proper dimension.
Trap
Dumping high-cardinality comments or free-text into a junk dimension. Junk is for small flags, not for customer names.
Q-DW-012 What is a conformed dimension?
Answer
A conformed dimension is the same dimension, with the same keys and meanings, reused by more than one fact. Customer, product, and date are the usual conformed dimensions. They let you drill across processes.
Explanation
dim_customer used by sales facts and returns facts is conformed when "customer 55" is the same person in both marts. Then you can ask "who bought and later returned." In production, one team owns the conformed customer table. Local copies that rename segment or reuse customer ids will break cross-process reports.
Trap
Copying a customer table into two marts and calling it conformed. Same columns are not enough. Keys and definitions must match.
Q-DW-013 What is a role-playing dimension?
Answer
A role-playing dimension is one physical dimension used more than once in different roles. Date is the classic case: order date, ship date, and delivery date. Each role is a separate view or alias, not a copy of the data.
Explanation
fact_sales has order_date_sk, ship_date_sk, and delivery_date_sk. All three point at dim_date. A report can say "ordered in January, shipped in February." In production, do not clone dim_date three times. Use views like dim_order_date so BI tools can join the same calendar in three roles.
Trap
Building three physical date tables that drift. Holidays then disagree across roles.
Q-DW-014 What is SCD Type 0?
Answer
SCD Type 0 never changes the attribute after insert. Original values stay original. Date of birth and original credit score are Type 0.
Explanation
If a customer row stores original_signup_city = Pune, that city stays Pune even if they move to Delhi. Reports that need "city at signup" still work years later. In production, Type 0 is for durable original facts, not for attributes you must correct. A wrong date of birth still needs a controlled fix, not a silent overwrite policy.
Trap
Using Type 0 for address. Address changes, and the business usually needs history or the latest value.
Q-DW-015 What is SCD Type 1?
Answer
SCD Type 1 overwrites the attribute. There is no history. Use it for corrections and for values where only the latest truth matters.
Explanation
Customer email changes from old@mail.com to new@mail.com. Type 1 updates dim_customer in place. Yesterday's report now also shows the new email if it reads the dimension today. In production, Type 1 is right for typo fixes and for columns compliance says you must not keep. Do not use it for segment if finance needs last year's segment.
Trap
Using Type 1 for every change because it is easy. You will lose history that sales and audit later ask for.
Q-DW-016 What is SCD Type 2?
Answer
SCD Type 2 keeps history by adding a new dimension row when tracked attributes change. The old row is closed. The new row becomes current. Facts keep the surrogate key of the version that was true at event time.
Explanation
Customer 55 moves from Pune to Delhi. You close the Pune row (valid_to, current_flag = N) and insert a Delhi row with a new customer_sk. Old sales stay on the Pune key. New sales get the Delhi key. In production, Type 2 needs a business key, a surrogate key, non-overlapping dates, and exactly one current row per business key. It is the default interview SCD.
Trap
Updating the city on the same surrogate key and calling it Type 2. If the key does not change, old facts change meaning.
Q-DW-017 What is SCD Type 3?
Answer
SCD Type 3 keeps current and previous values on the same row. History depth is one step, not full history. Use it when the business only asks "old vs new."
Explanation
dim_customer has segment and previous_segment. When a customer moves from Silver to Gold, you copy Silver into previous_segment and set segment to Gold. You can report "now Gold, was Silver." You cannot report the segment from five changes ago. In production, Type 3 is rare as the only policy. It is often a column on top of Type 2.
Trap
Saying Type 3 keeps full history in columns. One previous column is not full history.
Q-DW-018 What is SCD Type 4?
Answer
Interviewers use Type 4 in two ways. Kimball Type 4 puts rapidly changing attributes in a mini-dimension. Many exam sites mean a current dimension plus a separate history table. Ask which one they want, then answer that design.
Explanation
Customer profile stays Type 1 on dim_customer. Credit band and risk score, which change often, go to dim_customer_score with its own key on the fact. That is the Kimball Type 4 / mini-dimension idea. The other Type 4 keeps dim_customer_current and dim_customer_history. Production should pick one story and document it. Do not implement both and call them the same Type 4.
Trap
Reciting "Type 4 is history table" as the only answer. In Kimball books, Type 4 is the mini-dimension.
Q-DW-019 What is SCD Type 6?
Answer
Type 6 is a hybrid of Types 1, 2, and 3. You keep Type 2 history rows, overwrite a current-value column on all versions (Type 1 overlay), and may also keep a previous-value column (Type 3). You can filter "current segment" even on old facts.
Explanation
Customer 55 was Silver, now Gold. Type 2 adds a new row for Gold. Type 6 also stamps current_segment = Gold on the old Silver row and the new Gold row. A report can group historical sales by today's segment without an as-of join. In production this helps marketing, but updates must touch every version of that customer. It is more write work than plain Type 2.
Trap
Calling any Type 2 table with a current_flag Type 6. A current flag is not a Type 1 overlay column.
Q-DW-020 What is a late arriving dimension?
Answer
A late arriving dimension is a dimension change or dimension row that shows up after facts already loaded. The fact was stored with an unknown, inferred, or old version. You must repair the dimension and sometimes re-point the fact.
Explanation
An order arrives today for a new customer. The customer MDM feed arrives tomorrow. Today's fact_sales row cannot wait. You insert an inferred customer and attach the sale. Tomorrow you update that member or Type 2 it with the real name and city. In production, late dimensions are normal with multiple source systems. Design the unknown row and a repair job before go-live.
Trap
Dropping the fact until the dimension is perfect. Orders will pile up, and daily sales will be wrong.
Q-DW-021 What is a late arriving fact?
Answer
A late arriving fact is a measurement that arrives after its event date. You insert it on the true event date, not on the load date. Dimension lookup must use the version that was current at event time.
Explanation
A store sends yesterday's missing orders today. Those rows belong on yesterday's sales, not today's. If dim_customer is Type 2, look up the customer version valid on the order date. In production, partition by event date and make the daily load rerunnable for a lookback window, not only for "today."
Trap
Joining late facts to current_flag = 'Y'. That attaches yesterday's sale to today's customer version.
Q-DW-022 Surrogate key vs natural key โ which does a warehouse use?
Answer
A natural key is the business id from the source, like customer_id. A surrogate key is a warehouse-generated integer the fact uses as a foreign key. Dimensions keep both. Facts should store the surrogate key.
Explanation
CRM customer C-55 can be reused, reused after delete, or formatted differently in billing. The warehouse surrogate customer_sk = 80021 never changes and is what fact_sales stores. Type 2 needs a new surrogate for each version of C-55. In production, never join facts to dimensions on the natural key alone if you keep history. Natural keys also make poor clustered keys when they are strings.
Trap
Using the source customer id as the fact foreign key and then trying to do Type 2. History versions cannot share one natural key as the primary key.
Q-DW-023 Additive vs semi-additive vs non-additive facts?
Answer
Additive facts sum across every dimension, including time. Semi-additive facts sum across some dimensions but not time. Non-additive facts cannot be summed; you must recompute them.
Explanation
sales_amt is additive. You can sum it by customer, product, and date. Inventory on_hand_qty is semi-additive. You can sum it across warehouses on one day, but not across days. Discount percent and average order value are non-additive. In production, store additive parts (sales_amt, qty) and compute ratios in the report or gold layer. Never sum a percentage column.
Trap
Summing inventory balances for a month and calling it "total inventory." That double counts stock that sat there for 30 days.
Q-DW-024 What is a periodic snapshot fact?
Answer
A periodic snapshot stores a balance or status at a regular time grain, such as daily or monthly. Each snapshot is a picture, not a transaction. Inventory and account balance are classic snapshots.
Explanation
Every night you write one row per product per warehouse: date, on_hand_qty, reserved_qty. You do not store every stock movement in that table. To see last month's average stock, average the daily pictures. In production, snapshots grow fast: 10,000 products ร 50 warehouses ร 365 days. Partition by snapshot date and do not update old pictures unless the source restates history.
Trap
Building a snapshot and a transaction fact in one table. Pictures and movements have different grains.
Q-DW-025 What is an accumulating snapshot fact?
Answer
An accumulating snapshot tracks one instance as it moves through a pipeline. The row is updated as milestones happen. Order fulfillment and claims processing use this pattern.
Explanation
One row per order has order_date_sk, pick_date_sk, ship_date_sk, delivery_date_sk, and lag days. When the order ships, you update that same row instead of inserting a new fact. Pipeline dashboards become simple. In production, this fact is update-heavy, so keep it only for processes with a fixed set of steps. High-volume click events do not belong here.
Trap
Using an accumulating snapshot for every process. If there is no finite pipeline, use transactions or periodic snapshots.
Q-DW-026 What is a bridge table?
Answer
A bridge table resolves a many-to-many between a fact and a dimension, or between two dimensions. It holds the group key, the member key, and often a weight. Customer households and product multi-categories need bridges.
Explanation
An order can belong to two customers in a household. fact_sales stores household_sk. bridge_household_customer lists each customer in that household with allocation_pct. Reports explode the bridge and multiply sales_amt * allocation_pct so the total does not double. In production, weights must sum to 1.0 for each group or you will inflate revenue.
Trap
Joining the many-to-many directly and summing sales with no weight. Every shared order will be counted twice.
Q-DW-027 What are slowly changing facts?
Answer
Slowly changing facts are measures that get corrected or restated after load. You can overwrite in place, version the fact row, or push changes into an accumulating snapshot. Pick the rule from the business: correction vs true new event.
Explanation
An order line loads with sales_amt = 100, then billing sends 90 after a discount. If it is a correction, update the same grain or version it with is_current. If it is a new adjustment, insert an adjustment fact. In production, never silently rewrite last quarter after finance has closed unless you have a restatement process. Audit columns should show the batch that changed the measure.
Trap
Always inserting another fact row for a correction. Revenue then double counts unless reports filter current versions.
Q-DW-028 What are conformed facts?
Answer
Conformed facts are measures that share the same meaning, grain, and units across marts. You can add them or compare them without a translation step. Store sales and web sales both as INR order-line revenue is the idea.
Explanation
If store sales_amt is before tax and web sales_amt is after tax, those facts are not conformed. A company total will be a lie. In production, publish a measure dictionary: grain, currency, tax rule, and timezone. Conformed dimensions are not enough. The numbers must also speak the same language.
Trap
Adding two revenue columns because they have the same name. Name is not meaning.
Q-DW-029 What is a bus matrix?
Answer
A bus matrix is a Kimball planning table. Rows are business processes (facts) and columns are dimensions. An X means that process uses that dimension, so it shows what must be conformed.
Explanation
Rows might be orders, shipments, inventory, and returns. Columns might be date, customer, product, and store. Orders and returns both mark customer and product, so those dimensions must be conformed. In production the bus matrix is the contract between data teams. New marts should add a row, not invent a private customer table.
Trap
Treating the bus matrix as an ER diagram. It is a reuse map, not a physical schema.
Q-DW-030 What is a staging area?
Answer
Staging is a landing zone for source extracts. It is not for business reports. It holds raw or lightly typed copies so you can restart loads and compare with source.
Explanation
Nightly order files land in stg_orders as they came from the shop system. Transforms then build fact_sales. If the transform fails, you do not re-hit the OLTP system. You replay staging. In production, staging is often truncate-and-load or a dated raw partition. Do not add Type 2 logic in staging.
Trap
Pointing Tableau at staging because "the data is already there." Staging has no grain contract and no conformed keys.
Q-DW-031 What is an ODS?
Answer
An Operational Data Store holds integrated, mostly current data for operational reporting. It is not a dimensional warehouse and not a long history store. Think of it as a clean, near-live copy of operations.
Explanation
A call-center ODS can show today's customer, open orders, and last payment in one place. It updates often. It will not keep five years of Type 2 city history. That history belongs in the warehouse. In production, do not make the ODS do both jobs. Near-real-time current and deep historical analytics fight each other.
Trap
Calling the ODS "a small data warehouse." Current operational integration is a different requirement.
Q-DW-032 Data mart vs data warehouse?
Answer
A data warehouse is the enterprise analytics store. A data mart is a subject slice, often a star for one team. Marts should share conformed dimensions from the warehouse bus.
Explanation
The warehouse holds sales, inventory, and finance. The sales mart is fact_sales plus customer, product, and date for the sales team. Independent marts that each build their own customer table will disagree on "number of customers." In production, prefer dependent marts from one hub or one lakehouse gold layer.
Trap
Saying a mart is just "a smaller warehouse" with no shared keys. Size is not the difference. Scope and conformation are.
Q-DW-033 What is a cube / OLAP cube?
Answer
A cube is a pre-aggregated multidimensional model over facts and dimensions. Users slice by product, date, and city without scanning every fact row. MOLAP stores the cube. ROLAP queries the relational star.
Explanation
A sales cube might precompute sales by month, category, and city. Pivot tools feel instant. The cost is refresh time and rigidity when grain changes. In production, many teams now skip cubes and use columnar warehouses plus a semantic layer. Cubes still appear in interviews and in older finance stacks.
Trap
Saying cubes are required for any OLAP. OLAP is the workload. A cube is one speed trick.
Q-DW-034 What is a slowly changing hierarchy?
Answer
A slowly changing hierarchy is a parent-child structure that moves over time. Products change category. Employees change manager. You must decide whether history follows the old path or the new path.
Explanation
SKU 88 moves from "Mobile" to "Electronics > Accessories." Old sales can stay under Mobile if you Type 2 the product row. If you overwrite category (Type 1), last year's mobiles become accessories in every report. In production, denormalize the current path on Type 2 product rows, or keep a hierarchy bridge with effective dates. Recalculating the whole tree on every report is slow and unstable.
Trap
Only storing parent_id and always walking the current tree. Historical reports then rewrite the past.
Q-DW-035 What is CDC?
Answer
CDC means Change Data Capture. It reads inserts, updates, and deletes from the source instead of full dumps. Log-based CDC watches the database log. Query-based CDC uses timestamps or version columns.
Explanation
When a customer city changes, CDC emits the old and new row. The warehouse applies that change to dim_customer as Type 1 or Type 2. You do not reload 20 million customers every night. In production, log-based CDC is kinder to OLTP. You still need ordering, deletes, and a replay topic. Timestamp CDC misses deletes unless the source soft-deletes.
Trap
Treating a nightly full extract as CDC. Full extract can feed incremental loads, but it is not change capture.
Q-DW-036 Truncation vs incremental load?
Answer
A truncate load empties the target and reloads everything. An incremental load applies only new or changed rows. Truncate is simple. Incremental is required when tables are large or history must stay.
Explanation
A 200-row dim_pay_method can truncate-reload in seconds. A 5-year fact_sales cannot. Incremental sales loads take yesterday's orders, plus a lookback for late facts. In production, truncate-reload of a Type 2 dimension wipes history unless you rebuild from a full archive. Prefer incremental for facts and SCD2 dimensions.
Trap
Truncating a fact table "to be safe" every run. You will lose history and blow the warehouse window.
Q-DW-037 What is an idempotent load?
Answer
An idempotent load can run twice with the same input and not duplicate data. The second run leaves the same grain as the first. You get this with MERGE, partition replace, or delete-then-insert by a load key.
Explanation
The 15 March sales job fails after insert and is rerun. If the job only appends, 15 March doubles and revenue explodes. If it deletes 15 March then inserts, or merges on (order_id, line_id), totals stay correct. In production, every scheduled warehouse job must be rerunnable. Airflow retries are not safe without idempotency.
Trap
Relying on "it probably will not fail." Failed jobs retry. Duplicate facts are the usual result.
Q-DW-038 What is a watermark in warehouse loads?
Answer
A watermark is the high-water mark of the last successful extract. The next incremental load starts after that point. It can be a timestamp, an id, or a file name.
Explanation
Last night the sales extract ended at src_updated_at = 2026-03-15 18:00. Tonight you pull rows with src_updated_at > that watermark. You advance the watermark only after the load commits. In production, add a lookback window because late orders can carry old timestamps. Store watermarks per source table, not one global clock.
Trap
Updating the watermark before the load succeeds. A crash then skips rows forever.
Q-DW-039 What are audit columns?
Answer
Audit columns record how a warehouse row got there. Typical ones are load_dts, record_source, batch_id, and src_updated_at. They are not business measures.
Explanation
fact_sales may have sales_amt for the business and batch_id for operations. When yesterday's total looks wrong, you trace the batch and the source file. Data Vault makes load_dts and record_source first-class. In production, do not reuse order_date as the load date. Event time and load time answer different questions.
Trap
Putting load_dts into sales dashboards as "order date." Late loads then move historical revenue to today.
Q-DW-040 SCD Type 2 current flag vs date range โ which should you use?
Answer
Use both if you can. current_flag makes "latest customer" queries easy. valid_from / valid_to makes as-of joins correct. A flag alone cannot answer "what was true on 12 Jan."
Explanation
BI often wants current city: WHERE current_flag = 'Y'. Late facts and as-of reports need order_date between valid_from and valid_to. In production, enforce one current row per natural key and non-overlapping ranges. Open-ended current rows usually use valid_to = 9999-12-31. Do not leave valid_to null if your engine cannot index range joins well โ pick a convention and test it.
Trap
Keeping only current_flag. You will not be able to reconstruct history for late arriving facts.
Q-DW-041 What is a mini-dimension?
Answer
A mini-dimension is a small dimension of rapidly changing, low-cardinality attributes split off a large dimension. The fact stores both the durable dimension key and the mini-dimension key. It stops Type 2 from exploding a monster customer table.
Explanation
dim_customer keeps name and signup date. Credit band, risk score band, and activity band change often, so they live in dim_customer_profile with one row per combination. Each sale stores customer_sk and profile_sk. In production this is the Kimball answer to "customer Type 2 grew to 40 versions each." Do not put high-cardinality fields like email into the mini-dimension.
Trap
Type 2-ing every credit-score change on the main customer dimension. The customer table becomes huge and slow.
Q-DW-042 What is an outrigger?
Answer
An outrigger is a secondary dimension joined to a dimension, not to the fact. It is a limited snowflake. Use it for a shared lookup that several dimension rows need.
Explanation
dim_customer may store primary_store_sk and join dim_store as an outrigger. dim_promotion may have start_date_sk pointing at dim_date. Keep outriggers rare. In production, BI tools struggle when every attribute is two joins away. If the outrigger is used in almost every sales query, denormalize the few columns onto the main dimension.
Trap
Snowflaking every lookup "for normalization." That is OLTP thinking inside a warehouse.
Q-DW-043 What is a galaxy / fact constellation schema?
Answer
A galaxy, or fact constellation, is more than one fact table sharing conformed dimensions. Sales and inventory facts both use date, product, and store. It is the normal enterprise Kimball shape.
Explanation
fact_sales is at order-line grain. fact_inventory_daily is at product-warehouse-day grain. They share dim_product and dim_date, but you do not join the two facts on a fake common grain. Drill-across happens by aggregating each fact to a shared grain, then combining. In production this is healthier than one giant fact that mixes processes.
Trap
Joining two facts row-to-row because they share product and date. That creates a many-to-many blow-up.
Q-DW-044 What is one-big-table (OBT)?
Answer
One-big-table is a denormalized analytics table where fact measures and dimension attributes sit on the same row. BI tools can query it with almost no joins. It is a presentation choice, not a replacement for a modeled hub.
Explanation
A sales OBT has sales_amt, customer city, product category, and order date on one row. Dashboards get simple. The cost is size, duplicated attributes, and painful Type 2. In production, build OBT in gold from a star, not as the only model. Several OBTs without conformed sources will drift like independent marts.
Trap
Loading source dumps straight into one wide table and calling it a warehouse. There is still no grain and no change policy.
Q-DW-045 Wide vs tall fact design?
Answer
Wide facts store each measure in its own column: sales_amt, qty, discount_amt. Tall facts store measure_name and measure_value. Wide is easier for BI. Tall is more flexible when new metrics appear.
Explanation
Sales teams want SUM(sales_amt) and SUM(qty) as columns. A tall "metric fact" can add "packaging_fee" without an ALTER, but every report needs filters and pivots. In production, keep core additive money measures wide. Use tall for a metrics store or for sparse custom measures. Do not mix units in one tall column without a unit field.
Trap
Storing percents and amounts in the same tall value column and summing them. Units get mixed.
Q-DW-046 What is the medallion bronze / silver / gold model?
Answer
Medallion is a lakehouse layering pattern. Bronze is raw ingestion, silver is cleaned and conformed, and gold is business marts. Each layer has a stronger contract.
Explanation
Bronze keeps raw order JSON from the shop. Silver turns it into clean order lines and Type 2 customers. Gold is fact_sales and sales dashboards. In production, do not skip silver. Bronze-to-gold copies source bugs into finance. Late data, schema drift, and SCD belong in silver. Gold should be rerunnable from silver.
Trap
Using bronze as the BI layer because it has "all the columns." Raw is not conformed and not quality-checked.
Q-DW-047 What is a metrics store?
Answer
A metrics store is a central definition of KPIs so every tool calculates them the same way. Revenue, active customers, and conversion live as versioned measures, not as copied SQL in ten dashboards.
Explanation
If marketing defines "active customer" as 30-day ordered and finance defines it as 90-day paid, meetings turn into number fights. A metrics layer (LookML, dbt metrics, Cube, a semantic view) points both tools at one definition on top of gold facts. In production, the store must declare grain, filters, and time windows. It does not replace the warehouse. It sits on it.
Trap
Building a metrics store on raw bronze tables. Unstable grain makes the KPI undefined.
Q-DW-048 What is a slowly changing calendar?
Answer
A calendar dimension looks static, but holidays, fiscal week rules, and season flags do change. A slowly changing calendar versions those attributes instead of editing history in place. Rare, but ugly when ignored.
Explanation
A government moves a holiday, or finance changes 4-4-5 week assignment. If you overwrite dim_date for 12 Jan, last year's year-over-year holiday report shifts. Production calendars should be generated, versioned, and released like code. Keep a durable date_sk = yyyymmdd. Version holiday and fiscal attributes, or snapshot the calendar used for a closed year.
Trap
Hand-editing holiday flags in dim_date the night before a board meeting. Closed years move.
Q-DW-049 Why does a warehouse need a fiscal calendar?
Answer
Finance does not always use JanuaryโDecember or MondayโSunday. A fiscal calendar stores fiscal year, quarter, week, and 4-4-5 periods on the date dimension. Reports then group by fiscal attributes without custom date math.
Explanation
A retailer may have fiscal year starting in February and weeks that are 4-4-5. dim_date has both calendar_year and fiscal_year, both calendar_week and fiscal_week. Sales facts only store date_sk. In production, never compute fiscal year in every query. One wrong week-start and the whole P&L shifts. Generate the calendar once and test year boundaries.
Trap
Using MONTH(order_date) for finance reports in a non-calendar fiscal year. Quarter totals will not match the books.
Q-DW-050 What are early-arriving facts?
Answer
Early-arriving facts show up before their dimension row exists. The process event is real, but the lookup master is late. You still load the fact, usually against an inferred member.
Explanation
A new customer places an order before MDM publishes the customer file. fact_sales arrives first. Create a customer inferred member with the natural key from the order, then attach the sale. When MDM arrives, fill the inferred row or Type 2 it. This is the same family as late arriving dimensions, seen from the fact side. Production pipelines should not wait for a perfect master.
Trap
Parking early facts in an error table forever. Revenue for new customers then appears days late.
Q-DW-051 What are inferred members?
Answer
An inferred member is a placeholder dimension row created so a fact can get a surrogate key. It holds the natural key and unknown attributes. Later you update it when the real dimension arrives.
Explanation
Order 88312 comes with customer_id = C-55, but dim_customer has no C-55. You insert customer_sk = 90001, name Unknown, inferred_flag = Y. The fact uses 90001. When the customer feed arrives, you fill name and city on that same surrogate (Type 1 repair) or close it and add a Type 2 version. In production, inferred members keep referential integrity. Facts should not store null dimension keys.
Trap
Inserting the fact with customer_sk = NULL and promising to fix joins later. Inner-join reports will drop those sales.
Q-DW-052 What is a factless fact table?
Answer
A factless fact table records an event or a coverage relationship with no numeric measure. The fact is that the event happened. Attendance, promotions on products, and student course enrollment are classic cases.
Explanation
fact_promotion_coverage has one row per product, store, and date the product was on promo. There is no amount. To ask "which products were on promo but did not sell," you left join sales facts to this coverage fact. In production, still declare grain and surrogate keys. You can add a dummy event_count = 1 to make COUNT(*) obvious, but the table is still factless in meaning.
Trap
Forcing a fake amount onto every event. Coverage questions then get mixed with revenue questions.
Q-DW-053 What belongs in a date dimension?
Answer
A date dimension has one row per calendar day and all the labels BI needs: year, quarter, month, week, weekday, holiday flag, and fiscal attributes. Facts store date_sk, not a pile of date functions.
Explanation
dim_date turns 2026-03-15 into fiscal_year, is_weekend, and month_name. Sales queries group by those columns. Generate it for 20+ years, including future dates for plans. In production, date_sk is often yyyymmdd as an integer. That is readable in facts and still a surrogate by contract.
Trap
Calling CAST(order_date AS date) a date dimension. You still push calendar logic into every report.
Q-DW-054 What is the unknown / dummy dimension member?
Answer
Every dimension needs a dummy row for missing keys, usually sk = -1 or 0. Facts that cannot resolve a natural key point there. Reports stay complete instead of dropping rows on inner joins.
Explanation
An order arrives with a blank product id. fact_sales.product_sk = -1 joins dim_product "Unknown." Revenue still shows up in totals, under Unknown. Inferred members are for real natural keys that will be repaired. Dummy unknown is for truly missing keys. In production, never let null SKs into facts if BI uses inner joins.
Trap
Using SQL null instead of an unknown member. Inner joins then hide sales.
Q-DW-055 Data lake vs warehouse vs lakehouse?
Answer
A data lake stores cheap raw files of many types. A warehouse stores structured, modeled data for SQL analytics. A lakehouse keeps lake storage but adds warehouse-like tables, transactions, and governance. Medallion layers often sit on a lakehouse.
Explanation
Raw order JSON can live in a lake. fact_sales belongs in a warehouse or gold lakehouse tables. Databricks, Iceberg, and similar tools try to give both: cheap storage plus MERGE, SCD2, and BI. In production, a lake without a model is not a warehouse. You still need grain, keys, and quality.
Trap
Saying the lake replaced dimensional modeling. Storage changed. Grain and conformation did not.
Q-DW-056 SCD Type 2 merge with current_flag and valid_from / valid_to
Answer
Match on the business key against the current dimension row. Close changed rows by setting valid_to and current_flag = 'N'. Insert a new current row for changes and for brand-new customers.
Explanation
Customer C-55 lives in Pune. Tomorrow the CRM says Delhi. You must keep the Pune version for old sales and add a Delhi version for new sales. Production SCD2 uses a surrogate key per version, one current row per natural key, and non-overlapping dates. Run the close step and the insert step in one transaction so a crash does not leave two current rows.
Code
-- dim_customer is Type 2: one surrogate per version of customer_nk
-- staging_customer is today's customer snapshot (natural key + attributes)
-- 1) Close current rows whose tracked attributes changed
MERGE INTO dim_customer AS t
USING (
SELECT
s.customer_nk,
s.customer_name,
s.city,
s.segment
FROM staging_customer AS s
) AS src
-- only the live version can be closed
ON t.customer_nk = src.customer_nk
AND t.current_flag = 'Y'
AND (
COALESCE(t.customer_name, '') <> COALESCE(src.customer_name, '')
OR COALESCE(t.city, '') <> COALESCE(src.city, '')
OR COALESCE(t.segment, '') <> COALESCE(src.segment, '')
)
WHEN MATCHED THEN UPDATE SET
t.valid_to = CURRENT_DATE, -- yesterday's version ends today
t.current_flag = 'N',
t.update_dts = CURRENT_TIMESTAMP;
-- 2) Insert new current versions: brand-new nks and changed nks
INSERT INTO dim_customer (
customer_sk,
customer_nk,
customer_name,
city,
segment,
valid_from,
valid_to,
current_flag,
inferred_flag,
load_dts
)
SELECT
-- warehouse surrogate; use a sequence in production
NEXT VALUE FOR seq_customer_sk,
s.customer_nk,
s.customer_name,
s.city,
s.segment,
CURRENT_DATE,
DATE '9999-12-31',
'Y',
'N',
CURRENT_TIMESTAMP
FROM staging_customer AS s
LEFT JOIN dim_customer AS cur
ON cur.customer_nk = s.customer_nk
AND cur.current_flag = 'Y'
WHERE cur.customer_sk IS NULL -- new customer
OR COALESCE(cur.customer_name, '') <> COALESCE(s.customer_name, '')
OR COALESCE(cur.city, '') <> COALESCE(s.city, '')
OR COALESCE(cur.segment, '') <> COALESCE(s.segment, ''); -- already closed above
What this code does
- The MERGE looks only at current dimension rows (
current_flag = 'Y'). - If name, city, or segment changed, it closes that row with
valid_to = todayandcurrent_flag = 'N'. - The INSERT adds a new current row for new customers and for those closed changes.
- New rows get a fresh surrogate key,
valid_from = today, andvalid_to = 9999-12-31. - Old sales keep the old surrogate key, so Pune history does not become Delhi.
Trap
Matching MERGE on the natural key without current_flag = 'Y'. You would close every historical version, not just the live one. Plain <> also misses NULL to value changes.
Q-DW-057 Star-schema sales query
Answer
Join the fact to each dimension on surrogate keys. Group by the dimension attributes the report needs. Sum only additive facts. Do not filter Type 2 dimensions to current if you want historical truth.
Explanation
Sales by city and category for 2025 should use the customer version stored on the fact. That is the city at order time. Production BI should hide the SKs and expose city, category, and year. If you join current_flag = 'Y', movers will have last year's orders reported in this year's city.
Code
-- grain of fact_sales: one row per order line
SELECT
dd.calendar_year,
dc.city,
dp.category,
SUM(fs.sales_amt) AS sales_amt, -- additive
SUM(fs.qty) AS qty -- additive
FROM fact_sales AS fs
JOIN dim_date AS dd
ON dd.date_sk = fs.order_date_sk -- role: order date
JOIN dim_customer AS dc
ON dc.customer_sk = fs.customer_sk -- SK at order time, not current
JOIN dim_product AS dp
ON dp.product_sk = fs.product_sk
WHERE dd.calendar_year = 2025
GROUP BY
dd.calendar_year,
dc.city,
dp.category
ORDER BY sales_amt DESC;
What this code does
fact_salesholds the measures and the dimension keys.- Each join uses the surrogate key stored on the fact.
- Year, city, and category come from dimensions, not from the fact.
SUM(sales_amt)is safe because sales amount is additive.- There is no
current_flagfilter, so a customer who moved still reports under the city on that order.
Trap
Joining dim_customer on customer_nk plus current_flag = 'Y'. Historical city is lost.
Q-DW-058 Grain-check query for an order-line fact
Answer
Declare the grain: one row per order_id + line_id. Group by that key and keep groups with COUNT(*) > 1. Also check null keys. A clean fact has zero duplicates at grain.
Explanation
If the same order line lands twice, every sales dashboard doubles. Production tests should run this after every load and fail the job. Duplicate grain is more dangerous than a missing column because the table still "looks fine."
Code
-- 1) Duplicate grain: same order line more than once
SELECT
order_id,
line_id,
COUNT(*) AS row_cnt,
SUM(sales_amt) AS summed_amt
FROM fact_sales
GROUP BY order_id, line_id
HAVING COUNT(*) > 1
ORDER BY row_cnt DESC;
-- 2) Nulls that break the grain or the joins
SELECT
SUM(CASE WHEN order_id IS NULL THEN 1 ELSE 0 END) AS null_order_id,
SUM(CASE WHEN line_id IS NULL THEN 1 ELSE 0 END) AS null_line_id,
SUM(CASE WHEN customer_sk IS NULL THEN 1 ELSE 0 END) AS null_customer_sk,
SUM(CASE WHEN order_date_sk IS NULL THEN 1 ELSE 0 END) AS null_date_sk,
COUNT(*) AS fact_rows
FROM fact_sales;
-- 3) Optional: rows whose grain key is not unique across the whole table
SELECT
COUNT(*) AS fact_rows,
COUNT(DISTINCT order_id || '#' || CAST(line_id AS VARCHAR)) AS distinct_grain
FROM fact_sales;
What this code does
- Query 1 lists order lines that appear more than once.
- The summed amount shows how badly revenue would inflate.
- Query 2 counts null business keys and null surrogate keys.
- Query 3 compares row count with distinct grain. If they differ, the grain is broken.
- A passing load returns zero duplicate groups and zero null keys.
Trap
Checking only COUNT(*) on the table. Volume can stay "about right" while a few fat orders duplicate.
Q-DW-059 Late arriving dimension โ create an inferred member
Answer
When a fact arrives with a customer natural key that is not in the dimension, insert an inferred member first. Give it a surrogate key, unknown attributes, and inferred_flag = 'Y'. Then load the fact with that key. Repair the member when the real customer feed arrives.
Explanation
A new customer can order before MDM sends the profile. The sale still needs a customer_sk. Production inferred members keep referential integrity and stop inner-join reports from dropping revenue. Later, update the same SK (Type 1 repair) or Type 2 a complete version.
Code
-- fact staging has orders, including customer_nk that may be new
-- 1) Insert inferred members for unknown natural keys
INSERT INTO dim_customer (
customer_sk,
customer_nk,
customer_name,
city,
segment,
valid_from,
valid_to,
current_flag,
inferred_flag,
load_dts
)
SELECT
NEXT VALUE FOR seq_customer_sk,
s.customer_nk,
'Unknown', -- placeholder attribute
'Unknown',
'Unknown',
DATE '1900-01-01', -- open history until we know better
DATE '9999-12-31',
'Y',
'Y', -- mark as inferred
CURRENT_TIMESTAMP
FROM (
SELECT DISTINCT customer_nk
FROM staging_orders
WHERE customer_nk IS NOT NULL
) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dim_customer AS d
WHERE d.customer_nk = s.customer_nk
);
-- 2) Load facts using the (real or inferred) current SK
INSERT INTO fact_sales (
order_id,
line_id,
order_date_sk,
customer_sk,
product_sk,
sales_amt,
qty,
batch_id
)
SELECT
o.order_id,
o.line_id,
dd.date_sk,
dc.customer_sk, -- inferred SK if the profile was late
dp.product_sk,
o.sales_amt,
o.qty,
:batch_id
FROM staging_orders AS o
JOIN dim_date AS dd
ON dd.full_date = o.order_date
JOIN dim_customer AS dc
ON dc.customer_nk = o.customer_nk
AND dc.current_flag = 'Y'
JOIN dim_product AS dp
ON dp.product_nk = o.product_nk
AND dp.current_flag = 'Y';
-- 3) Later: real customer file arrives โ fill inferred row in place
UPDATE dim_customer AS d
SET
customer_name = s.customer_name,
city = s.city,
segment = s.segment,
inferred_flag = 'N',
update_dts = CURRENT_TIMESTAMP
FROM staging_customer AS s
WHERE d.customer_nk = s.customer_nk
AND d.inferred_flag = 'Y'
AND d.current_flag = 'Y';
What this code does
- Distinct customer keys from orders are checked against
dim_customer. - Missing keys become inferred members with unknown attributes and
inferred_flag = 'Y'. - Fact load can now always resolve a
customer_sk. - When the real customer feed arrives, the inferred row is filled in place.
- Old facts keep the same surrogate key, so you do not rewrite the fact table.
Trap
Leaving customer_sk null and fixing it "in the dashboard." Inner joins will hide those orders.
Q-DW-060 Daily inventory snapshot load
Answer
A periodic snapshot is one row per product, warehouse, and snapshot date. Delete-and-insert that date, then write today's on-hand balances. Do not sum these balances across days.
Explanation
The warehouse needs "how much stock did we hold each day," not every stock movement. Production snapshot jobs must be rerunnable for one date. If 15 March is reloaded, only 15 March is replaced.
Code
-- grain: product_sk + warehouse_sk + snapshot_date
-- rerun-safe: replace one snapshot day, then insert
DELETE FROM fact_inventory_daily
WHERE snapshot_date = DATE '2026-03-15';
INSERT INTO fact_inventory_daily (
snapshot_date,
product_sk,
warehouse_sk,
on_hand_qty, -- semi-additive: do not sum across dates
reserved_qty,
batch_id,
load_dts
)
SELECT
DATE '2026-03-15' AS snapshot_date,
dp.product_sk,
dw.warehouse_sk,
src.on_hand_qty,
src.reserved_qty,
:batch_id,
CURRENT_TIMESTAMP
FROM staging_inventory_balance AS src
JOIN dim_product AS dp
ON dp.product_nk = src.product_nk
AND dp.current_flag = 'Y'
JOIN dim_warehouse AS dw
ON dw.warehouse_nk = src.warehouse_nk
AND dw.current_flag = 'Y';
What this code does
- Deletes any previous snapshot for 15 March so a rerun does not duplicate.
- Reads the source on-hand picture for that day.
- Maps product and warehouse natural keys to surrogate keys.
- Inserts one row per product and warehouse for that date.
- Keeps
on_hand_qtyas a picture, not as a movement.
Trap
Appending snapshots without deleting the same date first. A retry doubles stock.
Q-DW-061 Explode a household bridge without double-counting sales
Answer
Store a group key on the fact. Explode the bridge to members. Multiply the measure by allocation_pct. Weights for one group must sum to 1.0.
Explanation
Two customers share one order. If you join both customers with no weight, revenue doubles. Production bridges always carry a weight. Test that each household_sk sums to 1.
Code
-- fact_sales grain: order line, with household_sk
-- bridge_household_customer: household_sk, customer_sk, allocation_pct
-- 1) Allocated sales by customer (weights prevent double count)
SELECT
dc.customer_nk,
dc.customer_name,
SUM(fs.sales_amt * b.allocation_pct) AS allocated_sales
FROM fact_sales AS fs
JOIN bridge_household_customer AS b
ON b.household_sk = fs.household_sk
JOIN dim_customer AS dc
ON dc.customer_sk = b.customer_sk
GROUP BY
dc.customer_nk,
dc.customer_name;
-- 2) Quality check: each household's weights must sum to 1
SELECT
household_sk,
SUM(allocation_pct) AS weight_sum,
COUNT(*) AS member_cnt
FROM bridge_household_customer
GROUP BY household_sk
HAVING ROUND(SUM(allocation_pct), 4) <> 1.0000;
What this code does
- The fact stays at order-line grain with one household key.
- The bridge lists every customer in that household plus a share.
- Multiplying
sales_amt * allocation_pctsplits the order without inflating it. - The second query finds households whose shares do not add to 1.
- Those bad groups would silently create or lose revenue.
Trap
Joining the bridge and summing sales_amt with no weight. Shared orders count twice.
Q-DW-062 Semi-additive inventory โ last day and average, never sum across time
Answer
Inventory quantity adds across warehouses on one day. It does not add across days. Use the last snapshot for "stock now" and AVG for "average stock." Never SUM(on_hand_qty) over a month.
Explanation
100 units sitting for 30 days is still 100 units, not 3,000. Production inventory dashboards should default to last-day balance. Average on-hand is the right monthly KPI.
Code
-- last snapshot day in March, summed across warehouses (this SUM is ok)
WITH last_day AS (
SELECT MAX(snapshot_date) AS snapshot_date
FROM fact_inventory_daily
WHERE snapshot_date BETWEEN DATE '2026-03-01' AND DATE '2026-03-31'
)
SELECT
dp.category,
SUM(f.on_hand_qty) AS month_end_on_hand
FROM fact_inventory_daily AS f
JOIN last_day AS d
ON d.snapshot_date = f.snapshot_date
JOIN dim_product AS dp
ON dp.product_sk = f.product_sk
GROUP BY dp.category;
-- average daily on-hand by warehouse (AVG across days, SUM across products)
SELECT
f.warehouse_sk,
AVG(daily_qty) AS avg_daily_on_hand
FROM (
SELECT
warehouse_sk,
snapshot_date,
SUM(on_hand_qty) AS daily_qty -- additive across products on one day
FROM fact_inventory_daily
WHERE snapshot_date BETWEEN DATE '2026-03-01' AND DATE '2026-03-31'
GROUP BY warehouse_sk, snapshot_date
) AS f
GROUP BY f.warehouse_sk;
What this code does
- The first query finds the last snapshot date in March.
- It sums on-hand only for that one day, then by category.
- The inner part of the second query sums products for each warehouse-day.
- The outer query averages those daily totals across March.
- Neither query sums the same stock across 31 days.
Trap
SUM(on_hand_qty) for the whole month. That is the classic semi-additive mistake.
Q-DW-063 Funnel from an event fact
Answer
Keep one fact at event grain. Count distinct sessions (or customers) at each step. Divide each step by the first step to get conversion. Do not sum a dummy amount and call it a funnel.
Explanation
A shop funnel is view โ cart โ order. Production funnels need a stable entity (session_id or customer_nk) and event timestamps. Count people or sessions, not event rows, or one chatty session looks like many conversions.
Code
-- fact_web_event grain: one row per event
-- event_name in ('view', 'cart', 'order')
WITH step_counts AS (
SELECT
COUNT(DISTINCT CASE WHEN event_name = 'view' THEN session_id END) AS views,
COUNT(DISTINCT CASE WHEN event_name = 'cart' THEN session_id END) AS carts,
COUNT(DISTINCT CASE WHEN event_name = 'order' THEN session_id END) AS orders
FROM fact_web_event
WHERE event_date BETWEEN DATE '2026-03-01' AND DATE '2026-03-31'
)
SELECT
views,
carts,
orders,
ROUND(100.0 * carts / NULLIF(views, 0), 2) AS view_to_cart_pct,
ROUND(100.0 * orders / NULLIF(views, 0), 2) AS view_to_order_pct,
ROUND(100.0 * orders / NULLIF(carts, 0), 2) AS cart_to_order_pct
FROM step_counts;
What this code does
- It scans the event fact for March.
- It counts distinct sessions that viewed, added to cart, or ordered.
- Conversion rates divide later steps by earlier steps.
NULLIFstops divide-by-zero when a step is empty.- One session with five page views still counts as one view.
Trap
Using COUNT(*) per event name. Repeat views inflate the top of the funnel.
Q-DW-064 Rolling 12-month sales
Answer
Aggregate to month first. Then use a window SUM with ROWS BETWEEN 11 PRECEDING AND CURRENT ROW. That is 12 months including the current month. Do this on monthly totals, not on raw order lines.
Explanation
Finance asks "trailing twelve months revenue" every month. Production TTM should use a month grain table or a gold aggregate. A 12-month window on daily facts will mix day rows unless you aggregate first.
Code
-- 1) month grain
WITH monthly AS (
SELECT
dd.month_start_date,
SUM(fs.sales_amt) AS month_sales
FROM fact_sales AS fs
JOIN dim_date AS dd
ON dd.date_sk = fs.order_date_sk
GROUP BY dd.month_start_date
)
-- 2) rolling 12 months including current month
SELECT
month_start_date,
month_sales,
SUM(month_sales) OVER (
ORDER BY month_start_date
ROWS BETWEEN 11 PRECEDING AND CURRENT ROW
) AS rolling_12m_sales
FROM monthly
ORDER BY month_start_date;
What this code does
- Sales are summed to one row per month.
- Months are ordered by
month_start_date. - The window looks at the current month and the 11 months before it.
- Each output row is trailing-twelve-month revenue.
- Early months with fewer than 12 history rows will have a partial window unless you filter them out.
Trap
Using RANGE BETWEEN INTERVAL 12 MONTH on timestamps without aggregating. Duplicate days and missing months both break TTM.
Q-DW-065 Delete+insert incremental load vs MERGE
Answer
Delete+insert replaces a whole slice, usually one date partition. MERGE upserts by business key and can also delete missing keys. Use delete+insert for rerunnable daily facts. Use MERGE when you must apply mixed inserts, updates, and deletes in one table.
Explanation
Yesterday's orders often arrive as a full replacement file for that date. Replacing the date slice is simpler than matching every line. CDC customer changes fit MERGE better. Production choice is about the source shape, not fashion.
Code
-- A) Delete+insert for one fact date (idempotent day reload)
DELETE FROM fact_sales
WHERE order_date_sk = 20260315;
INSERT INTO fact_sales (
order_id, line_id, order_date_sk, customer_sk, product_sk,
sales_amt, qty, batch_id, load_dts
)
SELECT
o.order_id,
o.line_id,
20260315,
dc.customer_sk,
dp.product_sk,
o.sales_amt,
o.qty,
:batch_id,
CURRENT_TIMESTAMP
FROM staging_orders AS o
JOIN dim_customer AS dc
ON dc.customer_nk = o.customer_nk
AND dc.current_flag = 'Y'
JOIN dim_product AS dp
ON dp.product_nk = o.product_nk
AND dp.current_flag = 'Y'
WHERE o.order_date = DATE '2026-03-15';
-- B) MERGE when the source is a change set, not a full day picture
MERGE INTO fact_sales AS t
USING staging_order_changes AS s
ON t.order_id = s.order_id
AND t.line_id = s.line_id
WHEN MATCHED AND s.change_type = 'D' THEN DELETE
WHEN MATCHED AND s.change_type IN ('U', 'I') THEN UPDATE SET
t.sales_amt = s.sales_amt,
t.qty = s.qty,
t.batch_id = :batch_id,
t.load_dts = CURRENT_TIMESTAMP
WHEN NOT MATCHED AND s.change_type IN ('I', 'U') THEN INSERT (
order_id, line_id, order_date_sk, customer_sk, product_sk,
sales_amt, qty, batch_id, load_dts
) VALUES (
s.order_id, s.line_id, s.order_date_sk, s.customer_sk, s.product_sk,
s.sales_amt, s.qty, :batch_id, CURRENT_TIMESTAMP
);
What this code does
- Pattern A deletes all sales for 15 March, then inserts that day again.
- Running pattern A twice still leaves one copy of 15 March.
- Pattern B matches each order line and applies insert, update, or delete.
- MERGE is the CDC-style path. Delete+insert is the snapshot-slice path.
- Both can be idempotent if the match key or the replaced slice is complete.
Trap
Appending a daily file with INSERT only, then using MERGE "sometimes." The second run duplicates the day.
Q-DW-066 Point-in-time SCD2 lookup for a late arriving fact
Answer
Do not join late facts to current_flag = 'Y'. Join the dimension version whose valid_from / valid_to range contains the fact's event date. That restores the customer who was true at order time.
Explanation
An order from 12 Jan arrives on 20 Jan. The customer may have moved on 15 Jan. The sale still belongs to the 12 Jan version. Production as-of joins are required for late facts against Type 2 dimensions.
Code
INSERT INTO fact_sales (
order_id, line_id, order_date_sk, customer_sk, product_sk,
sales_amt, qty, batch_id, load_dts
)
SELECT
o.order_id,
o.line_id,
dd.date_sk,
dc.customer_sk, -- version valid on the order date
dp.product_sk,
o.sales_amt,
o.qty,
:batch_id,
CURRENT_TIMESTAMP
FROM staging_late_orders AS o
JOIN dim_date AS dd
ON dd.full_date = o.order_date
JOIN dim_customer AS dc
ON dc.customer_nk = o.customer_nk
AND o.order_date >= dc.valid_from
AND o.order_date < dc.valid_to -- half-open range, no overlap
JOIN dim_product AS dp
ON dp.product_nk = o.product_nk
AND o.order_date >= dp.valid_from
AND o.order_date < dp.valid_to;
What this code does
- Late orders keep their real
order_date, not the load date. - Customer and product are joined on natural key plus date range.
- Half-open ranges (
>= fromand< to) stop a row from matching two versions. - The fact stores those historical surrogate keys.
- A customer who moved after the order does not steal the old sale.
Trap
AND dc.current_flag = 'Y' on a late fact load. That is today's customer, not the order-time customer.
Q-DW-067 SCD Type 1 MERGE
Answer
Match on the natural key and overwrite attributes. Insert when the key is new. There is no new surrogate and no date range. Use this for corrections and for attributes with no history need.
Explanation
Email and phone often Type 1. Yesterday's typo should disappear. Production Type 1 still needs a surrogate for the fact join, but that surrogate stays stable.
Code
MERGE INTO dim_customer AS t
USING staging_customer AS s
ON t.customer_nk = s.customer_nk
AND t.current_flag = 'Y' -- Type 1 table usually has one row per nk
WHEN MATCHED THEN UPDATE SET
t.customer_name = s.customer_name,
t.email = s.email,
t.phone = s.phone,
t.update_dts = CURRENT_TIMESTAMP
WHEN NOT MATCHED THEN INSERT (
customer_sk,
customer_nk,
customer_name,
email,
phone,
valid_from,
valid_to,
current_flag,
inferred_flag,
load_dts
) VALUES (
NEXT VALUE FOR seq_customer_sk,
s.customer_nk,
s.customer_name,
s.email,
s.phone,
DATE '1900-01-01',
DATE '9999-12-31',
'Y',
'N',
CURRENT_TIMESTAMP
);
What this code does
- Each staging customer is matched on
customer_nk. - Existing rows are overwritten. Old email is gone.
- New natural keys get a new surrogate and one current row.
- Facts that already stored that surrogate now see the new email.
- There is no close-and-insert history step.
Trap
Type 1 merging on customer_sk. Incoming files have the business key, not the warehouse SK.
Q-DW-068 Accumulating snapshot โ update pipeline dates on the same order row
Answer
One row per order is inserted when the order is placed. Later steps update ship and delivery date keys on that same row. Lag days are derived from those dates.
Explanation
Ops wants "orders placed but not shipped" without assembling many transaction facts. Production accumulating snapshots are update-heavy. Index the order natural key.
Code
-- grain: one row per order_id (header pipeline, not line items)
MERGE INTO fact_order_pipeline AS t
USING (
SELECT
s.order_id,
s.customer_sk,
od.date_sk AS order_date_sk,
od.full_date AS order_date,
sd.date_sk AS ship_date_sk, -- null until the order ships
sd.full_date AS ship_date,
ddv.date_sk AS delivery_date_sk, -- null until the order is delivered
s.current_status,
s.sales_amt
FROM staging_order_status AS s
JOIN dim_date AS od
ON od.full_date = s.order_date -- role: order date
LEFT JOIN dim_date AS sd
ON sd.full_date = s.ship_date -- role: ship date
LEFT JOIN dim_date AS ddv
ON ddv.full_date = s.delivery_date -- role: delivery date
) AS src
ON t.order_id = src.order_id
WHEN MATCHED THEN UPDATE SET
t.ship_date_sk = COALESCE(src.ship_date_sk, t.ship_date_sk),
t.delivery_date_sk = COALESCE(src.delivery_date_sk, t.delivery_date_sk),
t.current_status = src.current_status,
t.lag_order_to_ship_days = CASE
WHEN src.ship_date IS NULL THEN t.lag_order_to_ship_days
ELSE DATE_DIFF('day', src.order_date, src.ship_date)
END,
t.update_dts = CURRENT_TIMESTAMP
WHEN NOT MATCHED THEN INSERT (
order_id,
customer_sk,
order_date_sk,
ship_date_sk,
delivery_date_sk,
current_status,
sales_amt,
load_dts
) VALUES (
src.order_id,
src.customer_sk,
src.order_date_sk,
src.ship_date_sk,
src.delivery_date_sk,
src.current_status,
src.sales_amt,
CURRENT_TIMESTAMP
);
What this code does
- The pipeline fact is keyed by
order_id, not by order line. - A new order inserts a row with order date and null later dates.
- When shipping happens, the same row gets
ship_date_sk. - Lag days are recomputed from the date dimension values.
- Status dashboards read one row per order instead of stitching events.
Trap
Inserting a new fact row for every status change. Then "one order" is no longer one row, and the accumulating grain is broken.
Q-DW-069 Factless fact โ products on promotion with no sales
Answer
A coverage fact records "this product was on promo that day" with no amount. Left join sales at the same grain. Rows with null sales are promo coverage that did not convert.
Explanation
Marketing needs "on promo but did not sell," which sales facts alone cannot answer. Production coverage facts still have grain: product, store, date.
Code
-- fact_promo_coverage grain: product_sk + store_sk + promo_date_sk
-- no measure; the row is the event
SELECT
dp.product_nk,
dp.product_name,
ds.store_nk,
dd.full_date,
COALESCE(SUM(fs.sales_amt), 0) AS sales_amt
FROM fact_promo_coverage AS pc
JOIN dim_product AS dp
ON dp.product_sk = pc.product_sk
JOIN dim_store AS ds
ON ds.store_sk = pc.store_sk
JOIN dim_date AS dd
ON dd.date_sk = pc.promo_date_sk
LEFT JOIN fact_sales AS fs
ON fs.product_sk = pc.product_sk
AND fs.store_sk = pc.store_sk
AND fs.order_date_sk = pc.promo_date_sk
WHERE dd.full_date BETWEEN DATE '2026-03-01' AND DATE '2026-03-31'
GROUP BY
dp.product_nk,
dp.product_name,
ds.store_nk,
dd.full_date
HAVING COALESCE(SUM(fs.sales_amt), 0) = 0;
What this code does
- Every promo coverage row is a product-store-day on promotion.
- Sales are left-joined at that same grain.
COALESCEturns no sales into zero.HAVING ... = 0keeps promo days that did not sell.- There is no fake revenue on the coverage fact.
Trap
Inner-joining sales to coverage. Then "did not sell" rows disappear.
Q-DW-070 Role-playing dates โ ordered vs shipped in different months
Answer
Alias the date dimension twice. Join order_date_sk to one alias and ship_date_sk to the other. Filter each role on its own month. Do not reuse one date join for both roles.
Explanation
An order can be placed in March and shipped in April. Finance and logistics disagree if you have only one date. Production models keep both keys on the fact.
Code
SELECT
od.calendar_month AS order_month,
sd.calendar_month AS ship_month,
COUNT(DISTINCT fs.order_id) AS orders,
SUM(fs.sales_amt) AS sales_amt
FROM fact_sales AS fs
JOIN dim_date AS od
ON od.date_sk = fs.order_date_sk -- role: ordered
JOIN dim_date AS sd
ON sd.date_sk = fs.ship_date_sk -- role: shipped
WHERE od.calendar_year = 2026
AND od.calendar_month = 3 -- ordered in March
AND sd.calendar_month = 4 -- shipped in April
GROUP BY
od.calendar_month,
sd.calendar_month;
What this code does
odis the order-date role ofdim_date.sdis the ship-date role of the same table.- The fact holds two different date surrogate keys.
- Filters can ask for March orders that shipped in April.
- One physical calendar supports both roles.
Trap
Joining dim_date once and using it as both order date and ship date. Those dates are not the same.
Q-DW-071 Apply CDC insert / update / delete to a Type 1 customer dimension
Answer
CDC events carry an op code: insert, update, delete. MERGE applies each op on the natural key. Deletes should be real deletes or a durable is_deleted flag. Do not ignore deletes.
Explanation
CRM deletes happen. A warehouse that only upserts will keep ghost customers. Production CDC needs event order, a primary key, and a rule for deletes. Soft delete is safer when old facts still point at the SK.
Code
-- staging_customer_cdc: customer_nk, attrs, op_code in ('I','U','D'), event_ts
-- keep latest event per natural key for this batch
WITH latest AS (
SELECT *
FROM (
SELECT
c.*,
ROW_NUMBER() OVER (
PARTITION BY customer_nk
ORDER BY event_ts DESC
) AS rn
FROM staging_customer_cdc AS c
) AS x
WHERE rn = 1
)
MERGE INTO dim_customer AS t
USING latest AS s
ON t.customer_nk = s.customer_nk
AND t.current_flag = 'Y'
WHEN MATCHED AND s.op_code = 'D' THEN UPDATE SET
t.is_deleted = 'Y',
t.current_flag = 'N',
t.valid_to = CURRENT_DATE,
t.update_dts = CURRENT_TIMESTAMP
WHEN MATCHED AND s.op_code IN ('I', 'U') THEN UPDATE SET
t.customer_name = s.customer_name,
t.city = s.city,
t.segment = s.segment,
t.is_deleted = 'N',
t.update_dts = CURRENT_TIMESTAMP
WHEN NOT MATCHED AND s.op_code IN ('I', 'U') THEN INSERT (
customer_sk, customer_nk, customer_name, city, segment,
valid_from, valid_to, current_flag, is_deleted, load_dts
) VALUES (
NEXT VALUE FOR seq_customer_sk,
s.customer_nk, s.customer_name, s.city, s.segment,
CURRENT_DATE, DATE '9999-12-31', 'Y', 'N', CURRENT_TIMESTAMP
);
What this code does
- Several CDC events for one customer are reduced to the latest event.
- MERGE matches the live dimension row on natural key.
- Delete ops close the row and set
is_deleted. - Insert/update ops overwrite Type 1 attributes.
- New keys insert a current row. Delete events for unknown keys do nothing.
Trap
Applying CDC in random order. An old update can overwrite a newer delete.
Q-DW-072 Watermark incremental extract
Answer
Read the last successful watermark. Pull source rows greater than that mark, with a lookback for late updates. Load them, then advance the watermark only after commit.
Explanation
Orders have src_updated_at. A watermark of last night's max timestamp is the next starting point. Production watermarks are per table. Late rows need a lookback window, not a naive > last_ts.
Code
-- etl_watermark(source_name, last_ts, last_batch_id)
WITH wm AS (
SELECT last_ts
FROM etl_watermark
WHERE source_name = 'erp.orders'
),
extract_slice AS (
SELECT o.*
FROM erp.orders AS o
CROSS JOIN wm
WHERE o.src_updated_at >= wm.last_ts - INTERVAL '2' HOUR -- lookback
AND o.src_updated_at < CURRENT_TIMESTAMP
)
INSERT INTO staging_orders
SELECT * FROM extract_slice;
-- after fact/dim load succeeds:
UPDATE etl_watermark
SET
last_ts = (
SELECT COALESCE(MAX(src_updated_at), last_ts)
FROM staging_orders
),
last_batch_id = :batch_id,
updated_at = CURRENT_TIMESTAMP
WHERE source_name = 'erp.orders';
What this code does
- The job reads the last committed timestamp for
erp.orders. - It extracts rows from two hours before that mark up to now.
- Those rows land in staging, then the normal incremental load.
- Only after success does the watermark move to the new max timestamp.
- The lookback recaptures late-updated orders that would be skipped by a strict
> last_ts.
Trap
Setting last_ts = CURRENT_TIMESTAMP before the load finishes. A crash skips rows forever.
Q-DW-073 Sales by fiscal year using the date dimension
Answer
Join fact_sales to dim_date on order_date_sk. Group by fiscal_year and fiscal_quarter, not by YEAR(order_date). Fiscal calendars are attributes, not SQL date functions.
Explanation
A retailer fiscal year may start in February. YEAR(order_date) would still say 2026 in January while finance says FY2025. Production reports must use the calendar table finance signed off.
Code
-- fiscal attributes live on dim_date; facts only store order_date_sk
SELECT
dd.fiscal_year,
dd.fiscal_quarter,
SUM(fs.sales_amt) AS sales_amt, -- additive
SUM(fs.qty) AS qty
FROM fact_sales AS fs
JOIN dim_date AS dd
ON dd.date_sk = fs.order_date_sk -- order-date role
WHERE dd.fiscal_year = 2026 -- finance year, not YEAR(order_date)
GROUP BY
dd.fiscal_year,
dd.fiscal_quarter
ORDER BY
dd.fiscal_year,
dd.fiscal_quarter;
What this code does
- Each sale already has an order date surrogate key.
- Fiscal year and quarter come from
dim_date, not fromYEAR(). - Filters use
fiscal_year = 2026as finance defines it. - Additive facts are summed by fiscal quarter.
- Changing a 4-4-5 rule means rebuilding
dim_date, not rewriting every query.
Trap
GROUP BY YEAR(order_date), QUARTER(order_date) in a company with a non-calendar fiscal year.
Q-DW-074 Unknown member โ map null product keys to SK -1
Answer
Seed dim_product with product_sk = -1 named Unknown. When the source product id is null or unmatched, attach -1. Do not leave null foreign keys on the fact.
Explanation
A broken order line with no SKU should still count in revenue. Production unknown rows are created once per dimension, not per load.
Code
-- seed once
INSERT INTO dim_product (
product_sk, product_nk, product_name, category,
valid_from, valid_to, current_flag
)
SELECT
-1, 'UNKNOWN', 'Unknown product', 'Unknown',
DATE '1900-01-01', DATE '9999-12-31', 'Y'
WHERE NOT EXISTS (
SELECT 1 FROM dim_product WHERE product_sk = -1
);
INSERT INTO fact_sales (
order_id, line_id, order_date_sk, customer_sk, product_sk,
sales_amt, qty, batch_id
)
SELECT
o.order_id,
o.line_id,
dd.date_sk,
dc.customer_sk,
COALESCE(dp.product_sk, -1) AS product_sk, -- dummy unknown
o.sales_amt,
o.qty,
:batch_id
FROM staging_orders AS o
JOIN dim_date AS dd
ON dd.full_date = o.order_date
JOIN dim_customer AS dc
ON dc.customer_nk = o.customer_nk
AND dc.current_flag = 'Y'
LEFT JOIN dim_product AS dp
ON dp.product_nk = o.product_nk
AND dp.current_flag = 'Y';
What this code does
- The unknown product member exists with
product_sk = -1. - Orders try a real product join first.
- Unmatched or null product ids become
-1throughCOALESCE. - The fact always has a product key, so inner-join reports keep the sale.
- Unknown revenue is visible instead of missing.
Trap
Using INNER JOIN dim_product and dropping unmatched SKUs. Totals silently shrink.
Q-DW-075 Idempotent MERGE on order-line grain
Answer
MERGE on the grain key (order_id, line_id). Matched rows update and new rows insert. A second run of the same file does not create extra lines.
Explanation
Some sources send the same order line again with a corrected amount. Delete+insert of a whole day also works, but MERGE is better when the file is only a subset of the day. Production MERGE still needs the full change for that key. Partial columns will overwrite with nulls if you set every column blindly.
Code
-- upsert on the fact grain so a replay cannot insert a second line
MERGE INTO fact_sales AS t
USING (
SELECT
o.order_id,
o.line_id,
dd.date_sk AS order_date_sk,
dc.customer_sk,
dp.product_sk,
o.sales_amt,
o.qty
FROM staging_orders AS o
JOIN dim_date AS dd
ON dd.full_date = o.order_date
JOIN dim_customer AS dc
ON dc.customer_nk = o.customer_nk
AND dc.current_flag = 'Y'
JOIN dim_product AS dp
ON dp.product_nk = o.product_nk
AND dp.current_flag = 'Y'
) AS s
ON t.order_id = s.order_id -- grain part 1
AND t.line_id = s.line_id -- grain part 2
WHEN MATCHED THEN UPDATE SET
t.order_date_sk = s.order_date_sk,
t.customer_sk = s.customer_sk,
t.product_sk = s.product_sk,
t.sales_amt = s.sales_amt, -- corrections overwrite the same grain
t.qty = s.qty,
t.load_dts = CURRENT_TIMESTAMP
WHEN NOT MATCHED THEN INSERT (
order_id, line_id, order_date_sk, customer_sk, product_sk,
sales_amt, qty, load_dts
) VALUES (
s.order_id, s.line_id, s.order_date_sk, s.customer_sk, s.product_sk,
s.sales_amt, s.qty, CURRENT_TIMESTAMP
);
What this code does
- Staging order lines are mapped to surrogate keys.
- MERGE matches the fact on the grain
(order_id, line_id). - Existing grain keys are updated in place.
- New grain keys are inserted.
- Replaying the same staging file does not create a second line.
Trap
MERGEing only on order_id. Multi-line orders collapse or overwrite each other.
Q-DW-076 Decode a junk dimension of order flags
Answer
Build a junk dimension of flag combinations. Facts store one junk_sk. Reports group by the decoded flags, not by three tiny dimensions.
Explanation
is_gift, channel, and priority have few values. The combination table stays small. Production junk dimensions are generated from distinct combinations in staging, plus a default unknown combo.
Code
-- build / reuse junk combos
INSERT INTO dim_order_junk (
junk_sk, is_gift, channel, priority
)
SELECT
NEXT VALUE FOR seq_junk_sk,
s.is_gift,
s.channel,
s.priority
FROM (
SELECT DISTINCT is_gift, channel, priority
FROM staging_orders
) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dim_order_junk AS j
WHERE j.is_gift = s.is_gift
AND j.channel = s.channel
AND j.priority = s.priority
);
-- report: gift vs not gift by channel
SELECT
j.channel,
j.is_gift,
SUM(fs.sales_amt) AS sales_amt
FROM fact_sales AS fs
JOIN dim_order_junk AS j
ON j.junk_sk = fs.junk_sk
GROUP BY
j.channel,
j.is_gift;
What this code does
- Distinct flag combinations from orders are inserted if missing.
- Each combination gets one
junk_sk. - The fact already stored that single key at load time.
- The report groups by channel and gift flag from the junk dimension.
- The fact table does not hold three extra flag columns or three extra FKs.
Trap
Putting free-text notes into the junk dimension. Cardinality explodes and it stops being junk.
Q-DW-077 Header vs line grain โ do not mix them
Answer
If the grain is order line, store line measures on the line fact. If you need header freight, keep it on a header fact or allocate it. Never repeat header freight on every line.
Explanation
Order 88312 has 3 lines and 50 freight. If each line stores 50 freight, freight becomes 150. Production models either allocate freight by line share or keep a separate fact_order_header.
Code
-- wrong: freight on every line will triple
-- right option 1 โ allocate freight by line share of merchandise
WITH lines AS (
SELECT
order_id,
line_id,
sales_amt,
SUM(sales_amt) OVER (PARTITION BY order_id) AS order_sales
FROM staging_order_lines
)
INSERT INTO fact_sales (
order_id, line_id, sales_amt, freight_amt
)
SELECT
l.order_id,
l.line_id,
l.sales_amt,
h.freight_amt * (l.sales_amt / NULLIF(l.order_sales, 0)) AS freight_amt
FROM lines AS l
JOIN staging_order_header AS h
ON h.order_id = l.order_id;
-- right option 2 โ keep a header fact at order grain
SELECT
order_id,
SUM(sales_amt) AS merchandise_amt
FROM fact_sales
GROUP BY order_id;
SELECT
order_id,
freight_amt
FROM fact_order_header;
What this code does
- Line merchandise stays at line grain.
- Option 1 splits header freight across lines by sales share.
- Those allocated freight amounts sum back to the header freight.
- Option 2 keeps freight only on an order-header fact.
- Both options avoid repeating 50 freight on each of three lines.
Trap
Joining header freight onto line grain and summing freight in a line report. Freight is multiplied by line count.
No questions match. Clear search or pick All.