Dimensional Modeling

Quote

“The grain declaration becomes a binding contract on the design.”

Ralph Kimball, The Data Warehouse Toolkit (2013)

ER Diagram Legend — Relationship Connectors

erDiagram
    ONE_PARENT ||--o{ MANY_CHILDREN : "one-to-many"
    EXACTLY_ONE ||--|| EXACTLY_ONE_OTHER : "one-to-one"
    MANY_LEFT }o--o{ MANY_RIGHT : "many-to-many"
    LEGEND {
        int id PK
        int parent_id FK
        varchar column_name
    }

The Kimball Four-Step Dimensional Design Process

Every dimensional model begins with four decisions, made in order. Skip a step and the model collapses.

Step 1 — Select the Business Process

A business process is a measurable activity the organization performs. It is not a department or a report — it is the operational event that generates data.

How to identify business processes

Ask: “What does this team do every day that produces rows of data?” The answer is the business process.

For an index provider, the core business processes are:

#Business ProcessSource SystemFrequency
1Index valuation (calculating daily index levels)Calculation engineDaily
2Constituent weighting (determining stock weights at rebalance)Rebalancing systemQuarterly / semi-annual
3Corporate action processing (adjusting for splits, mergers, dividends)Corporate actions feedEvent-driven
4Pipeline execution monitoring (tracking data pipeline health)Orchestration platformContinuous

Step 2 — Declare the Grain

The grain is the most important decision in dimensional modeling. It answers: what does one row in the fact table represent?

The grain rule

Declare the grain before identifying dimensions or facts. Every column in the fact table must be true for that single grain row. If a proposed measure does not live at the declared grain, it belongs in a different fact table.

Safe Pattern: Write the Grain in the Table's Description

Add a COMMENT on the table (or a description: in dbt) stating the grain explicitly — e.g., "One row per index per trading day". At load time, assert uniqueness on the natural key that defines the grain before committing the batch. If uniqueness fails, abort and alert rather than letting mixed-grain rows corrupt the table.

Business ProcessGrain Statement
Index valuationOne row per index per trading day
Constituent weightingOne row per constituent instrument per index per rebalancing date
Corporate action processingOne row per corporate action event
Pipeline monitoringOne row per pipeline execution run

Step 3 — Identify the Dimensions

Dimensions provide the filtering, grouping, and labeling context for every fact row. They answer: who, what, where, when, how?

For the index valuation process at the grain of one index per trading day:

  • When did the valuation occur? → dim_date
  • Which index was valued? → dim_index
  • In what currency is the value expressed? → dim_currency

Step 4 — Identify the Facts

Facts are the numeric measurements produced by the business process at the declared grain. They should be additive, semi-additive, or non-additive — and you must know which. DataFrame operations like joins and groupbys in 05_py_aggregation_reshaping mirror the star schema query pattern — joining a fact DataFrame to dimension DataFrames along key columns.

MeasureAdditivityExplanation
market_cap_usdSemi-additiveCan sum across indices but not across dates (use snapshot logic)
daily_return_pctNon-additivePercentages cannot be summed; must be compounded (see bq-advanced for BigQuery window functions that handle compounding)
num_constituentsSemi-additiveCount at a point in time; averaging across dates is valid, summing is not
weight_pctSemi-additiveSums to 100% within one index on one date; cannot sum across indices
rows_processedAdditiveCan sum across pipelines, dates, or any dimension

Additive vs semi-additive vs non-additive

  • Additive: safe to SUM across every dimension (revenue, quantity, duration)
  • Semi-additive: safe to SUM across some dimensions but not all — typically not across time for balance/snapshot measures
  • Non-additive: never SUM — use AVG, weighted calculations, or compounding (percentages, ratios, factors)

Star Schema

A star schema places a single fact table at the center, surrounded by dimension tables radiating outward like points of a star. Fact tables hold foreign keys and numeric measures. Dimension tables hold descriptive attributes with a single surrogate primary key.

Star 1 — Index Valuation (one row per index per trading day):

erDiagram
    dim_date ||--o{ fact_index_valuation : "date_key"
    dim_index ||--o{ fact_index_valuation : "index_key"
    dim_currency ||--o{ fact_index_valuation : "currency_key"

    fact_index_valuation {
        int date_key FK
        int index_key FK
        int currency_key FK
        decimal index_level
        decimal daily_return_pct
        decimal market_cap_usd
        _more _columns
    }
    dim_date {
        int date_key PK
        date full_date
        bit is_trading_day
        int fiscal_quarter
        _more _columns
    }
    dim_index {
        int index_key PK
        varchar index_code
        varchar index_name
        varchar asset_class
        _more _columns
    }
    dim_currency {
        int currency_key PK
        char currency_code
        varchar currency_name
        nvarchar symbol
        bit is_major
        tinyint decimal_places
    }

Star 2 — Constituent Weights (one row per stock per index per rebalancing date):

erDiagram
    dim_date ||--o{ fact_constituent_weight : "date_key"
    dim_index ||--o{ fact_constituent_weight : "index_key"
    dim_instrument ||--o{ fact_constituent_weight : "instrument_key"
    dim_sector ||--o{ fact_constituent_weight : "sector_key"

    fact_constituent_weight {
        int date_key FK
        int index_key FK
        int instrument_key FK
        int sector_key FK
        decimal weight_pct
        decimal free_float_mcap_usd
        decimal full_mcap_usd
        _more _columns
    }
    dim_instrument {
        int instrument_key PK
        char isin
        varchar company_name
        varchar gics_sector_name
        _more _columns
    }
    dim_sector {
        int sector_key PK
        varchar gics_sector_code
        varchar gics_sector_name
        varchar gics_sub_industry_name
        _more _columns
    }

Star 3 — Corporate Actions (one row per corporate action event):

erDiagram
    dim_date ||--o{ fact_corporate_action : "date_key"
    dim_date ||--o{ fact_corporate_action : "announce_date_key"
    dim_instrument ||--o{ fact_corporate_action : "instrument_key"
    dim_corporate_action_type ||--o{ fact_corporate_action : "action_type_key"

    fact_corporate_action {
        int corporate_action_key PK
        int effective_date_key FK
        int announcement_date_key FK
        int instrument_key FK
        int action_type_key FK
        decimal adjustment_factor
        decimal old_value
        decimal new_value
        _more _columns
    }
    dim_corporate_action_type {
        int action_type_key PK
        varchar action_code
        varchar action_name
        varchar action_category
        bit affects_shares
        bit affects_price
        varchar description
    }

Star 4 — Pipeline Monitoring (one row per pipeline execution):

erDiagram
    dim_date ||--o{ fact_calculation_run : "date_key"
    dim_pipeline ||--o{ fact_calculation_run : "pipeline_key"
    dim_status ||--o{ fact_calculation_run : "status_key"

    fact_calculation_run {
        int run_key PK
        int date_key FK
        int pipeline_key FK
        int status_key FK
        int duration_seconds
        bigint rows_processed
        decimal quality_score
        _more _columns
    }
    dim_pipeline {
        int pipeline_key PK
        varchar pipeline_name
        varchar pipeline_type
        int sla_minutes
        _more _columns
    }
    dim_status {
        int status_key PK
        varchar execution_status
        varchar quality_status
        varchar freshness_status
    }

In practice, the gold-transforms layer is where dimensional models are physically built — fact and dimension tables are materialized as gold-layer outputs ready for dashboard consumption.

Why Star Schemas Outperform Normalized Models for Analytics

FactorStar Schema3NF (Normalized)
Joins for a typical query2-510-20
Query plan complexitySimple hash/merge joinsComplex multi-way joins
Predicate pushdownDimension filter pushed directly to fact scanFilters must propagate through join chains
Columnar storage alignmentWide dimension rows stored once; narrow fact columns compress wellMany skinny tables defeat columnar compression
User understandabilityBusiness users can navigateRequires DBA-level knowledge
Aggregate navigationStraightforward rollups along dimension hierarchiesAggregation paths unclear
Index utilizationFK columns on fact → fast lookupsPK/FK chains require composite indexes

Fact Tables — Full DDL

fact_index_valuation

One row per index per trading day. The primary analytical fact table for an index provider.

-- =============================================================
-- fact_index_valuation
-- Grain: one index, one trading day
-- Measures: index_level, daily_return_pct, market_cap, PE, yield
-- =============================================================
 
-- SQL Server DDL
CREATE TABLE dw.fact_index_valuation (
    -- Surrogate keys (foreign keys to dimensions)
    date_key              INT           NOT NULL,
    index_key             INT           NOT NULL,
    currency_key          INT           NOT NULL,
 
    -- Degenerate dimension (no separate table needed)
    calculation_batch_id  VARCHAR(50)   NOT NULL,
 
    -- Additive measures
    market_cap_usd        DECIMAL(20,2) NULL,
    num_constituents      INT           NULL,
 
    -- Semi-additive measures (snapshot — do not SUM across dates)
    index_level           DECIMAL(18,6) NOT NULL,
    pe_ratio              DECIMAL(10,4) NULL,
    dividend_yield_pct    DECIMAL(8,4)  NULL,
 
    -- Non-additive measures (percentages — compound, do not SUM)
    daily_return_pct      DECIMAL(10,6) NULL,
    daily_return_gross_pct DECIMAL(10,6) NULL,
    daily_return_net_pct  DECIMAL(10,6) NULL,
    ytd_return_pct        DECIMAL(12,6) NULL,
 
    -- Metadata
    row_loaded_at         DATETIME2     NOT NULL DEFAULT SYSUTCDATETIME(),
    row_source_system     VARCHAR(50)   NOT NULL DEFAULT 'CALC_ENGINE',
 
    -- Constraints
    CONSTRAINT pk_fact_index_valuation
        PRIMARY KEY NONCLUSTERED (date_key, index_key),
 
    CONSTRAINT fk_fiv_date
        FOREIGN KEY (date_key) REFERENCES dw.dim_date(date_key),
    CONSTRAINT fk_fiv_index
        FOREIGN KEY (index_key) REFERENCES dw.dim_index(index_key),
    CONSTRAINT fk_fiv_currency
        FOREIGN KEY (currency_key) REFERENCES dw.dim_currency(currency_key)
);
 
-- Clustered columnstore for analytical workloads
CREATE CLUSTERED COLUMNSTORE INDEX cci_fact_index_valuation
    ON dw.fact_index_valuation;
 
-- Nonclustered for point lookups by index + date
CREATE NONCLUSTERED INDEX ix_fiv_index_date
    ON dw.fact_index_valuation(index_key, date_key)
    INCLUDE (index_level, daily_return_pct);
 fact_constituent_weight
erDiagram
    dim_date ||--o{ fact_constituent_weight : "date_key"
    dim_index ||--o{ fact_constituent_weight : "index_key"
    dim_instrument ||--o{ fact_constituent_weight : "instrument_key"
    dim_sector ||--o{ fact_constituent_weight : "sector_key"

    fact_constituent_weight {
        int date_key FK
        int index_key FK
        int instrument_key FK
        int sector_key FK
        decimal weight_pct
        decimal free_float_mcap_usd
        decimal full_mcap_usd
        _more _columns
    }

    dim_instrument {
        int instrument_key PK
        char isin
        varchar company_name
        varchar gics_sector_name
        _more _columns
    }

    dim_sector {
        int sector_key PK
        varchar gics_sector_code
        varchar gics_sector_name
        varchar gics_sub_industry_name
        _more _columns
    }
-- BigQuery DDL
CREATE TABLE IF NOT EXISTS `project.warehouse.fact_constituent_weight` (
    date_key              INT64         NOT NULL,
    index_key             INT64         NOT NULL,
    instrument_key        INT64         NOT NULL,
    sector_key            INT64         NOT NULL,
    rebalancing_event_id  STRING        NOT NULL,
    weight_pct            NUMERIC       NOT NULL,
    free_float_factor     NUMERIC,
    capping_factor        NUMERIC,
    shares_outstanding    INT64,
    free_float_mcap_usd   NUMERIC,
    full_mcap_usd         NUMERIC,
    divisor_contribution  NUMERIC,
    row_loaded_at         TIMESTAMP     NOT NULL,
    row_source_system     STRING        NOT NULL
)
PARTITION BY RANGE_BUCKET(date_key, GENERATE_ARRAY(19900101, 20401231, 10000))
CLUSTER BY index_key, instrument_key
OPTIONS (
    description = 'Constituent weight fact. Grain: one constituent, one index, one rebalancing date.',
    require_partition_filter = TRUE
);

fact_corporate_action

One row per corporate action event applied to an instrument.

-- =============================================================
-- fact_corporate_action
-- Grain: one corporate action event
-- Measures: adjustment_factor, old_value, new_value
-- =============================================================
 
-- SQL Server DDL
CREATE TABLE dw.fact_corporate_action (
    -- Surrogate key (corporate actions are events, so a single PK is natural)
    corporate_action_key  INT           IDENTITY(1,1) NOT NULL,
 
    -- Foreign keys
    effective_date_key    INT           NOT NULL,
    announcement_date_key INT           NOT NULL,  -- role-playing dim_date
    instrument_key        INT           NOT NULL,
    action_type_key       INT           NOT NULL,
 
    -- Degenerate dimensions
    corporate_action_id   VARCHAR(50)   NOT NULL,  -- source system ID
    ex_date_key           INT           NULL,
    record_date_key       INT           NULL,
 
    -- Measures
    adjustment_factor     DECIMAL(18,10) NOT NULL,
    old_value             DECIMAL(18,6)  NULL,
    new_value             DECIMAL(18,6)  NULL,
    cash_amount           DECIMAL(18,6)  NULL,
    cash_currency_key     INT            NULL,
 
    -- Flags (degenerate / junk)
    is_mandatory          BIT           NOT NULL DEFAULT 1,
    is_processed          BIT           NOT NULL DEFAULT 0,
    affects_index_divisor BIT           NOT NULL DEFAULT 0,
 
    -- Metadata
    row_loaded_at         DATETIME2     NOT NULL DEFAULT SYSUTCDATETIME(),
    row_source_system     VARCHAR(50)   NOT NULL DEFAULT 'CORP_ACTIONS',
 
    CONSTRAINT pk_fact_corporate_action
        PRIMARY KEY NONCLUSTERED (corporate_action_key),
 
    CONSTRAINT fk_fca_eff_date
        FOREIGN KEY (effective_date_key) REFERENCES dw.dim_date(date_key),
    CONSTRAINT fk_fca_ann_date
        FOREIGN KEY (announcement_date_key) REFERENCES dw.dim_date(date_key),
    CONSTRAINT fk_fca_instrument
        FOREIGN KEY (instrument_key) REFERENCES dw.dim_instrument(instrument_key),
    CONSTRAINT fk_fca_action_type
        FOREIGN KEY (action_type_key) REFERENCES dw.dim_corporate_action_type(action_type_key)
);
 
CREATE CLUSTERED COLUMNSTORE INDEX cci_fact_corporate_action
    ON dw.fact_corporate_action;
 
CREATE NONCLUSTERED INDEX ix_fca_instrument_date
    ON dw.fact_corporate_action(instrument_key, effective_date_key);
 fact_index_calculation_run
erDiagram
    dim_date ||--o{ fact_index_calculation_run : "date_key"
    dim_pipeline ||--o{ fact_index_calculation_run : "pipeline_key"
    dim_status ||--o{ fact_index_calculation_run : "status_key"

    fact_index_calculation_run {
        int run_key PK
        int date_key FK
        int pipeline_key FK
        int status_key FK
        int duration_seconds
        bigint rows_processed
        decimal quality_score
        _more _columns
    }

    dim_pipeline {
        int pipeline_key PK
        varchar pipeline_name
        varchar pipeline_type
        int sla_minutes
        _more _columns
    }

    dim_status {
        int status_key PK
        varchar execution_status
        varchar quality_status
        varchar freshness_status
    }
-- BigQuery DDL
CREATE TABLE IF NOT EXISTS `project.warehouse.fact_index_calculation_run` (
    run_key               INT64         NOT NULL,
    date_key              INT64         NOT NULL,
    pipeline_key          INT64         NOT NULL,
    status_key            INT64         NOT NULL,
    run_id                STRING        NOT NULL,
    triggered_by          STRING,
    run_start_utc         TIMESTAMP     NOT NULL,
    run_end_utc           TIMESTAMP,
    duration_seconds      INT64,
    rows_processed        INT64,
    rows_inserted         INT64,
    rows_updated          INT64,
    rows_rejected         INT64,
    data_freshness_seconds INT64,
    quality_score         NUMERIC,
    row_loaded_at         TIMESTAMP     NOT NULL
)
PARTITION BY RANGE_BUCKET(date_key, GENERATE_ARRAY(20200101, 20401231, 10000))
CLUSTER BY pipeline_key, status_key
OPTIONS (
    description = 'Pipeline monitoring fact. Grain: one pipeline execution run.'
);

Dimension Tables — Full DDL

dim_date

The universal date dimension. Every warehouse needs one. Pre-populated from the earliest historical date to several years in the future.

-- =============================================================
-- dim_date — the universal date dimension
-- 30+ columns covering calendar, fiscal, trading day attributes
-- =============================================================
 
-- SQL Server DDL
CREATE TABLE dw.dim_date (
    date_key                INT           NOT NULL,  -- YYYYMMDD integer
    full_date               DATE          NOT NULL,
    date_iso                CHAR(10)      NOT NULL,  -- 'YYYY-MM-DD'
 
    -- Day-level attributes
    day_of_week             TINYINT       NOT NULL,  -- 1=Monday, 7=Sunday (ISO)
    day_of_week_name        VARCHAR(10)   NOT NULL,  -- 'Monday'
    day_of_week_short       CHAR(3)       NOT NULL,  -- 'Mon'
    day_of_month            TINYINT       NOT NULL,
    day_of_year             SMALLINT      NOT NULL,
    is_weekend              BIT           NOT NULL,
    is_weekday              BIT           NOT NULL,
 
    -- Trading calendar
    is_trading_day          BIT           NOT NULL DEFAULT 1,
    trading_day_of_month    TINYINT       NULL,
    trading_day_of_quarter  SMALLINT      NULL,
    trading_day_of_year     SMALLINT      NULL,
 
    -- Week-level attributes
    iso_week_number         TINYINT       NOT NULL,
    iso_week_year           SMALLINT      NOT NULL,
    week_start_date         DATE          NOT NULL,  -- Monday of ISO week
    week_end_date           DATE          NOT NULL,  -- Sunday of ISO week
 
    -- Month-level attributes
    month_number            TINYINT       NOT NULL,
    month_name              VARCHAR(10)   NOT NULL,  -- 'January'
    month_name_short        CHAR(3)       NOT NULL,  -- 'Jan'
    year_month_key          INT           NOT NULL,  -- YYYYMM
    month_start_date        DATE          NOT NULL,
    month_end_date          DATE          NOT NULL,
    is_month_end            BIT           NOT NULL,
    days_in_month           TINYINT       NOT NULL,
 
    -- Quarter-level attributes
    calendar_quarter        TINYINT       NOT NULL,  -- 1,2,3,4
    quarter_name            CHAR(6)       NOT NULL,  -- 'Q1 2026'
    quarter_start_date      DATE          NOT NULL,
    quarter_end_date        DATE          NOT NULL,
    is_quarter_end          BIT           NOT NULL,
 
    -- Year-level attributes
    calendar_year           SMALLINT      NOT NULL,
    year_start_date         DATE          NOT NULL,
    year_end_date           DATE          NOT NULL,
    is_year_end             BIT           NOT NULL,
 
    -- Fiscal calendar (assuming fiscal year = calendar year; adjust offset as needed)
    fiscal_year             SMALLINT      NOT NULL,
    fiscal_quarter          TINYINT       NOT NULL,
    fiscal_month            TINYINT       NOT NULL,
    fiscal_year_quarter     CHAR(7)       NOT NULL,  -- 'FY26-Q1'
 
    -- Relative flags (updated daily by ETL or computed at query time)
    is_current_day          BIT           NOT NULL DEFAULT 0,
    is_prior_day            BIT           NOT NULL DEFAULT 0,
    is_current_month        BIT           NOT NULL DEFAULT 0,
    is_prior_month          BIT           NOT NULL DEFAULT 0,
    is_current_year         BIT           NOT NULL DEFAULT 0,
 
    CONSTRAINT pk_dim_date PRIMARY KEY CLUSTERED (date_key)
);
 
-- Index for date lookups
CREATE UNIQUE NONCLUSTERED INDEX uix_dim_date_full
    ON dw.dim_date(full_date);
 dim_index
erDiagram
    dim_index {
        int index_key PK
        varchar index_code
        varchar index_name
        varchar asset_class
        _more _columns
    }
-- BigQuery DDL
CREATE TABLE IF NOT EXISTS `project.warehouse.dim_index` (
    index_key                 INT64         NOT NULL,
    index_code                STRING        NOT NULL,
    index_name                STRING        NOT NULL,
    index_family              STRING,
    currency_code             STRING        NOT NULL,
    region                    STRING,
    asset_class               STRING        NOT NULL,
    weighting_method          STRING        NOT NULL,
    num_constituents_target   INT64,
    rebalancing_frequency     STRING,
    launch_date               DATE,
    is_active                 BOOL          NOT NULL,
    valid_from                DATE          NOT NULL,
    valid_to                  DATE          NOT NULL,
    is_current                BOOL          NOT NULL,
    row_loaded_at             TIMESTAMP     NOT NULL
)
CLUSTER BY index_code, is_current
OPTIONS (description = 'Index dimension. SCD Type 2.');

dim_instrument

The instrument (security/stock) dimension. SCD Type 2 captures company name changes, sector reclassifications, and listing transfers.

-- =============================================================
-- dim_instrument — SCD Type 2
-- Full GICS hierarchy embedded for star schema convenience
-- =============================================================
 
-- SQL Server DDL
CREATE TABLE dw.dim_instrument (
    instrument_key            INT           IDENTITY(1,1) NOT NULL,
 
    -- Natural keys (identifiers)
    isin                      CHAR(12)      NULL,
    sedol                     CHAR(7)       NULL,
    cusip                     CHAR(9)       NULL,
    ticker                    VARCHAR(20)   NULL,
    figi                      CHAR(12)      NULL,
 
    -- Descriptive attributes
    company_name              VARCHAR(200)  NOT NULL,
    company_name_short        VARCHAR(50)   NULL,
    country_of_incorporation  CHAR(2)       NULL,  -- ISO 3166 alpha-2
    country_of_listing        CHAR(2)       NULL,
    exchange_code             VARCHAR(10)   NULL,  -- MIC code
    exchange_name             VARCHAR(100)  NULL,
    primary_currency          CHAR(3)       NULL,
 
    -- GICS classification (denormalized into dimension for star schema)
    gics_sector_code          VARCHAR(10)   NULL,
    gics_sector_name          VARCHAR(100)  NULL,
    gics_industry_group_code  VARCHAR(10)   NULL,
    gics_industry_group_name  VARCHAR(100)  NULL,
    gics_industry_code        VARCHAR(10)   NULL,
    gics_industry_name        VARCHAR(100)  NULL,
    gics_sub_industry_code    VARCHAR(10)   NULL,
    gics_sub_industry_name    VARCHAR(100)  NULL,
 
    -- Derived attributes
    market_cap_band           VARCHAR(20)   NULL,  -- 'Mega', 'Large', 'Mid', 'Small', 'Micro'
    is_active                 BIT           NOT NULL DEFAULT 1,
 
    -- SCD Type 2
    valid_from                DATE          NOT NULL,
    valid_to                  DATE          NOT NULL DEFAULT '9999-12-31',
    is_current                BIT           NOT NULL DEFAULT 1,
 
    -- Metadata
    row_loaded_at             DATETIME2     NOT NULL DEFAULT SYSUTCDATETIME(),
 
    CONSTRAINT pk_dim_instrument PRIMARY KEY CLUSTERED (instrument_key)
);
 
CREATE NONCLUSTERED INDEX ix_dim_instrument_isin
    ON dw.dim_instrument(isin, is_current)
    INCLUDE (instrument_key, company_name);
 
CREATE NONCLUSTERED INDEX ix_dim_instrument_sedol
    ON dw.dim_instrument(sedol, is_current)
    INCLUDE (instrument_key);
 
CREATE NONCLUSTERED INDEX ix_dim_instrument_ticker
    ON dw.dim_instrument(ticker, is_current)
    INCLUDE (instrument_key, company_name);
 
CREATE NONCLUSTERED INDEX ix_dim_instrument_scd
    ON dw.dim_instrument(isin, valid_from, valid_to);
 dim_sector
erDiagram
    dim_sector {
        int sector_key PK
        varchar gics_sector_code
        varchar gics_sector_name
        varchar gics_sub_industry_name
        _more _columns
    }
-- BigQuery DDL
CREATE TABLE IF NOT EXISTS `project.warehouse.dim_sector` (
    sector_key                INT64         NOT NULL,
    gics_sector_code          STRING        NOT NULL,
    gics_sector_name          STRING        NOT NULL,
    gics_industry_group_code  STRING        NOT NULL,
    gics_industry_group_name  STRING        NOT NULL,
    gics_industry_code        STRING        NOT NULL,
    gics_industry_name        STRING        NOT NULL,
    gics_sub_industry_code    STRING        NOT NULL,
    gics_sub_industry_name    STRING        NOT NULL,
    row_loaded_at             TIMESTAMP     NOT NULL,
    row_updated_at            TIMESTAMP     NOT NULL
)
OPTIONS (description = 'GICS sector hierarchy dimension. SCD Type 1.');

dim_currency

-- =============================================================
-- dim_currency — static reference dimension
-- =============================================================
 
-- SQL Server DDL
CREATE TABLE dw.dim_currency (
    currency_key    INT          IDENTITY(1,1) NOT NULL,
    currency_code   CHAR(3)      NOT NULL,  -- ISO 4217
    currency_name   VARCHAR(50)  NOT NULL,
    symbol          NVARCHAR(5)  NULL,
    is_major        BIT          NOT NULL DEFAULT 0,
    decimal_places  TINYINT      NOT NULL DEFAULT 2,
 
    CONSTRAINT pk_dim_currency PRIMARY KEY CLUSTERED (currency_key)
);
 
CREATE UNIQUE NONCLUSTERED INDEX uix_dim_currency_code
    ON dw.dim_currency(currency_code);
 dim_corporate_action_type
erDiagram
    dim_corporate_action_type {
        int action_type_key PK
        varchar action_code
        varchar action_name
        varchar action_category
        _more _columns
    }
-- Sample data
INSERT INTO dw.dim_corporate_action_type
    (action_code, action_name, action_category, affects_shares, affects_price, description)
VALUES
    ('SPLIT',   'Stock Split',           'Mandatory',  1, 1, 'Share count increases; price adjusts inversely'),
    ('REV_SPL', 'Reverse Stock Split',   'Mandatory',  1, 1, 'Share count decreases; price adjusts inversely'),
    ('DIV_CSH', 'Cash Dividend',         'Mandatory',  0, 1, 'Cash distribution; price drops by dividend amount on ex-date'),
    ('DIV_STK', 'Stock Dividend',        'Mandatory',  1, 1, 'Additional shares issued as dividend'),
    ('RIGHTS',  'Rights Issue',          'Voluntary',  1, 1, 'Existing shareholders offered new shares at discount'),
    ('MERGER',  'Merger / Acquisition',  'Mandatory',  1, 1, 'Company absorbed into acquirer; shares converted or cashed out'),
    ('SPINOFF', 'Spin-Off',             'Mandatory',  1, 1, 'Subsidiary becomes independent company; parent shares adjusted'),
    ('DELIST',  'Delisting',            'Mandatory',  0, 0, 'Security removed from exchange'),
    ('NAME_CHG','Name Change',          'Mandatory',  0, 0, 'Company changes its legal name');
-- BigQuery DDL
CREATE TABLE IF NOT EXISTS `project.warehouse.dim_corporate_action_type` (
    action_type_key   INT64       NOT NULL,
    action_code       STRING      NOT NULL,
    action_name       STRING      NOT NULL,
    action_category   STRING      NOT NULL,
    affects_shares    BOOL        NOT NULL,
    affects_price     BOOL        NOT NULL,
    description       STRING
)
OPTIONS (description = 'Corporate action type dimension.');

dim_pipeline

-- =============================================================
-- dim_pipeline — pipeline metadata dimension
-- =============================================================
 
-- SQL Server DDL
CREATE TABLE dw.dim_pipeline (
    pipeline_key      INT          IDENTITY(1,1) NOT NULL,
    pipeline_name     VARCHAR(100) NOT NULL,
    pipeline_type     VARCHAR(50)  NOT NULL,  -- 'Ingestion', 'Transformation', 'Calculation', 'Distribution'
    schedule          VARCHAR(50)  NULL,       -- 'Daily 06:00 UTC', 'Hourly', 'Event-driven'
    owner_team        VARCHAR(100) NULL,
    sla_minutes       INT          NULL,
    is_active         BIT          NOT NULL DEFAULT 1,
 
    CONSTRAINT pk_dim_pipeline PRIMARY KEY CLUSTERED (pipeline_key)
);
 
CREATE UNIQUE NONCLUSTERED INDEX uix_dim_pipeline_name
    ON dw.dim_pipeline(pipeline_name);
 dim_status
erDiagram
    dim_status {
        int status_key PK
        varchar execution_status
        varchar quality_status
        varchar freshness_status
    }
-- Pre-populate all combinations
INSERT INTO dw.dim_status (execution_status, quality_status, freshness_status)
SELECT e.val, q.val, f.val
FROM (VALUES ('Success'),('Failed'),('Partial'),('Running'),('Cancelled')) AS e(val)
CROSS JOIN (VALUES ('Pass'),('Warning'),('Fail'),('Not Checked')) AS q(val)
CROSS JOIN (VALUES ('On Time'),('Late'),('Stale'),('Unknown')) AS f(val);
-- BigQuery DDL
CREATE TABLE IF NOT EXISTS `project.warehouse.dim_status` (
    status_key          INT64       NOT NULL,
    execution_status    STRING      NOT NULL,
    quality_status      STRING      NOT NULL,
    freshness_status    STRING      NOT NULL
)
OPTIONS (description = 'Junk dimension combining execution, quality, and freshness status flags.');

Why junk dimensions?

Without dim_status, the fact table would need three separate foreign keys to three tiny tables (or worse, three raw VARCHAR columns). The junk dimension consolidates them into one key, keeping the fact table narrow and the schema clean.


Snowflake Schema

A snowflake schema normalizes dimension hierarchies into separate tables. Instead of storing the full GICS hierarchy in dim_instrument, each level gets its own table.

Snowflake Example: GICS Sector Hierarchy

erDiagram
     dim_gics_sector
erDiagram
    dim_gics_sector {
        int gics_sector_key PK
        varchar sector_code
        varchar sector_name
    }
CREATE TABLE dw.dim_gics_industry_group (
    gics_industry_group_key INT     IDENTITY(1,1) NOT NULL,
    industry_group_code     VARCHAR(10)  NOT NULL,
    industry_group_name     VARCHAR(100) NOT NULL,
    gics_sector_key         INT          NOT NULL,
    CONSTRAINT pk_gics_ig PRIMARY KEY CLUSTERED (gics_industry_group_key),
    CONSTRAINT fk_ig_sector FOREIGN KEY (gics_sector_key) REFERENCES dw.dim_gics_sector(gics_sector_key)
);
 dim_gics_industry
erDiagram
    dim_gics_industry_group ||--o{ dim_gics_industry : "gics_industry_group_key"

    dim_gics_industry {
        int gics_industry_key PK
        varchar industry_code
        varchar industry_name
        int gics_industry_group_key FK
    }

    dim_gics_industry_group {
        int gics_industry_group_key PK
        varchar industry_group_code
        varchar industry_group_name
    }
CREATE TABLE dw.dim_gics_sub_industry (
    gics_sub_industry_key   INT          IDENTITY(1,1) NOT NULL,
    sub_industry_code       VARCHAR(10)  NOT NULL,
    sub_industry_name       VARCHAR(100) NOT NULL,
    gics_industry_key       INT          NOT NULL,
    CONSTRAINT pk_gics_si PRIMARY KEY CLUSTERED (gics_sub_industry_key),
    CONSTRAINT fk_si_ind FOREIGN KEY (gics_industry_key) REFERENCES dw.dim_gics_industry(gics_industry_key)
);
 dim_instrument_profile
erDiagram
    dim_instrument_profile {
        int instrument_profile_key PK
        varchar market_cap_band
        varchar liquidity_tier
        varchar free_float_band
        int volatility_quintile
    }

SCD Type 6 — Hybrid (Type 1 + 2 + 3)

Combines all three approaches. A new SCD Type 2 row is created (Type 2), the previous value is stored in a column (Type 3), and the current value is also overwritten on all historical rows (Type 1) so that queries filtering on the current sector still find the historical rows.

-- SCD Type 6 example: dim_instrument with current_ overwrite columns
ALTER TABLE dw.dim_instrument ADD
    current_gics_sector_code VARCHAR(10) NULL,
    current_gics_sector_name VARCHAR(100) NULL;
 
-- When sector changes from 'Technology' to 'Communication Services':
 
-- Step 1 (Type 2): Expire old row, insert new row
-- (Same MERGE logic as Type 2 above)
 
-- Step 2 (Type 1): Overwrite current_ columns on ALL historical rows
UPDATE dw.dim_instrument
SET
    current_gics_sector_code = '50',
    current_gics_sector_name = 'Communication Services'
WHERE isin = 'US0000000001';
-- This updates ALL rows for this ISIN — both historical and current

When to use Type 6

Type 6 is powerful when users need both historical accuracy AND the ability to filter all history by the current classification. For example: “Show me the complete weight history of all stocks that are currently in the Communication Services sector — even before they were reclassified.”


SCD Decision Matrix

AttributeExampleRecommended SCD TypeReasoning
ISINUS0000000001Type 0 (fixed)Assigned once, never changes
Company name (typo fix)‘Gloabl’ → ‘Global’Type 1 (overwrite)No business value in preserving errors
GICS sector (dim_sector)GICS reclassificationType 1 (overwrite)Industry standard is to restate
GICS sector (dim_instrument)Company reclassifiedType 2 (new row)Preserve which sector the company was in when it was a constituent
Exchange listingNYSE → LSEType 2 (new row)Material change, history needed
Market cap bandLarge → MidType 4 (mini-dimension)Changes frequently, would bloat Type 2 rows
Index weighting methodFree-float → cappedType 2 (new row)Methodology change is a significant event
Currency name’Euro’ (never changes)Type 0 (fixed)Static reference data
Rebalancing frequencyQuarterly → MonthlyType 2 (new row)Methodology change with historical impact

Advanced Modeling Patterns

Conformed Dimensions

A conformed dimension is a dimension table shared by multiple fact tables across the warehouse. It ensures that “Wednesday” means the same thing in the index valuation fact as it does in the pipeline monitoring fact.

The two rules of conformed dimensions

  1. Same dimension table, same keys: Multiple fact tables reference the same physical dim_date, dim_instrument, etc.
  2. Subset conformance: A dimension used by one fact table may be a subset of a broader dimension used by another — as long as the shared attributes have identical meaning.

The enterprise bus matrix documents which conformed dimensions are used by which business processes.

Enterprise Bus Matrix — Index Provider Domain

Business Process / Fact Tabledim_datedim_indexdim_instrumentdim_sectordim_currencydim_corporate_action_typedim_pipelinedim_status
fact_index_valuationXXX
fact_constituent_weightXXXX
fact_corporate_actionXXXX
fact_index_calculation_runXXX
fact_index_eligibility (factless)XXXX

dim_date is conformed across every fact table. dim_instrument is conformed across constituent weight, corporate action, and eligibility facts.


Bridge Tables

erDiagram
     bridge_index_constituent
erDiagram
    dim_index ||--o{ bridge_index_constituent : "index_key"
    dim_instrument ||--o{ bridge_index_constituent : "instrument_key"

    bridge_index_constituent {
        int bridge_key PK
        int index_key FK
        int instrument_key FK
        date effective_date
        date expiry_date
        decimal weight_pct
    }
-- Query: Which instruments are in a given index today?
SELECT
    di.index_name,
    dinst.company_name,
    dinst.ticker,
    b.weight_pct
FROM dw.bridge_index_constituent b
JOIN dw.dim_index di ON b.index_key = di.index_key AND di.is_current = 1
JOIN dw.dim_instrument dinst ON b.instrument_key = dinst.instrument_key AND dinst.is_current = 1
WHERE di.index_code = 'GLBL_EQ_500'
  AND CAST(GETDATE() AS DATE) BETWEEN b.effective_date AND b.expiry_date
ORDER BY b.weight_pct DESC;

Bridge table vs fact table for many-to-many

In this index provider domain, fact_constituent_weight already captures the many-to-many relationship (each row has both index_key and instrument_key). A separate bridge table is useful when:

  1. The relationship exists without a natural fact (membership without weights)
  2. You need to filter one dimension through another in a BI tool (e.g., “show me all indices containing a specific stock”)
  3. The relationship changes at a different cadence than the fact table grain

Factless Fact Tables

erDiagram
     fact_index_eligibility
erDiagram
    dim_date ||--o{ fact_index_eligibility : "date_key"
    dim_index ||--o{ fact_index_eligibility : "index_key"
    dim_instrument ||--o{ fact_index_eligibility : "instrument_key"
    dim_sector ||--o{ fact_index_eligibility : "sector_key"

    fact_index_eligibility {
        int date_key FK
        int index_key FK
        int instrument_key FK
        int sector_key FK
        varchar eligibility_reason
        varchar review_cycle
    }
-- Query: How many stocks were eligible for the global equity benchmark
--        but not actually selected in Q1 2026?
SELECT
    dd.quarter_name,
    COUNT(DISTINCT elig.instrument_key)  AS eligible_count,
    COUNT(DISTINCT cw.instrument_key)    AS selected_count,
    COUNT(DISTINCT elig.instrument_key)
        - COUNT(DISTINCT cw.instrument_key) AS eligible_not_selected
FROM dw.fact_index_eligibility elig
JOIN dw.dim_date dd ON elig.date_key = dd.date_key
JOIN dw.dim_index di ON elig.index_key = di.index_key AND di.is_current = 1
LEFT JOIN dw.fact_constituent_weight cw
    ON elig.date_key = cw.date_key
    AND elig.index_key = cw.index_key
    AND elig.instrument_key = cw.instrument_key
WHERE di.index_code = 'GLBL_EQ_500'
  AND dd.quarter_name = 'Q1 2026'
GROUP BY dd.quarter_name;

Event Factless Fact

“Which corporate actions were announced on which date?” The announcement itself is the event — no measures needed (the detailed corporate action facts live in fact_corporate_action).

-- Query using fact_corporate_action as an event fact
-- Count: how many corporate actions per type per month?
SELECT
    dd.year_month_key,
    cat.action_name,
    COUNT(*) AS action_count
FROM dw.fact_corporate_action fca
JOIN dw.dim_date dd ON fca.announcement_date_key = dd.date_key
JOIN dw.dim_corporate_action_type cat ON fca.action_type_key = cat.action_type_key
WHERE dd.calendar_year = 2026
GROUP BY dd.year_month_key, cat.action_name
ORDER BY dd.year_month_key, action_count DESC;

Aggregate / Summary Tables

erDiagram
     agg_monthly_index_performance
erDiagram
    dim_date ||--o{ agg_monthly_index_performance : "year_month_key"
    dim_index ||--o{ agg_monthly_index_performance : "index_key"
    dim_currency ||--o{ agg_monthly_index_performance : "currency_key"

    agg_monthly_index_performance {
        int year_month_key FK
        int index_key FK
        int currency_key FK
        decimal monthly_return_pct
        decimal month_close_level
        decimal avg_market_cap_usd
        _more _columns
    }
-- Populate the aggregate
INSERT INTO dw.agg_monthly_index_performance
SELECT
    dd.year_month_key,
    fiv.index_key,
    fiv.currency_key,
    FIRST_VALUE(fiv.index_level) OVER (
        PARTITION BY dd.year_month_key, fiv.index_key ORDER BY dd.date_key
    )                                                           AS month_open_level,
    LAST_VALUE(fiv.index_level) OVER (
        PARTITION BY dd.year_month_key, fiv.index_key
        ORDER BY dd.date_key
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    )                                                           AS month_close_level,
    MAX(fiv.index_level)                                        AS month_high_level,
    MIN(fiv.index_level)                                        AS month_low_level,
    -- Monthly return: (close / open) - 1
    (LAST_VALUE(fiv.index_level) OVER (
        PARTITION BY dd.year_month_key, fiv.index_key
        ORDER BY dd.date_key
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) / NULLIF(FIRST_VALUE(fiv.index_level) OVER (
        PARTITION BY dd.year_month_key, fiv.index_key ORDER BY dd.date_key
    ), 0) - 1) * 100                                            AS monthly_return_pct,
    AVG(fiv.daily_return_pct)                                   AS avg_daily_return_pct,
    STDEV(fiv.daily_return_pct)                                 AS stddev_daily_return,
    COUNT(*)                                                    AS trading_days_in_month,
    AVG(fiv.market_cap_usd)                                     AS avg_market_cap_usd,
    AVG(fiv.pe_ratio)                                           AS avg_pe_ratio,
    AVG(fiv.dividend_yield_pct)                                 AS avg_dividend_yield,
    AVG(CAST(fiv.num_constituents AS DECIMAL(8,2)))             AS avg_num_constituents,
    SYSUTCDATETIME()                                            AS row_computed_at
FROM dw.fact_index_valuation fiv
JOIN dw.dim_date dd ON fiv.date_key = dd.date_key
WHERE dd.is_trading_day = 1
GROUP BY dd.year_month_key, fiv.index_key, fiv.currency_key;

Sector-Level Weight Rollup

-- =============================================================
-- agg_sector_weight
-- Sector-level constituent weights per index per rebalancing date
-- =============================================================
 
CREATE TABLE dw.agg_sector_weight (
    date_key              INT           NOT NULL,
    index_key             INT           NOT NULL,
    sector_key            INT           NOT NULL,
 
    total_weight_pct      DECIMAL(12,8) NOT NULL,
    constituent_count     INT           NOT NULL,
    avg_free_float_factor DECIMAL(8,6)  NULL,
    total_free_float_mcap DECIMAL(20,2) NULL,
 
    row_computed_at       DATETIME2     NOT NULL DEFAULT SYSUTCDATETIME(),
 
    CONSTRAINT pk_agg_sector_weight PRIMARY KEY CLUSTERED (date_key, index_key, sector_key)
);
 fact_index_valuation_partitioned
erDiagram
    dim_date ||--o{ fact_index_valuation_partitioned : "date_key"
    dim_index ||--o{ fact_index_valuation_partitioned : "index_key"
    dim_currency ||--o{ fact_index_valuation_partitioned : "currency_key"

    fact_index_valuation_partitioned {
        int date_key FK
        int index_key FK
        int currency_key FK
        decimal index_level
        decimal daily_return_pct
        decimal market_cap_usd
        _more _columns
    }

Compression Strategy

Table TypeCompressionRationale
Fact tablesClustered columnstore (COLUMNSTORE_ARCHIVE for cold partitions)Maximum compression + scan speed
Dimension tables (small, <1M rows)PAGE compressionGood compression without columnstore overhead
Dimension tables (large, >1M rows)Clustered columnstoreSame benefits as fact tables
Staging tablesNone or ROWStaging is transient; compression overhead not worth it
Aggregate tablesPAGE or columnstoreDepends on size; columnstore if >100K rows
-- Apply PAGE compression to a dimension
ALTER TABLE dw.dim_instrument REBUILD WITH (DATA_COMPRESSION = PAGE);
 
-- Apply COLUMNSTORE_ARCHIVE to cold fact partitions (older than 1 year)
ALTER INDEX cci_fact_index_valuation ON dw.fact_index_valuation
    REBUILD PARTITION = 1  -- January 2025
    WITH (DATA_COMPRESSION = COLUMNSTORE_ARCHIVE);

BigQuery

Partitioning and Clustering

-- Partitioning by date_key (integer range) and clustering by frequently filtered columns
-- Already shown in fact table DDL above. Key points:
 
-- 1. PARTITION BY RANGE_BUCKET for integer date keys
-- 2. CLUSTER BY the top 1-4 columns used in WHERE / JOIN clauses
-- 3. require_partition_filter = TRUE prevents full-table scans
 
-- Verify partitioning and clustering
SELECT
    table_name,
    partition_type,
    clustering_columns
FROM `project.warehouse.INFORMATION_SCHEMA.TABLE_OPTIONS`
WHERE table_name LIKE 'fact_%';

Nested / Repeated Fields for Denormalized Dimensions

BigQuery’s native STRUCT and ARRAY types allow embedding dimension attributes directly into the fact table, eliminating joins entirely.

-- Denormalized fact with nested dimension attributes (BigQuery)
CREATE TABLE IF NOT EXISTS `project.warehouse.fact_index_valuation_denorm` (
    date_key              INT64         NOT NULL,
    full_date             DATE          NOT NULL,
 
    -- Nested index dimension (STRUCT)
    index_info            STRUCT<
        index_key         INT64,
        index_code        STRING,
        index_name        STRING,
        index_family      STRING,
        asset_class       STRING,
        weighting_method  STRING,
        region            STRING
    >                     NOT NULL,
 
    -- Nested currency dimension (STRUCT)
    currency_info         STRUCT<
        currency_code     STRING,
        currency_name     STRING
    >                     NOT NULL,
 
    -- Measures
    index_level           NUMERIC       NOT NULL,
    daily_return_pct      NUMERIC,
    market_cap_usd        NUMERIC,
    pe_ratio              NUMERIC,
    dividend_yield_pct    NUMERIC,
    num_constituents      INT64,
 
    row_loaded_at         TIMESTAMP     NOT NULL
)
PARTITION BY full_date
CLUSTER BY index_info.index_code
OPTIONS (
    description = 'Denormalized index valuation with nested dimension attributes. Zero-join analytical queries.'
);
%% fact_index_valuation_denorm
erDiagram
    fact_index_valuation_denorm {
        int date_key
        struct index_info
        struct currency_info
        decimal index_level
        decimal daily_return_pct
        decimal market_cap_usd
        _more _columns
    }
-- Query: no joins needed
SELECT
    full_date,
    index_info.index_name,
    index_info.region,
    currency_info.currency_code,
    index_level,
    daily_return_pct
FROM `project.warehouse.fact_index_valuation_denorm`
WHERE full_date BETWEEN '2026-01-01' AND '2026-03-22'
  AND index_info.asset_class = 'Equity'
  AND index_info.region = 'Global'
ORDER BY full_date;

Materialized Views in BigQuery

-- Materialized view: monthly index performance
CREATE MATERIALIZED VIEW `project.warehouse.mv_monthly_index_performance`
OPTIONS (enable_refresh = true, refresh_interval_minutes = 60)
AS
SELECT
    dd.year_month_key,
    fiv.index_key,
    fiv.currency_key,
    MIN(fiv.index_level)              AS month_low_level,
    MAX(fiv.index_level)              AS month_high_level,
    AVG(fiv.daily_return_pct)         AS avg_daily_return_pct,
    COUNT(*)                          AS trading_days,
    AVG(fiv.market_cap_usd)           AS avg_market_cap_usd,
    AVG(fiv.pe_ratio)                 AS avg_pe_ratio
FROM `project.warehouse.fact_index_valuation` fiv
JOIN `project.warehouse.dim_date` dd ON fiv.date_key = dd.date_key
WHERE dd.is_trading_day = TRUE
GROUP BY dd.year_month_key, fiv.index_key, fiv.currency_key;

Modeling Tools and Workflows

dbt: Model Layers for Dimensional Modeling

dbt organizes transformations into layers that map naturally to dimensional modeling.

LayerPrefixPurposeExample
Stagingstg_1:1 with source tables, light cleaning (rename, cast, dedupe)stg_vendor__instruments
Intermediateint_Business logic, joins, SCD processingint_instruments_scd2
Martsdim_, fact_, bridge_, agg_Final dimensional model tablesdim_instrument, fact_index_valuation
# dbt project structure
models/
  staging/
    vendor/
      stg_vendor__instruments.sql
      stg_vendor__index_levels.sql
      stg_vendor__corporate_actions.sql
      _vendor__sources.yml
  intermediate/
    int_instruments_scd2.sql
    int_index_valuation_enriched.sql
  marts/
    dimensions/
      dim_date.sql
      dim_index.sql
      dim_instrument.sql
      dim_sector.sql
      dim_currency.sql
      dim_corporate_action_type.sql
      dim_pipeline.sql
      dim_status.sql
    facts/
      fact_index_valuation.sql
      fact_constituent_weight.sql
      fact_corporate_action.sql
      fact_index_calculation_run.sql
    bridges/
      bridge_index_constituent.sql
    aggregates/
      agg_monthly_index_performance.sql
      agg_sector_weight.sql
-- dbt model: fact_index_valuation.sql
-- Uses ref() for lineage tracking
 
{{ config(
    materialized = 'incremental',
    unique_key = ['date_key', 'index_key'],
    partition_by = {'field': 'date_key', 'data_type': 'int64', 'range': {'start': 19900101, 'end': 20401231, 'interval': 10000}},
    cluster_by = ['index_key', 'currency_key']
) }}
 
SELECT
    dd.date_key,
    di.index_key,
    dc.currency_key,
    stg.calculation_batch_id,
    stg.index_level,
    stg.daily_return_pct,
    stg.daily_return_gross_pct,
    stg.daily_return_net_pct,
    stg.ytd_return_pct,
    stg.market_cap_usd,
    stg.pe_ratio,
    stg.dividend_yield_pct,
    stg.num_constituents,
    CURRENT_TIMESTAMP()                     AS row_loaded_at,
    'CALC_ENGINE'                           AS row_source_system
FROM {{ ref('int_index_valuation_enriched') }} stg
JOIN {{ ref('dim_date') }} dd
    ON stg.valuation_date = dd.full_date
JOIN {{ ref('dim_index') }} di
    ON stg.index_code = di.index_code
    AND di.is_current = TRUE
JOIN {{ ref('dim_currency') }} dc
    ON stg.currency_code = dc.currency_code
 
{% if is_incremental() %}
WHERE stg.valuation_date > (SELECT MAX(dd2.full_date) FROM {{ this }} t JOIN {{ ref('dim_date') }} dd2 ON t.date_key = dd2.date_key)
{% endif %}
# dbt schema test: _schema.yml
models:
  - name: fact_index_valuation
    description: "Daily index valuation fact. Grain: one index, one trading day."
    columns:
      - name: date_key
        tests:
          - not_null
          - relationships:
              to: ref('dim_date')
              field: date_key
      - name: index_key
        tests:
          - not_null
          - relationships:
              to: ref('dim_index')
              field: index_key
      - name: index_level
        tests:
          - not_null
          - dbt_utils.accepted_range:
              min_value: 0
              inclusive: false
      - name: daily_return_pct
        tests:
          - dbt_utils.accepted_range:
              min_value: -50
              max_value: 50
    tests:
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns:
            - date_key
            - index_key

Other Modeling Tools

ToolTypeBest ForCost
erwin Data ModelerEnterprise data modelingLarge-scale enterprise warehouse design, forward/reverse engineering, governanceCommercial
ER/StudioEnterprise data modelingSimilar to erwin, strong in multi-platform DDL generationCommercial
dbdiagram.ioBrowser-based ERDQuick schema visualization, sharing with team, DBML syntaxFree tier available
DrawSQLCollaborative schema designTeam collaboration on schema design, visual ERD editorFree tier available
BigQuery INFORMATION_SCHEMASchema introspectionReverse-engineering existing BigQuery schemasIncluded with BigQuery
SQL Server sys catalog viewsSchema introspectionReverse-engineering existing SQL Server schemasIncluded with SQL Server

Reverse-Engineering Existing Schemas

-- BigQuery: list all tables with partitioning and clustering info
SELECT
    t.table_name,
    t.table_type,
    ARRAY_TO_STRING(c.clustering_columns, ', ') AS cluster_cols,
    p.partition_type
FROM `project.warehouse.INFORMATION_SCHEMA.TABLES` t
LEFT JOIN (
    SELECT table_name, ARRAY_AGG(column_name ORDER BY clustering_ordinal_position) AS clustering_columns
    FROM `project.warehouse.INFORMATION_SCHEMA.COLUMNS`
    WHERE clustering_ordinal_position IS NOT NULL
    GROUP BY table_name
) c ON t.table_name = c.table_name
LEFT JOIN (
    SELECT table_name, 'RANGE' AS partition_type
    FROM `project.warehouse.INFORMATION_SCHEMA.PARTITIONS`
    GROUP BY table_name
) p ON t.table_name = p.table_name
WHERE t.table_schema = 'warehouse'
ORDER BY t.table_name;
-- SQL Server: list all tables with columns, types, and indexes
SELECT
    s.name                          AS schema_name,
    t.name                          AS table_name,
    c.name                          AS column_name,
    ty.name                         AS data_type,
    c.max_length,
    c.is_nullable,
    CASE WHEN ic.object_id IS NOT NULL THEN 'PK' ELSE '' END AS is_pk,
    CASE WHEN fkc.parent_object_id IS NOT NULL THEN
        OBJECT_NAME(fkc.referenced_object_id)
    ELSE '' END                     AS fk_references
FROM sys.tables t
JOIN sys.schemas s ON t.schema_id = s.schema_id
JOIN sys.columns c ON t.object_id = c.object_id
JOIN sys.types ty ON c.user_type_id = ty.user_type_id
LEFT JOIN (
    SELECT ic.object_id, ic.column_id
    FROM sys.index_columns ic
    JOIN sys.indexes i ON ic.object_id = i.object_id AND ic.index_id = i.index_id
    WHERE i.is_primary_key = 1
) ic ON t.object_id = ic.object_id AND c.column_id = ic.column_id
LEFT JOIN sys.foreign_key_columns fkc
    ON t.object_id = fkc.parent_object_id AND c.column_id = fkc.parent_column_id
WHERE s.name = 'dw'
ORDER BY t.name, c.column_id;

Naming Conventions

Consistent naming is essential for a maintainable warehouse. Adopt these prefixes and enforce them through code review and CI checks.

PrefixPurposeExample
fact_Fact table (measures + foreign keys)fact_index_valuation
dim_Dimension table (descriptive attributes)dim_instrument
bridge_Bridge table (many-to-many resolver)bridge_index_constituent
agg_Aggregate / summary tableagg_monthly_index_performance
stg_Staging table (raw source copy)stg_vendor__instruments
int_Intermediate transformationint_instruments_scd2
mv_Materialized viewmv_monthly_index_performance
vw_Standard viewvw_current_constituents

Column naming conventions

PatternPurposeExample
*_keySurrogate key (integer, auto-generated)index_key, instrument_key
*_codeNatural business key / identifierindex_code, currency_code
*_idSource system identifier (degenerate dimension)calculation_batch_id
*_nameHuman-readable labelindex_name, company_name
*_pctPercentage valueweight_pct, daily_return_pct
*_usdValue in US dollarsmarket_cap_usd
*_dateDate value (DATE type)launch_date, valid_from
*_atTimestamp value (DATETIME2 / TIMESTAMP)row_loaded_at, run_start_utc
is_*Boolean flagis_current, is_active, is_weekend
num_*Count / quantitynum_constituents, num_constituents_target

Putting It All Together — Complete Analytical Queries

Query 1: Top 10 Constituents by Weight in an Index

SELECT TOP 10
    di.index_name,
    dinst.company_name,
    dinst.ticker,
    ds.gics_sector_name,
    fcw.weight_pct,
    fcw.free_float_mcap_usd
FROM dw.fact_constituent_weight fcw
JOIN dw.dim_date dd        ON fcw.date_key = dd.date_key
JOIN dw.dim_index di       ON fcw.index_key = di.index_key       AND di.is_current = 1
JOIN dw.dim_instrument dinst ON fcw.instrument_key = dinst.instrument_key AND dinst.is_current = 1
JOIN dw.dim_sector ds      ON fcw.sector_key = ds.sector_key
WHERE di.index_code = 'GLBL_EQ_500'
  AND dd.full_date = (
      SELECT MAX(dd2.full_date)
      FROM dw.fact_constituent_weight fcw2
      JOIN dw.dim_date dd2 ON fcw2.date_key = dd2.date_key
      JOIN dw.dim_index di2 ON fcw2.index_key = di2.index_key AND di2.is_current = 1
      WHERE di2.index_code = 'GLBL_EQ_500'
  )
ORDER BY fcw.weight_pct DESC;

Query 2: Year-to-Date Index Performance Comparison

SELECT
    di.index_name,
    di.region,
    dd.full_date,
    fiv.index_level,
    fiv.ytd_return_pct,
    fiv.num_constituents,
    fiv.pe_ratio,
    fiv.dividend_yield_pct
FROM dw.fact_index_valuation fiv
JOIN dw.dim_date dd   ON fiv.date_key = dd.date_key
JOIN dw.dim_index di  ON fiv.index_key = di.index_key AND di.is_current = 1
WHERE dd.full_date = (SELECT MAX(full_date) FROM dw.dim_date WHERE is_trading_day = 1 AND full_date <= GETDATE())
  AND di.asset_class = 'Equity'
  AND di.is_active = 1
ORDER BY fiv.ytd_return_pct DESC;

Query 3: Corporate Actions Impact Analysis

-- Which stock splits affected the global equity benchmark in 2026?
SELECT
    dd.full_date                      AS effective_date,
    dinst.company_name,
    dinst.ticker,
    cat.action_name,
    fca.adjustment_factor,
    fca.old_value                     AS old_shares,
    fca.new_value                     AS new_shares,
    fcw.weight_pct                    AS weight_at_last_rebal
FROM dw.fact_corporate_action fca
JOIN dw.dim_date dd                   ON fca.effective_date_key = dd.date_key
JOIN dw.dim_instrument dinst          ON fca.instrument_key = dinst.instrument_key
    AND dd.full_date BETWEEN dinst.valid_from AND dinst.valid_to  -- SCD2: as-of effective date
JOIN dw.dim_corporate_action_type cat ON fca.action_type_key = cat.action_type_key
-- Join to latest constituent weight to see the stock's weight in the benchmark
LEFT JOIN dw.fact_constituent_weight fcw
    ON dinst.instrument_key = fcw.instrument_key
    AND fcw.index_key = (SELECT index_key FROM dw.dim_index WHERE index_code = 'GLBL_EQ_500' AND is_current = 1)
    AND fcw.date_key = (
        SELECT MAX(date_key) FROM dw.fact_constituent_weight
        WHERE index_key = fcw.index_key AND instrument_key = fcw.instrument_key
          AND date_key <= fca.effective_date_key
    )
WHERE cat.action_code = 'SPLIT'
  AND dd.calendar_year = 2026
ORDER BY dd.full_date, dinst.company_name;

Query 4: Pipeline SLA Breach Report

SELECT
    dd.full_date,
    dp.pipeline_name,
    dp.pipeline_type,
    dp.sla_minutes,
    fcr.duration_seconds / 60.0       AS actual_minutes,
    ds.execution_status,
    ds.quality_status,
    ds.freshness_status,
    CASE
        WHEN fcr.duration_seconds / 60.0 > dp.sla_minutes THEN 'SLA BREACH'
        WHEN fcr.duration_seconds / 60.0 > dp.sla_minutes * 0.8 THEN 'WARNING'
        ELSE 'OK'
    END                               AS sla_status
FROM dw.fact_index_calculation_run fcr
JOIN dw.dim_date dd       ON fcr.date_key = dd.date_key
JOIN dw.dim_pipeline dp   ON fcr.pipeline_key = dp.pipeline_key
JOIN dw.dim_status ds     ON fcr.status_key = ds.status_key
WHERE dd.full_date >= DATEADD(DAY, -7, GETDATE())
  AND (ds.execution_status <> 'Success' OR fcr.duration_seconds / 60.0 > dp.sla_minutes)
ORDER BY dd.full_date DESC, dp.pipeline_name;

Query 5: Sector Rotation Over Time (Using Aggregate Table)

-- How have sector weights shifted across the last 4 rebalancing dates?
SELECT
    dd.full_date                       AS rebalancing_date,
    di.index_name,
    ds.gics_sector_name,
    asw.total_weight_pct,
    asw.constituent_count,
    asw.total_weight_pct - LAG(asw.total_weight_pct) OVER (
        PARTITION BY di.index_key, ds.sector_key
        ORDER BY dd.full_date
    )                                  AS weight_change_pct
FROM dw.agg_sector_weight asw
JOIN dw.dim_date dd    ON asw.date_key = dd.date_key
JOIN dw.dim_index di   ON asw.index_key = di.index_key AND di.is_current = 1
JOIN dw.dim_sector ds  ON asw.sector_key = ds.sector_key
WHERE di.index_code = 'GLBL_EQ_500'
  AND dd.full_date IN (
      SELECT DISTINCT TOP 4 dd2.full_date
      FROM dw.agg_sector_weight asw2
      JOIN dw.dim_date dd2 ON asw2.date_key = dd2.date_key
      WHERE asw2.index_key = di.index_key
      ORDER BY dd2.full_date DESC
  )
ORDER BY dd.full_date, asw.total_weight_pct DESC;

Common Dimensional Modeling Pitfalls and Anti-Patterns

Anti-patterns to avoid

1. Declaring the grain too late (or not at all) If you design fact and dimension tables before nailing down the grain, you will end up with rows that mean different things — some at the daily level, some at the monthly level — in the same table. Every fact table gets exactly one grain statement.

2. Using natural keys as foreign keys in fact tables Natural keys (ISIN, ticker, index_code) change or have inconsistent formats. Always use integer surrogate keys in fact tables. Natural keys belong in dimension tables for lookups.

3. Putting descriptive text in fact tables index_name, company_name, sector_name — these are dimension attributes. If you see a VARCHAR column in a fact table that is not a degenerate dimension, move it to a dimension.

4. Storing derived metrics as facts when they can be calculated Do not store monthly_return_pct in a daily fact table. It does not live at the daily grain. Either calculate it at query time or put it in a separate aggregate table at the monthly grain.

5. Over-snowflaking Normalizing every hierarchy into separate tables adds joins and complexity. For an index provider warehouse with typical data volumes (millions of fact rows, thousands of dimension rows), star schemas with denormalized dimensions are faster and simpler.

6. Ignoring SCD requirements Defaulting everything to Type 1 (overwrite) destroys historical context. If a company changes sectors, you lose the ability to accurately reproduce past index compositions. Decide the SCD type per attribute, document it, and enforce it in ETL.

7. Missing the unknown / placeholder dimension member When a fact row arrives before its dimension data, the ETL fails or — worse — silently drops the row. Always have a strategy for late-arriving dimensions.

Safe Pattern: Pre-Flight Checklist Before Schema Promotion

Before promoting any new fact or dimension table to production, run through: (1) grain written in the table description, (2) uniqueness assertion on the grain key in CI, (3) all FK columns are integer surrogate keys, (4) no VARCHAR descriptive columns in the fact table, (5) each attribute has an explicit SCD type documented, (6) an unknown/default dimension member (-1 or 0 surrogate key) exists for late-arriving handling. Block the deploy if any check fails.


Summary

Dimensional modeling remains the most effective technique for structuring analytical data. The Kimball four-step process — select the business process, declare the grain, identify dimensions, identify facts — provides a repeatable framework that scales from a single-process data mart to an enterprise warehouse.

For an index provider, the core model consists of four fact tables (fact_index_valuation, fact_constituent_weight, fact_corporate_action, fact_index_calculation_run) surrounded by eight dimension tables (dim_date, dim_index, dim_instrument, dim_sector, dim_currency, dim_corporate_action_type, dim_pipeline, dim_status). Conformed dimensions (dim_date, dim_instrument) are shared across fact tables via the enterprise bus matrix. Bridge tables resolve many-to-many relationships. Factless fact tables capture eligibility and events. Aggregate tables accelerate dashboard queries.

Physical implementation varies by platform: SQL Server uses clustered columnstore indexes, partitioning, and PAGE compression on dimensions; BigQuery uses partitioning, clustering, nested STRUCT fields, and materialized views. dbt provides the transformation framework, organizing models into staging, intermediate, and mart layers with built-in lineage and testing.

The key decisions that determine model quality are: grain declaration, SCD type selection per attribute, and conformed dimension alignment across fact tables. Get these right and the rest follows.