EVIKA ACADEMY · DATA ANALYTICS SERIES · CHAPTER 89

Data Warehouse Fundamentals for Data Analysts

OLTP vs OLAP, star and snowflake schemas, fact and dimension tables, slowly changing dimensions, partitioning, and how to query BigQuery, Redshift, and Snowflake efficiently.

⏱ 21 min read🏗 Data Warehouse · BigQuery · SQL
← Ch 88: A/B Testing Deep DiveCh 90: dbt for Data Analysts →

Most analysts learn SQL on transactional databases — MySQL or PostgreSQL tables designed to record individual events quickly. Data warehouses are built on completely different principles, optimised not for fast writes but for fast reads across enormous datasets. Understanding those principles helps you write better queries, interpret explain plans, and have more informed conversations with the data engineers who build the infrastructure you depend on.

This chapter covers the conceptual layer (OLTP vs OLAP, schemas, dimensions) and the practical layer (how to write efficient warehouse queries, what partitioning and clustering mean for your work, and how the major cloud warehouses differ).

OLTP vs OLAP: Two Different Optimisation Goals

OLTP (Transactional)OLAP (Analytical)
PurposeRecord individual transactionsAggregate and analyse history
QueriesINSERT/UPDATE/DELETE; small SELECTsLarge SELECTs across many rows
Row count per queryTypically 1–100 rowsMillions to billions of rows
Schema designNormalised (3NF) — minimise duplicationDenormalised — minimise joins at query time
Data freshnessReal-time (milliseconds)Near-real-time to daily batch
ExamplesMySQL, PostgreSQL, OracleBigQuery, Snowflake, Redshift, ClickHouse
Who queries itApplication codeAnalysts, BI tools, data scientists

Star Schema: The Analyst's Data Model

The star schema is the dominant modelling pattern in analytical data warehouses. One central fact table records events or measurements — each row is one transaction, one click, one lesson completion. Surrounding dimension tables describe the who, what, when, and where of those events.

FACT TABLE: fct_orders
dim_customersdim_productsdim_date
fct_orders columns: order_id (PK), customer_key (FK), product_key (FK), date_key (FK), quantity, unit_price, discount_pct, line_revenue, cost_price, margin
-- Star schema DDL (BigQuery / PostgreSQL syntax)

-- Dimension: customers
CREATE TABLE dim_customers (
    customer_key    INT64   PRIMARY KEY,   -- surrogate key
    customer_id     STRING,               -- natural key from source
    customer_name   STRING,
    email           STRING,
    region          STRING,
    city            STRING,
    segment         STRING,               -- B2B / B2C / Enterprise
    first_order_date DATE,
    is_active       BOOL,
    -- SCD Type 2 fields (see next section)
    effective_from  DATE,
    effective_to    DATE,
    is_current      BOOL
);

-- Dimension: date (pre-generated, 1 row per calendar day)
CREATE TABLE dim_date (
    date_key        INT64   PRIMARY KEY,  -- YYYYMMDD integer
    calendar_date   DATE,
    year            INT64,
    quarter         INT64,
    month           INT64,
    month_name      STRING,
    week_of_year    INT64,
    day_of_week     STRING,
    is_weekend      BOOL,
    is_holiday      BOOL
);

-- Fact table: one row per order line item
CREATE TABLE fct_orders (
    order_line_key  INT64   PRIMARY KEY,
    order_id        STRING,
    customer_key    INT64   REFERENCES dim_customers(customer_key),
    product_key     INT64   REFERENCES dim_products(product_key),
    date_key        INT64   REFERENCES dim_date(date_key),
    quantity        INT64,
    unit_price      NUMERIC,
    discount_pct    NUMERIC,
    line_revenue    NUMERIC,   -- pre-calculated: quantity * unit_price * (1-discount)
    cost_price      NUMERIC,
    margin          NUMERIC    -- pre-calculated: line_revenue - cost_price
);

Snowflake schema: when to normalise dimensions

A snowflake schema further normalises dimension tables — for example, splitting dim_customers into dim_customers + dim_regions. This reduces storage but increases query complexity (more joins). In modern cloud warehouses where storage is cheap and compute is the bottleneck, star schemas are generally preferred over snowflake schemas for most analytical workloads.

Fact Table Types

Transaction fact
When: One row per event that happened at a point in time
Example: fct_orders, fct_page_views, fct_payments
Most common. Grain is the individual transaction.
Snapshot fact
When: One row per entity per time period — captures state over time
Example: fct_daily_account_balances, fct_weekly_inventory
Good for slowly changing values. Can be large — multiply entities × periods.
Accumulating snapshot
When: One row per pipeline instance, updated as it progresses through stages
Example: fct_lead_lifecycle (row updated as lead → MQL → SQL → closed)
Useful for funnel analysis. Rows are mutable — ETL updates them, not inserts.

Slowly Changing Dimensions (SCD)

Dimension attributes change over time — a customer moves cities, a product changes categories, a sales rep changes territory. How you handle these changes in the warehouse determines whether you can answer historical questions accurately.

SCD Type 1 — Overwrite

Simply update the dimension row. Old value is lost. Use when history doesn't matter (e.g. correcting a typo in an email address).

UPDATE dim_customers
SET    email = 'new@example.com'
WHERE  customer_id = 'C1042';
SCD Type 2 — Add a new row

Expire the old row and insert a new one with new values. Preserves full history. The fact table's surrogate key always points to the version that was current at event time.

-- Expire the current row
UPDATE dim_customers
SET    effective_to = CURRENT_DATE - 1,
       is_current   = FALSE
WHERE  customer_id = 'C1042'
AND    is_current  = TRUE;

-- Insert new version
INSERT INTO dim_customers
  (customer_key, customer_id, region, city, ..., effective_from, effective_to, is_current)
VALUES
  (NEXT_KEY, 'C1042', 'South', 'Bengaluru', ..., CURRENT_DATE, '9999-12-31', TRUE);
SCD Type 3 — Add a column

Add a "previous_value" column. Simple but only tracks one change back — not full history. Use for cases where only the most recent change matters.

-- Add previous_region column to dim_customers
ALTER TABLE dim_customers ADD COLUMN previous_region STRING;

-- When region changes:
UPDATE dim_customers
SET    previous_region = region,
       region          = 'South'
WHERE  customer_id = 'C1042';

Partitioning and Clustering

In cloud warehouses, query cost and speed are dominated by how much data is scanned. Partitioning and clustering are the two main mechanisms for reducing scan volume.

-- BigQuery: partition by date column (most common pattern)
CREATE TABLE fct_orders
PARTITION BY DATE(order_date)
CLUSTER BY region, product_category
AS SELECT * FROM fct_orders_staging;

-- Queries that filter on order_date only scan the relevant partitions
-- Queries that also filter on region/product_category scan fewer files within partitions

-- Good query: uses partition + cluster pruning
SELECT region, SUM(line_revenue)
FROM   fct_orders
WHERE  order_date BETWEEN '2026-01-01' AND '2026-03-31'   -- partition filter
  AND  region IN ('North', 'East')                         -- cluster filter
GROUP  BY region;

-- Bad query: full table scan — no partition or cluster filter
SELECT *
FROM   fct_orders
WHERE  customer_name = 'Acme Corp';   -- not a partition or cluster column
-- → scans EVERY partition across ALL time

-- Check how many bytes a query will scan before running it:
-- BigQuery: use "dry run" — click "More > Query settings > Dry run" in console
-- or via CLI: bq query --dry_run --use_legacy_sql=false 'SELECT ...'

Cloud Warehouse Comparison

BigQuery (Google)SnowflakeRedshift (AWS)
Pricing modelPay per query (bytes scanned) or flat-rate slotsPay per compute (credits) + storage separatelyReserved nodes or Serverless (per byte)
ScalingFully serverless, automaticVirtual warehouses — scale up/down manually or autoManual cluster resize or Serverless
SQL dialectStandard SQL with BigQuery extensionsANSI SQL with Snowflake extensionsPostgreSQL-based
Best forTeams already in GCP; large ad-hoc queries; ML integrationMulti-cloud; data sharing; mature analytics teamsTeams in AWS ecosystem; tight AWS service integration
Analyst experienceConsole + notebooks + Looker Studio nativeWorksheets; works with any BI toolQuery editor; works with any BI tool

Query Optimisation Patterns for Warehouse SQL

-- 1. SELECT only what you need — never SELECT * in production warehouse queries
-- Bad:
SELECT * FROM fct_orders WHERE order_date = '2026-10-01';
-- Good:
SELECT order_id, customer_key, line_revenue FROM fct_orders WHERE order_date = '2026-10-01';

-- 2. Filter on partition/cluster columns FIRST in WHERE clause
-- 3. Avoid functions on partition columns — they defeat partition pruning
-- Bad: DATE_FORMAT(order_date, '%Y') = '2026'
-- Good: order_date BETWEEN '2026-01-01' AND '2026-12-31'

-- 4. Use CTEs for readability, not performance — they don't cache by default
-- In BigQuery, materialise expensive CTEs as temp tables if referenced multiple times:
CREATE TEMP TABLE tmp_active_orders AS
SELECT * FROM fct_orders WHERE order_date >= '2026-01-01' AND status = 'completed';

-- 5. JOIN dimension tables after filtering the fact table
SELECT c.region, SUM(o.line_revenue)
FROM (
    SELECT customer_key, line_revenue
    FROM   fct_orders
    WHERE  order_date BETWEEN '2026-01-01' AND '2026-06-30'  -- filter first
) o
JOIN dim_customers c ON o.customer_key = c.customer_key AND c.is_current = TRUE
GROUP BY c.region;

-- 6. APPROX functions for exploration (BigQuery)
SELECT APPROX_COUNT_DISTINCT(customer_key) AS approx_unique_customers
FROM   fct_orders
WHERE  order_date >= '2026-10-01';
-- ~2% error vs exact COUNT(DISTINCT) but scans far less data
← Ch 88: A/B Testing Deep DiveCh 90: dbt for Data Analysts →
Learn Data Engineering Fundamentals
EVIKA ACADEMY · Sector 51, Noida · Live classes online & offline
Book Free DemoWhatsApp Us
🎓 Free Demo Class — Online & Offline · Noida Sector 51