Skip to content

Medallion Architecture

Advanced

Quick Reference

sql
-- Bronze Layer: Raw data ingestion (append-only)
CREATE TABLE bronze_customer_events (
    event_id BIGSERIAL PRIMARY KEY,
    event_timestamp TIMESTAMP NOT NULL,
    event_type VARCHAR(50),
    source_system VARCHAR(100),
    raw_payload JSONB,
    ingestion_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    file_name VARCHAR(500),
    file_row_number BIGINT
);

-- Silver Layer: Cleaned and validated data
CREATE TABLE silver_customers (
    customer_id VARCHAR(50) PRIMARY KEY,
    customer_name VARCHAR(200),
    email VARCHAR(200),
    phone VARCHAR(50),
    address VARCHAR(200),
    city VARCHAR(100),
    state VARCHAR(50),
    postal_code VARCHAR(20),
    country VARCHAR(50),
    segment VARCHAR(50),

    -- Audit columns
    valid_from TIMESTAMP NOT NULL,
    valid_to TIMESTAMP,
    is_current BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    source_system VARCHAR(100),
    data_quality_score DECIMAL(5,2)
);

-- Gold Layer: Business-level aggregates and curated datasets
CREATE TABLE gold_customer_lifetime_value (
    customer_id VARCHAR(50) PRIMARY KEY,
    segment VARCHAR(50),
    first_purchase_date DATE,
    last_purchase_date DATE,
    total_orders INTEGER,
    total_revenue DECIMAL(12,2),
    total_profit DECIMAL(12,2),
    average_order_value DECIMAL(10,2),
    lifetime_value DECIMAL(12,2),
    customer_tenure_days INTEGER,
    churn_risk_score DECIMAL(5,2),
    preferred_category VARCHAR(100),

    -- Metadata
    calculated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    calculation_version VARCHAR(20)
);

-- Query pattern: Bronze to Silver transformation
INSERT INTO silver_customers (
    customer_id, customer_name, email, phone,
    address, city, state, postal_code, country, segment,
    valid_from, source_system
)
SELECT DISTINCT ON (raw_payload->>'customer_id')
    raw_payload->>'customer_id' as customer_id,
    TRIM(raw_payload->>'name') as customer_name,
    LOWER(TRIM(raw_payload->>'email')) as email,
    regexp_replace(raw_payload->>'phone', '[^0-9]', '', 'g') as phone,
    raw_payload->>'address' as address,
    raw_payload->>'city' as city,
    raw_payload->>'state' as state,
    raw_payload->>'postal_code' as postal_code,
    COALESCE(raw_payload->>'country', 'USA') as country,
    raw_payload->>'segment' as segment,
    event_timestamp as valid_from,
    source_system
FROM bronze_customer_events
WHERE event_type = 'customer_created'
  AND raw_payload IS NOT NULL
  AND raw_payload->>'customer_id' IS NOT NULL
ORDER BY raw_payload->>'customer_id', event_timestamp DESC
ON CONFLICT (customer_id) DO UPDATE
SET customer_name = EXCLUDED.customer_name,
    email = EXCLUDED.email,
    updated_at = CURRENT_TIMESTAMP;

-- Query pattern: Silver to Gold aggregation
INSERT INTO gold_customer_lifetime_value
SELECT
    c.customer_id,
    c.segment,
    MIN(o.order_date) as first_purchase_date,
    MAX(o.order_date) as last_purchase_date,
    COUNT(DISTINCT o.order_id) as total_orders,
    SUM(o.total_amount) as total_revenue,
    SUM(o.profit_amount) as total_profit,
    AVG(o.total_amount) as average_order_value,
    SUM(o.total_amount) * 1.2 as lifetime_value,  -- Simple LTV calculation
    CURRENT_DATE - MIN(o.order_date) as customer_tenure_days,
    CASE
        WHEN MAX(o.order_date) < CURRENT_DATE - 90 THEN 0.8
        WHEN MAX(o.order_date) < CURRENT_DATE - 60 THEN 0.5
        ELSE 0.1
    END as churn_risk_score,
    MODE() WITHIN GROUP (ORDER BY p.category) as preferred_category,
    CURRENT_TIMESTAMP as calculated_at,
    'v1.0' as calculation_version
FROM silver_customers c
JOIN silver_orders o ON c.customer_id = o.customer_id
JOIN silver_order_items oi ON o.order_id = oi.order_id
JOIN silver_products p ON oi.product_id = p.product_id
WHERE c.is_current = true
GROUP BY c.customer_id, c.segment
ON CONFLICT (customer_id) DO UPDATE
SET total_orders = EXCLUDED.total_orders,
    total_revenue = EXCLUDED.total_revenue,
    last_purchase_date = EXCLUDED.last_purchase_date,
    calculated_at = CURRENT_TIMESTAMP;

Overview

The Medallion Architecture (also known as Bronze-Silver-Gold or Multi-Hop Architecture) is a data design pattern used in modern data lakehouses to organize data in a progressive manner through multiple layers of refinement. This pattern was popularized by Databricks for use with Delta Lake and has become a standard approach for building robust, scalable data platforms.

The architecture progressively improves data quality and structure through three distinct layers:

  • Bronze Layer (Raw): Ingests raw data from source systems in its original format with minimal transformation, preserving complete data lineage
  • Silver Layer (Refined): Cleanses, validates, and standardizes data, applying business rules and deduplication
  • Gold Layer (Curated): Aggregates and enriches data into business-level datasets optimized for analytics and reporting

This multi-tiered approach provides incremental ETL, data quality gates, schema evolution, and reprocessing capabilities while maintaining a clear separation of concerns between raw data preservation and business logic.

mermaid
graph LR
    subgraph Sources["Data Sources"]
        S1[(Operational<br/>Databases)]
        S2[APIs &<br/>Webhooks]
        S3[Files<br/>CSV/JSON]
        S4[Streaming<br/>Kafka/Kinesis]
        S5[SaaS<br/>Applications]
    end

    subgraph Bronze["🥉 Bronze Layer (Raw Zone)"]
        B1[Raw Customer Data<br/>JSON/Parquet]
        B2[Raw Order Events<br/>Append-Only]
        B3[Raw Product Feed<br/>Complete History]
        B4[Raw Clickstream<br/>Event Stream]
    end

    subgraph Silver["🥈 Silver Layer (Refined Zone)"]
        SL1[Validated Customers<br/>Deduplicated & Cleaned]
        SL2[Validated Orders<br/>Type-Safe Schema]
        SL3[Validated Products<br/>Enriched Attributes]
        SL4[Validated Sessions<br/>Sessionized Events]
    end

    subgraph Gold["🥇 Gold Layer (Curated Zone)"]
        G1[Customer 360<br/>Complete Profile]
        G2[Sales Analytics<br/>Star Schema]
        G3[Product Performance<br/>Aggregated Metrics]
        G4[User Behavior<br/>ML Features]
        G5[Executive Dashboard<br/>KPIs & Trends]
    end

    subgraph Consumption["Consumption Layer"]
        C1[BI Tools<br/>Tableau/PowerBI]
        C2[ML Models<br/>Training & Inference]
        C3[Reports &<br/>Dashboards]
        C4[APIs<br/>Data Products]
    end

    S1 --> B1
    S2 --> B2
    S3 --> B3
    S4 --> B4
    S5 --> B1

    B1 --> SL1
    B2 --> SL2
    B3 --> SL3
    B4 --> SL4

    SL1 --> G1
    SL1 --> G2
    SL2 --> G2
    SL3 --> G3
    SL4 --> G4
    SL1 --> G4
    G1 --> G5
    G2 --> G5
    G3 --> G5

    G1 --> C1
    G2 --> C1
    G3 --> C2
    G4 --> C2
    G5 --> C3
    G1 --> C4
    G2 --> C4

    style B1 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style B2 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style B3 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style B4 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style SL1 fill:#c0c0c0,stroke:#808080,color:#000
    style SL2 fill:#c0c0c0,stroke:#808080,color:#000
    style SL3 fill:#c0c0c0,stroke:#808080,color:#000
    style SL4 fill:#c0c0c0,stroke:#808080,color:#000
    style G1 fill:#ffd700,stroke:#daa520,color:#000
    style G2 fill:#ffd700,stroke:#daa520,color:#000
    style G3 fill:#ffd700,stroke:#daa520,color:#000
    style G4 fill:#ffd700,stroke:#daa520,color:#000
    style G5 fill:#ffd700,stroke:#daa520,color:#000

Core Principles

1. Bronze Layer - Raw Data Preservation

The Bronze layer serves as the landing zone for all source data. Data is ingested in its original format with minimal transformation, creating an immutable audit trail.

Characteristics:

  • Append-only: Never delete or update existing records
  • Schema-on-read: Store data as-is (JSON, CSV, Parquet, etc.)
  • Complete lineage: Track source system, file name, ingestion time
  • No business logic: Preserve exactly what was received
  • Batch and streaming: Support both ingestion patterns
sql
-- Bronze table structure: Raw ingestion
CREATE TABLE bronze_orders_raw (
    -- Unique identifier
    bronze_id BIGSERIAL PRIMARY KEY,

    -- Raw data payload
    raw_data JSONB NOT NULL,

    -- Source tracking
    source_system VARCHAR(100) NOT NULL,
    source_file VARCHAR(500),
    source_row_number BIGINT,

    -- Ingestion metadata
    ingestion_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    ingestion_batch_id VARCHAR(100),
    ingestion_method VARCHAR(50), -- 'batch', 'streaming', 'api'

    -- Data lineage
    source_timestamp TIMESTAMP,
    source_checksum VARCHAR(64)
);

-- Create index for efficient querying
CREATE INDEX idx_bronze_orders_ingestion
ON bronze_orders_raw(ingestion_timestamp);

CREATE INDEX idx_bronze_orders_source
ON bronze_orders_raw(source_system, ingestion_batch_id);

-- Insert raw data (example: batch ingestion from CSV)
INSERT INTO bronze_orders_raw (
    raw_data,
    source_system,
    source_file,
    source_row_number,
    ingestion_batch_id,
    ingestion_method,
    source_timestamp
)
SELECT
    row_to_json(staging_orders)::jsonb as raw_data,
    'ERP_SYSTEM' as source_system,
    'orders_20241120.csv' as source_file,
    row_number() OVER () as source_row_number,
    'batch_20241120_001' as ingestion_batch_id,
    'batch' as ingestion_method,
    CURRENT_TIMESTAMP as source_timestamp
FROM staging_orders;

-- Streaming ingestion pattern (Kafka, Kinesis, etc.)
CREATE TABLE bronze_events_stream (
    event_id BIGSERIAL PRIMARY KEY,
    event_type VARCHAR(100),
    event_payload JSONB,
    event_timestamp TIMESTAMP,
    partition_key VARCHAR(100),
    offset_position BIGINT,
    ingestion_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Partition by ingestion date for efficient querying
CREATE TABLE bronze_orders_partitioned (
    bronze_id BIGSERIAL,
    raw_data JSONB,
    source_system VARCHAR(100),
    ingestion_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) PARTITION BY RANGE (ingestion_timestamp);

-- Create monthly partitions
CREATE TABLE bronze_orders_202401
PARTITION OF bronze_orders_partitioned
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

CREATE TABLE bronze_orders_202402
PARTITION OF bronze_orders_partitioned
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

2. Silver Layer - Cleaned and Validated Data

The Silver layer contains cleansed, validated, and deduplicated data. This is where data quality rules are applied, types are enforced, and business keys are established.

Characteristics:

  • Strongly typed: Enforce proper data types and schemas
  • Validated: Apply data quality rules and constraints
  • Deduplicated: Remove duplicates based on business keys
  • Standardized: Normalize formats (dates, phone numbers, addresses)
  • Merged sources: Combine data from multiple bronze sources
  • Change tracking: Maintain history with SCD Type 2 or temporal tables
sql
-- Silver table structure: Cleaned and validated
CREATE TABLE silver_customers (
    -- Surrogate key
    customer_key BIGSERIAL PRIMARY KEY,

    -- Business key
    customer_id VARCHAR(50) UNIQUE NOT NULL,

    -- Customer attributes (cleaned and validated)
    customer_name VARCHAR(200) NOT NULL,
    email VARCHAR(200),
    phone VARCHAR(20),  -- Standardized format

    -- Address (normalized)
    street_address VARCHAR(200),
    city VARCHAR(100),
    state VARCHAR(50),
    postal_code VARCHAR(20),
    country VARCHAR(50) DEFAULT 'USA',

    -- Business attributes
    segment VARCHAR(50),
    account_status VARCHAR(50),

    -- Data quality tracking
    data_quality_score DECIMAL(5,2),
    quality_checks_passed INTEGER,
    quality_checks_failed INTEGER,

    -- Change tracking (SCD Type 2)
    valid_from TIMESTAMP NOT NULL,
    valid_to TIMESTAMP,
    is_current BOOLEAN DEFAULT true,

    -- Audit columns
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    created_by VARCHAR(100) DEFAULT 'etl_process',

    -- Source tracking
    source_system VARCHAR(100),
    source_record_id VARCHAR(100),

    -- Constraints
    CONSTRAINT chk_email_format CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
    CONSTRAINT chk_segment CHECK (segment IN ('Premium', 'Standard', 'Basic', 'Unknown')),
    CONSTRAINT chk_valid_period CHECK (valid_from < COALESCE(valid_to, '9999-12-31'))
);

-- Indexes for performance
CREATE INDEX idx_silver_customers_current
ON silver_customers(customer_id)
WHERE is_current = true;

CREATE INDEX idx_silver_customers_validity
ON silver_customers(valid_from, valid_to);

CREATE INDEX idx_silver_customers_segment
ON silver_customers(segment)
WHERE is_current = true;

-- Bronze to Silver transformation with data quality
CREATE OR REPLACE FUNCTION load_silver_customers()
RETURNS TABLE(
    inserted_count INTEGER,
    updated_count INTEGER,
    failed_count INTEGER
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_inserted INTEGER := 0;
    v_updated INTEGER := 0;
    v_failed INTEGER := 0;
BEGIN
    -- Insert new customers from bronze
    WITH validated_data AS (
        SELECT
            raw_data->>'customer_id' as customer_id,
            TRIM(raw_data->>'name') as customer_name,
            LOWER(TRIM(raw_data->>'email')) as email,
            regexp_replace(raw_data->>'phone', '[^0-9]', '', 'g') as phone,
            raw_data->>'address' as street_address,
            raw_data->>'city' as city,
            UPPER(raw_data->>'state') as state,
            raw_data->>'postal_code' as postal_code,
            COALESCE(raw_data->>'country', 'USA') as country,
            CASE
                WHEN (raw_data->>'segment') IN ('Premium', 'Standard', 'Basic')
                THEN raw_data->>'segment'
                ELSE 'Unknown'
            END as segment,
            source_system,
            ingestion_timestamp,
            -- Data quality scoring
            (
                CASE WHEN raw_data->>'name' IS NOT NULL AND LENGTH(TRIM(raw_data->>'name')) > 0 THEN 20 ELSE 0 END +
                CASE WHEN raw_data->>'email' ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN 30 ELSE 0 END +
                CASE WHEN raw_data->>'phone' IS NOT NULL THEN 20 ELSE 0 END +
                CASE WHEN raw_data->>'city' IS NOT NULL THEN 15 ELSE 0 END +
                CASE WHEN raw_data->>'state' IS NOT NULL THEN 15 ELSE 0 END
            ) as quality_score
        FROM bronze_customer_events
        WHERE raw_data->>'customer_id' IS NOT NULL
          AND NOT EXISTS (
              SELECT 1 FROM silver_customers sc
              WHERE sc.customer_id = raw_data->>'customer_id'
                AND sc.is_current = true
          )
    )
    INSERT INTO silver_customers (
        customer_id, customer_name, email, phone,
        street_address, city, state, postal_code, country,
        segment, data_quality_score, source_system, valid_from
    )
    SELECT
        customer_id, customer_name, email, phone,
        street_address, city, state, postal_code, country,
        segment, quality_score, source_system, ingestion_timestamp
    FROM validated_data
    WHERE quality_score >= 50;  -- Minimum quality threshold

    GET DIAGNOSTICS v_inserted = ROW_COUNT;

    -- Handle updates (SCD Type 2)
    -- Expire current records that have changed
    WITH changed_records AS (
        SELECT DISTINCT
            sc.customer_key,
            b.raw_data->>'customer_id' as customer_id
        FROM silver_customers sc
        JOIN bronze_customer_events b
            ON sc.customer_id = b.raw_data->>'customer_id'
        WHERE sc.is_current = true
          AND (
              sc.customer_name != TRIM(b.raw_data->>'name')
              OR sc.email != LOWER(TRIM(b.raw_data->>'email'))
              OR sc.segment != b.raw_data->>'segment'
          )
    )
    UPDATE silver_customers sc
    SET is_current = false,
        valid_to = CURRENT_TIMESTAMP,
        updated_at = CURRENT_TIMESTAMP
    FROM changed_records cr
    WHERE sc.customer_key = cr.customer_key;

    GET DIAGNOSTICS v_updated = ROW_COUNT;

    RETURN QUERY SELECT v_inserted, v_updated, v_failed;
END;
$$;

-- Deduplication logic
CREATE TABLE silver_orders (
    order_key BIGSERIAL PRIMARY KEY,
    order_id VARCHAR(50) UNIQUE NOT NULL,
    customer_id VARCHAR(50) REFERENCES silver_customers(customer_id),
    order_date DATE NOT NULL,
    order_status VARCHAR(50),
    total_amount DECIMAL(12,2),

    -- Deduplication tracking
    source_record_count INTEGER DEFAULT 1,
    first_seen_at TIMESTAMP,
    last_seen_at TIMESTAMP,

    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Merge with deduplication
INSERT INTO silver_orders (
    order_id, customer_id, order_date,
    order_status, total_amount,
    first_seen_at, last_seen_at
)
SELECT
    raw_data->>'order_id',
    raw_data->>'customer_id',
    (raw_data->>'order_date')::DATE,
    raw_data->>'status',
    (raw_data->>'total')::DECIMAL,
    MIN(ingestion_timestamp),
    MAX(ingestion_timestamp)
FROM bronze_orders_raw
GROUP BY
    raw_data->>'order_id',
    raw_data->>'customer_id',
    raw_data->>'order_date',
    raw_data->>'status',
    raw_data->>'total'
ON CONFLICT (order_id) DO UPDATE
SET last_seen_at = EXCLUDED.last_seen_at,
    updated_at = CURRENT_TIMESTAMP;

3. Gold Layer - Business-Level Curated Data

The Gold layer contains business-level aggregates, denormalized datasets, and feature stores optimized for specific use cases like reporting, analytics, and machine learning.

Characteristics:

  • Business-oriented: Organized by business domain or use case
  • Highly aggregated: Pre-computed metrics and KPIs
  • Denormalized: Optimized for query performance
  • Feature-rich: Include calculated fields and derived metrics
  • Multi-source: Combine data from multiple silver tables
  • Performance-optimized: Materialized views, star schemas, OLAP cubes
sql
-- Gold table: Customer 360 view
CREATE TABLE gold_customer_360 (
    customer_id VARCHAR(50) PRIMARY KEY,

    -- Profile information
    customer_name VARCHAR(200),
    email VARCHAR(200),
    phone VARCHAR(20),
    full_address TEXT,
    segment VARCHAR(50),

    -- Purchase behavior
    first_purchase_date DATE,
    last_purchase_date DATE,
    total_orders INTEGER,
    total_items_purchased INTEGER,
    total_revenue DECIMAL(12,2),
    total_profit DECIMAL(12,2),
    average_order_value DECIMAL(10,2),
    average_items_per_order DECIMAL(6,2),

    -- Customer value metrics
    lifetime_value DECIMAL(12,2),
    customer_tenure_days INTEGER,
    purchase_frequency_days DECIMAL(6,2),
    recency_days INTEGER,

    -- Product preferences
    preferred_category VARCHAR(100),
    preferred_brand VARCHAR(100),
    favorite_products TEXT[], -- Array of product names

    -- Engagement metrics
    email_open_rate DECIMAL(5,2),
    email_click_rate DECIMAL(5,2),
    support_tickets_count INTEGER,
    nps_score INTEGER,

    -- Predictive scores
    churn_risk_score DECIMAL(5,2),
    upsell_propensity_score DECIMAL(5,2),
    credit_risk_score DECIMAL(5,2),

    -- Metadata
    calculated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    calculation_version VARCHAR(20),
    data_quality_rating VARCHAR(20)
);

-- Populate Customer 360 from Silver layer
INSERT INTO gold_customer_360
WITH customer_orders AS (
    SELECT
        o.customer_id,
        MIN(o.order_date) as first_purchase_date,
        MAX(o.order_date) as last_purchase_date,
        COUNT(DISTINCT o.order_id) as total_orders,
        SUM(oi.quantity) as total_items,
        SUM(o.total_amount) as total_revenue,
        SUM(o.profit_amount) as total_profit,
        AVG(o.total_amount) as avg_order_value
    FROM silver_orders o
    JOIN silver_order_items oi ON o.order_id = oi.order_id
    GROUP BY o.customer_id
),
product_preferences AS (
    SELECT
        o.customer_id,
        MODE() WITHIN GROUP (ORDER BY p.category) as preferred_category,
        MODE() WITHIN GROUP (ORDER BY p.brand) as preferred_brand,
        ARRAY_AGG(DISTINCT p.product_name ORDER BY p.product_name)
            FILTER (WHERE p.product_name IS NOT NULL) as favorite_products
    FROM silver_orders o
    JOIN silver_order_items oi ON o.order_id = oi.order_id
    JOIN silver_products p ON oi.product_id = p.product_id
    GROUP BY o.customer_id
),
engagement_metrics AS (
    SELECT
        customer_id,
        AVG(CASE WHEN email_opened THEN 1.0 ELSE 0.0 END) * 100 as email_open_rate,
        AVG(CASE WHEN email_clicked THEN 1.0 ELSE 0.0 END) * 100 as email_click_rate
    FROM silver_email_events
    GROUP BY customer_id
),
support_metrics AS (
    SELECT
        customer_id,
        COUNT(*) as support_tickets,
        AVG(satisfaction_score) as avg_satisfaction
    FROM silver_support_tickets
    GROUP BY customer_id
)
SELECT
    c.customer_id,
    c.customer_name,
    c.email,
    c.phone,
    CONCAT_WS(', ', c.street_address, c.city, c.state, c.postal_code) as full_address,
    c.segment,

    co.first_purchase_date,
    co.last_purchase_date,
    COALESCE(co.total_orders, 0) as total_orders,
    COALESCE(co.total_items, 0) as total_items_purchased,
    COALESCE(co.total_revenue, 0) as total_revenue,
    COALESCE(co.total_profit, 0) as total_profit,
    co.avg_order_value,
    COALESCE(co.total_items::DECIMAL / NULLIF(co.total_orders, 0), 0) as average_items_per_order,

    -- Customer value calculations
    COALESCE(co.total_revenue * 1.2, 0) as lifetime_value,
    CURRENT_DATE - co.first_purchase_date as customer_tenure_days,
    (CURRENT_DATE - co.first_purchase_date)::DECIMAL / NULLIF(co.total_orders, 0) as purchase_frequency_days,
    CURRENT_DATE - co.last_purchase_date as recency_days,

    pp.preferred_category,
    pp.preferred_brand,
    pp.favorite_products,

    COALESCE(em.email_open_rate, 0) as email_open_rate,
    COALESCE(em.email_click_rate, 0) as email_click_rate,
    COALESCE(sm.support_tickets, 0) as support_tickets_count,
    sm.avg_satisfaction::INTEGER as nps_score,

    -- Predictive scores (simplified examples)
    CASE
        WHEN CURRENT_DATE - co.last_purchase_date > 180 THEN 0.9
        WHEN CURRENT_DATE - co.last_purchase_date > 90 THEN 0.6
        WHEN CURRENT_DATE - co.last_purchase_date > 30 THEN 0.3
        ELSE 0.1
    END as churn_risk_score,

    CASE
        WHEN c.segment = 'Premium' AND co.total_orders > 10 THEN 0.8
        WHEN c.segment = 'Standard' AND co.total_orders > 5 THEN 0.5
        ELSE 0.2
    END as upsell_propensity_score,

    CASE
        WHEN co.total_orders > 20 AND co.avg_order_value > 100 THEN 0.9
        WHEN co.total_orders > 10 THEN 0.7
        ELSE 0.5
    END as credit_risk_score,

    CURRENT_TIMESTAMP as calculated_at,
    'v2.0' as calculation_version,
    CASE
        WHEN c.data_quality_score >= 90 THEN 'Excellent'
        WHEN c.data_quality_score >= 70 THEN 'Good'
        WHEN c.data_quality_score >= 50 THEN 'Fair'
        ELSE 'Poor'
    END as data_quality_rating
FROM silver_customers c
LEFT JOIN customer_orders co ON c.customer_id = co.customer_id
LEFT JOIN product_preferences pp ON c.customer_id = pp.customer_id
LEFT JOIN engagement_metrics em ON c.customer_id = em.customer_id
LEFT JOIN support_metrics sm ON c.customer_id = sm.customer_id
WHERE c.is_current = true
ON CONFLICT (customer_id) DO UPDATE
SET customer_name = EXCLUDED.customer_name,
    total_orders = EXCLUDED.total_orders,
    total_revenue = EXCLUDED.total_revenue,
    calculated_at = CURRENT_TIMESTAMP;

-- Gold table: Sales Analytics (Star Schema)
CREATE TABLE gold_sales_daily_summary (
    summary_date DATE,
    product_category VARCHAR(100),
    customer_segment VARCHAR(50),
    region VARCHAR(50),

    -- Metrics
    total_orders INTEGER,
    total_revenue DECIMAL(12,2),
    total_profit DECIMAL(12,2),
    total_units_sold INTEGER,
    unique_customers INTEGER,

    -- Calculated KPIs
    average_order_value DECIMAL(10,2),
    profit_margin_pct DECIMAL(5,2),
    revenue_per_customer DECIMAL(10,2),
    units_per_order DECIMAL(6,2),

    -- Year-over-year comparisons
    revenue_yoy_growth_pct DECIMAL(5,2),
    orders_yoy_growth_pct DECIMAL(5,2),

    calculated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

    PRIMARY KEY (summary_date, product_category, customer_segment, region)
);

-- Populate daily summary
INSERT INTO gold_sales_daily_summary
WITH daily_sales AS (
    SELECT
        o.order_date as summary_date,
        p.category as product_category,
        c.segment as customer_segment,
        c.state as region,
        COUNT(DISTINCT o.order_id) as total_orders,
        SUM(o.total_amount) as total_revenue,
        SUM(o.profit_amount) as total_profit,
        SUM(oi.quantity) as total_units,
        COUNT(DISTINCT o.customer_id) as unique_customers
    FROM silver_orders o
    JOIN silver_order_items oi ON o.order_id = oi.order_id
    JOIN silver_products p ON oi.product_id = p.product_id
    JOIN silver_customers c ON o.customer_id = c.customer_id
    WHERE c.is_current = true
    GROUP BY o.order_date, p.category, c.segment, c.state
),
prior_year AS (
    SELECT
        summary_date + INTERVAL '1 year' as current_date,
        product_category,
        customer_segment,
        region,
        total_revenue as prior_revenue,
        total_orders as prior_orders
    FROM gold_sales_daily_summary
    WHERE summary_date >= CURRENT_DATE - INTERVAL '2 years'
)
SELECT
    ds.summary_date,
    ds.product_category,
    ds.customer_segment,
    ds.region,
    ds.total_orders,
    ds.total_revenue,
    ds.total_profit,
    ds.total_units,
    ds.unique_customers,

    ds.total_revenue / NULLIF(ds.total_orders, 0) as average_order_value,
    (ds.total_profit / NULLIF(ds.total_revenue, 0)) * 100 as profit_margin_pct,
    ds.total_revenue / NULLIF(ds.unique_customers, 0) as revenue_per_customer,
    ds.total_units::DECIMAL / NULLIF(ds.total_orders, 0) as units_per_order,

    ((ds.total_revenue - py.prior_revenue) / NULLIF(py.prior_revenue, 0)) * 100 as revenue_yoy_growth_pct,
    ((ds.total_orders - py.prior_orders)::DECIMAL / NULLIF(py.prior_orders, 0)) * 100 as orders_yoy_growth_pct,

    CURRENT_TIMESTAMP as calculated_at
FROM daily_sales ds
LEFT JOIN prior_year py
    ON ds.summary_date = py.current_date
    AND ds.product_category = py.product_category
    AND ds.customer_segment = py.customer_segment
    AND ds.region = py.region
ON CONFLICT (summary_date, product_category, customer_segment, region) DO UPDATE
SET total_orders = EXCLUDED.total_orders,
    total_revenue = EXCLUDED.total_revenue,
    calculated_at = CURRENT_TIMESTAMP;

-- Gold table: ML Feature Store
CREATE TABLE gold_customer_features_ml (
    customer_id VARCHAR(50) PRIMARY KEY,
    feature_version VARCHAR(20),

    -- Demographic features
    customer_tenure_months INTEGER,
    account_age_days INTEGER,

    -- Behavioral features
    total_orders_lifetime INTEGER,
    orders_last_30d INTEGER,
    orders_last_90d INTEGER,
    orders_last_365d INTEGER,

    revenue_lifetime DECIMAL(12,2),
    revenue_last_30d DECIMAL(12,2),
    revenue_last_90d DECIMAL(12,2),
    revenue_last_365d DECIMAL(12,2),

    avg_order_value_lifetime DECIMAL(10,2),
    avg_order_value_last_90d DECIMAL(10,2),

    days_since_last_order INTEGER,
    avg_days_between_orders DECIMAL(6,2),

    -- Product affinity features
    unique_categories_purchased INTEGER,
    unique_products_purchased INTEGER,
    category_diversity_score DECIMAL(5,2),

    -- Engagement features
    email_engagement_score DECIMAL(5,2),
    website_visits_last_30d INTEGER,
    cart_abandonment_rate DECIMAL(5,2),

    -- Target variables
    churn_flag BOOLEAN,
    high_value_flag BOOLEAN,

    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Data Flow and Processing Patterns

Incremental Processing

mermaid
graph TB
    subgraph BronzeIngestion["Bronze Layer - Incremental Ingestion"]
        B1[New Source Data]
        B2[Append to Bronze<br/>No Updates/Deletes]
        B3[Watermark Tracking]
    end

    subgraph SilverProcessing["Silver Layer - Incremental Transformation"]
        S1[Read New Bronze Records<br/>Since Last Watermark]
        S2[Apply Data Quality Rules]
        S3[Deduplication & Merge]
        S4[Update Silver Tables<br/>Upsert Pattern]
        S5[Update Watermark]
    end

    subgraph GoldAggregation["Gold Layer - Incremental Aggregation"]
        G1[Identify Affected Partitions]
        G2[Recompute Aggregates]
        G3[Update Gold Tables<br/>Merge Pattern]
        G4[Refresh Materialized Views]
    end

    B1 --> B2
    B2 --> B3
    B3 --> S1
    S1 --> S2
    S2 --> S3
    S3 --> S4
    S4 --> S5
    S5 --> G1
    G1 --> G2
    G2 --> G3
    G3 --> G4

    style B1 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style B2 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style B3 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style S1 fill:#c0c0c0,stroke:#808080,color:#000
    style S2 fill:#c0c0c0,stroke:#808080,color:#000
    style S3 fill:#c0c0c0,stroke:#808080,color:#000
    style S4 fill:#c0c0c0,stroke:#808080,color:#000
    style S5 fill:#c0c0c0,stroke:#808080,color:#000
    style G1 fill:#ffd700,stroke:#daa520,color:#000
    style G2 fill:#ffd700,stroke:#daa520,color:#000
    style G3 fill:#ffd700,stroke:#daa520,color:#000
    style G4 fill:#ffd700,stroke:#daa520,color:#000
sql
-- Watermark table for incremental processing
CREATE TABLE processing_watermarks (
    pipeline_name VARCHAR(100) PRIMARY KEY,
    layer VARCHAR(20) NOT NULL, -- 'bronze', 'silver', 'gold'
    last_processed_timestamp TIMESTAMP NOT NULL,
    last_processed_id BIGINT,
    rows_processed BIGINT,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Incremental Bronze to Silver processing
CREATE OR REPLACE PROCEDURE process_bronze_to_silver_incremental(
    p_pipeline_name VARCHAR
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_last_watermark TIMESTAMP;
    v_new_watermark TIMESTAMP;
    v_rows_processed BIGINT;
BEGIN
    -- Get last watermark
    SELECT last_processed_timestamp INTO v_last_watermark
    FROM processing_watermarks
    WHERE pipeline_name = p_pipeline_name
      AND layer = 'bronze';

    -- If no watermark, use beginning of time
    v_last_watermark := COALESCE(v_last_watermark, '1900-01-01'::TIMESTAMP);

    -- Process new bronze records
    WITH new_bronze_data AS (
        SELECT *
        FROM bronze_customer_events
        WHERE ingestion_timestamp > v_last_watermark
        ORDER BY ingestion_timestamp
    )
    INSERT INTO silver_customers (
        customer_id, customer_name, email,
        valid_from, source_system
    )
    SELECT DISTINCT ON (raw_payload->>'customer_id')
        raw_payload->>'customer_id',
        TRIM(raw_payload->>'name'),
        LOWER(TRIM(raw_payload->>'email')),
        event_timestamp,
        source_system
    FROM new_bronze_data
    WHERE raw_payload->>'customer_id' IS NOT NULL
    ORDER BY raw_payload->>'customer_id', event_timestamp DESC
    ON CONFLICT (customer_id) DO UPDATE
    SET customer_name = EXCLUDED.customer_name,
        email = EXCLUDED.email,
        updated_at = CURRENT_TIMESTAMP;

    GET DIAGNOSTICS v_rows_processed = ROW_COUNT;

    -- Get new watermark
    SELECT MAX(ingestion_timestamp) INTO v_new_watermark
    FROM bronze_customer_events;

    -- Update watermark
    INSERT INTO processing_watermarks (
        pipeline_name, layer, last_processed_timestamp, rows_processed
    )
    VALUES (p_pipeline_name, 'bronze', v_new_watermark, v_rows_processed)
    ON CONFLICT (pipeline_name) DO UPDATE
    SET last_processed_timestamp = EXCLUDED.last_processed_timestamp,
        rows_processed = EXCLUDED.rows_processed,
        updated_at = CURRENT_TIMESTAMP;

    RAISE NOTICE 'Processed % rows. New watermark: %', v_rows_processed, v_new_watermark;
END;
$$;

-- Incremental Silver to Gold processing
CREATE OR REPLACE PROCEDURE process_silver_to_gold_incremental()
LANGUAGE plpgsql
AS $$
DECLARE
    v_last_watermark TIMESTAMP;
BEGIN
    -- Get customers updated since last run
    SELECT last_processed_timestamp INTO v_last_watermark
    FROM processing_watermarks
    WHERE pipeline_name = 'silver_to_gold_customer360';

    v_last_watermark := COALESCE(v_last_watermark, '1900-01-01'::TIMESTAMP);

    -- Refresh only affected customer records in gold layer
    INSERT INTO gold_customer_360
    SELECT /* customer 360 calculation */
    FROM silver_customers c
    WHERE c.updated_at > v_last_watermark
    ON CONFLICT (customer_id) DO UPDATE
    SET total_orders = EXCLUDED.total_orders,
        total_revenue = EXCLUDED.total_revenue,
        calculated_at = CURRENT_TIMESTAMP;

    -- Update watermark
    UPDATE processing_watermarks
    SET last_processed_timestamp = CURRENT_TIMESTAMP
    WHERE pipeline_name = 'silver_to_gold_customer360';
END;
$$;

Change Data Capture (CDC) Pattern

sql
-- CDC tracking in Silver layer
CREATE TABLE silver_customers_cdc (
    cdc_id BIGSERIAL PRIMARY KEY,
    operation_type VARCHAR(10), -- 'INSERT', 'UPDATE', 'DELETE'
    customer_key BIGINT,
    customer_id VARCHAR(50),

    -- Before image (for updates/deletes)
    old_values JSONB,

    -- After image (for inserts/updates)
    new_values JSONB,

    -- Change metadata
    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    changed_by VARCHAR(100),
    source_transaction_id VARCHAR(100)
);

-- Trigger to capture changes
CREATE OR REPLACE FUNCTION capture_customer_changes()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO silver_customers_cdc (
            operation_type, customer_key, customer_id, new_values
        )
        VALUES (
            'INSERT',
            NEW.customer_key,
            NEW.customer_id,
            row_to_json(NEW)::jsonb
        );
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO silver_customers_cdc (
            operation_type, customer_key, customer_id, old_values, new_values
        )
        VALUES (
            'UPDATE',
            NEW.customer_key,
            NEW.customer_id,
            row_to_json(OLD)::jsonb,
            row_to_json(NEW)::jsonb
        );
        RETURN NEW;
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO silver_customers_cdc (
            operation_type, customer_key, customer_id, old_values
        )
        VALUES (
            'DELETE',
            OLD.customer_key,
            OLD.customer_id,
            row_to_json(OLD)::jsonb
        );
        RETURN OLD;
    END IF;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_customer_cdc
AFTER INSERT OR UPDATE OR DELETE ON silver_customers
FOR EACH ROW EXECUTE FUNCTION capture_customer_changes();

Data Quality Framework

mermaid
graph LR
    subgraph DQChecks["Data Quality Checks"]
        DQ1[Completeness<br/>Non-null required fields]
        DQ2[Validity<br/>Format & Type checks]
        DQ3[Consistency<br/>Business rule validation]
        DQ4[Accuracy<br/>Range & Reference checks]
        DQ5[Uniqueness<br/>Duplicate detection]
        DQ6[Timeliness<br/>Freshness checks]
    end

    subgraph BronzeQuality["Bronze Layer Quality"]
        BQ1[Schema Validation]
        BQ2[File Integrity]
        BQ3[Source Availability]
    end

    subgraph SilverQuality["Silver Layer Quality"]
        SQ1[Type Enforcement]
        SQ2[Referential Integrity]
        SQ3[Deduplication]
        SQ4[Standardization]
    end

    subgraph GoldQuality["Gold Layer Quality"]
        GQ1[Aggregation Accuracy]
        GQ2[Metric Consistency]
        GQ3[SLA Compliance]
    end

    DQ1 --> SQ1
    DQ2 --> SQ1
    DQ3 --> SQ2
    DQ4 --> SQ4
    DQ5 --> SQ3
    DQ6 --> GQ3

    BQ1 --> BQ2
    BQ2 --> BQ3

    SQ1 --> SQ2
    SQ2 --> SQ3
    SQ3 --> SQ4

    GQ1 --> GQ2
    GQ2 --> GQ3

    style BQ1 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style BQ2 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style BQ3 fill:#cd7f32,stroke:#8b5a2b,color:#fff
    style SQ1 fill:#c0c0c0,stroke:#808080,color:#000
    style SQ2 fill:#c0c0c0,stroke:#808080,color:#000
    style SQ3 fill:#c0c0c0,stroke:#808080,color:#000
    style SQ4 fill:#c0c0c0,stroke:#808080,color:#000
    style GQ1 fill:#ffd700,stroke:#daa520,color:#000
    style GQ2 fill:#ffd700,stroke:#daa520,color:#000
    style GQ3 fill:#ffd700,stroke:#daa520,color:#000
sql
-- Data quality rules table
CREATE TABLE data_quality_rules (
    rule_id SERIAL PRIMARY KEY,
    rule_name VARCHAR(100) UNIQUE NOT NULL,
    layer VARCHAR(20) NOT NULL, -- 'bronze', 'silver', 'gold'
    table_name VARCHAR(100) NOT NULL,
    rule_type VARCHAR(50) NOT NULL, -- 'completeness', 'validity', 'consistency', etc.
    rule_sql TEXT NOT NULL,
    severity VARCHAR(20) DEFAULT 'ERROR', -- 'WARNING', 'ERROR', 'CRITICAL'
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Data quality results table
CREATE TABLE data_quality_results (
    result_id BIGSERIAL PRIMARY KEY,
    rule_id INTEGER REFERENCES data_quality_rules(rule_id),
    execution_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    passed BOOLEAN,
    records_checked BIGINT,
    records_failed BIGINT,
    failure_rate DECIMAL(5,2),
    sample_failures JSONB,
    execution_time_ms INTEGER
);

-- Insert quality rules
INSERT INTO data_quality_rules (rule_name, layer, table_name, rule_type, rule_sql, severity)
VALUES
-- Silver layer rules
('customer_email_format', 'silver', 'silver_customers', 'validity',
 'SELECT COUNT(*) FROM silver_customers WHERE is_current = true AND email IS NOT NULL AND email !~ ''^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$''',
 'ERROR'),

('customer_name_completeness', 'silver', 'silver_customers', 'completeness',
 'SELECT COUNT(*) FROM silver_customers WHERE is_current = true AND (customer_name IS NULL OR TRIM(customer_name) = '''')',
 'ERROR'),

('order_total_positive', 'silver', 'silver_orders', 'validity',
 'SELECT COUNT(*) FROM silver_orders WHERE total_amount <= 0',
 'ERROR'),

('order_customer_reference', 'silver', 'silver_orders', 'consistency',
 'SELECT COUNT(*) FROM silver_orders o WHERE NOT EXISTS (SELECT 1 FROM silver_customers c WHERE c.customer_id = o.customer_id AND c.is_current = true)',
 'CRITICAL'),

-- Gold layer rules
('customer360_revenue_consistency', 'gold', 'gold_customer_360', 'consistency',
 'SELECT COUNT(*) FROM gold_customer_360 WHERE total_revenue < 0 OR (total_orders > 0 AND total_revenue = 0)',
 'ERROR'),

('daily_summary_completeness', 'gold', 'gold_sales_daily_summary', 'completeness',
 'SELECT COUNT(*) FROM gold_sales_daily_summary WHERE summary_date >= CURRENT_DATE - 7 AND total_orders = 0',
 'WARNING');

-- Execute data quality checks
CREATE OR REPLACE FUNCTION execute_quality_checks(
    p_layer VARCHAR DEFAULT NULL
)
RETURNS TABLE (
    rule_name VARCHAR,
    passed BOOLEAN,
    records_failed BIGINT,
    severity VARCHAR
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_rule RECORD;
    v_failed_count BIGINT;
    v_execution_start TIMESTAMP;
    v_execution_time INTEGER;
BEGIN
    FOR v_rule IN
        SELECT * FROM data_quality_rules
        WHERE is_active = true
          AND (p_layer IS NULL OR layer = p_layer)
        ORDER BY severity DESC, rule_id
    LOOP
        v_execution_start := CLOCK_TIMESTAMP();

        -- Execute the quality check SQL
        EXECUTE v_rule.rule_sql INTO v_failed_count;

        v_execution_time := EXTRACT(EPOCH FROM (CLOCK_TIMESTAMP() - v_execution_start)) * 1000;

        -- Log results
        INSERT INTO data_quality_results (
            rule_id, passed, records_failed,
            failure_rate, execution_time_ms
        )
        VALUES (
            v_rule.rule_id,
            v_failed_count = 0,
            v_failed_count,
            0, -- Would calculate based on total records
            v_execution_time
        );

        -- Return results
        RETURN QUERY SELECT
            v_rule.rule_name,
            v_failed_count = 0,
            v_failed_count,
            v_rule.severity;

        -- If critical failure, raise exception
        IF v_rule.severity = 'CRITICAL' AND v_failed_count > 0 THEN
            RAISE EXCEPTION 'CRITICAL data quality failure in rule: %. % records failed.',
                v_rule.rule_name, v_failed_count;
        END IF;
    END LOOP;
END;
$$;

-- Run quality checks
SELECT * FROM execute_quality_checks('silver');

Benefits

1. Incremental ETL and Reprocessing

The layered architecture enables efficient incremental processing and easy reprocessing when logic changes.

sql
-- Reprocess specific time period in Silver layer
DELETE FROM silver_customers
WHERE valid_from >= '2024-01-01'
  AND valid_from < '2024-02-01';

-- Reload from Bronze
INSERT INTO silver_customers
SELECT /* transformation logic */
FROM bronze_customer_events
WHERE ingestion_timestamp >= '2024-01-01'
  AND ingestion_timestamp < '2024-02-01';

-- Gold layer automatically picks up changes via incremental processing
CALL process_silver_to_gold_incremental();

2. Data Lineage and Auditability

Complete traceability from source to consumption layer.

sql
-- Trace data lineage for a specific customer
WITH gold_record AS (
    SELECT customer_id, calculated_at
    FROM gold_customer_360
    WHERE customer_id = 'CUST-12345'
),
silver_records AS (
    SELECT customer_key, customer_id, valid_from, source_system
    FROM silver_customers
    WHERE customer_id = 'CUST-12345'
),
bronze_records AS (
    SELECT bronze_id, ingestion_timestamp, source_file, raw_data
    FROM bronze_customer_events
    WHERE raw_data->>'customer_id' = 'CUST-12345'
)
SELECT
    'Gold' as layer,
    g.customer_id,
    NULL as valid_from,
    g.calculated_at as timestamp
FROM gold_record g
UNION ALL
SELECT
    'Silver' as layer,
    s.customer_id,
    s.valid_from,
    NULL as timestamp
FROM silver_records s
UNION ALL
SELECT
    'Bronze' as layer,
    b.raw_data->>'customer_id',
    NULL as valid_from,
    b.ingestion_timestamp as timestamp
FROM bronze_records b
ORDER BY layer, timestamp;

3. Schema Evolution

Each layer can evolve independently without breaking downstream consumers.

sql
-- Add new column to Silver layer
ALTER TABLE silver_customers
ADD COLUMN customer_lifetime_status VARCHAR(50);

-- Update transformation logic
UPDATE silver_customers
SET customer_lifetime_status =
    CASE
        WHEN /* some calculation */ THEN 'VIP'
        WHEN /* some calculation */ THEN 'Standard'
        ELSE 'New'
    END;

-- Gold layer can optionally use new field
-- Existing Gold tables continue to work unchanged

4. Multiple Consumption Patterns

Gold layer supports diverse use cases from same Silver layer.

sql
-- Gold for BI/Reporting: Star schema
CREATE TABLE gold_sales_star_schema AS
SELECT /* denormalized for reporting */
FROM silver_orders o
JOIN silver_customers c ON o.customer_id = c.customer_id;

-- Gold for ML: Feature store with different grain
CREATE TABLE gold_ml_features AS
SELECT /* engineered features for ML */
FROM silver_orders o
JOIN silver_customers c ON o.customer_id = c.customer_id;

-- Gold for Real-time API: Latest snapshot
CREATE MATERIALIZED VIEW gold_api_customer_current AS
SELECT /* optimized for API queries */
FROM silver_customers
WHERE is_current = true;

Challenges & Considerations

1. Storage Costs

Storing multiple copies of data across layers increases storage requirements.

sql
-- Monitor storage by layer
SELECT
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as total_size,
    pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) as data_size,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) - pg_relation_size(schemaname||'.'||tablename)) as index_size
FROM pg_tables
WHERE tablename LIKE 'bronze_%'
   OR tablename LIKE 'silver_%'
   OR tablename LIKE 'gold_%'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;

-- Solution: Implement retention policies
DELETE FROM bronze_customer_events
WHERE ingestion_timestamp < CURRENT_DATE - INTERVAL '90 days';

-- Archive to cheaper storage
CREATE TABLE bronze_customer_events_archive
PARTITION OF bronze_customer_events_partitioned
FOR VALUES FROM ('2020-01-01') TO ('2023-01-01')
TABLESPACE archive_tablespace;

2. Processing Complexity

Managing dependencies between layers requires orchestration.

sql
-- Pipeline dependency tracking
CREATE TABLE pipeline_dependencies (
    pipeline_name VARCHAR(100) PRIMARY KEY,
    depends_on VARCHAR(100)[] NOT NULL,
    layer VARCHAR(20),
    schedule_cron VARCHAR(100)
);

INSERT INTO pipeline_dependencies VALUES
('bronze_ingest_customers', '{}', 'bronze', '0 * * * *'),  -- Hourly
('silver_process_customers', '{bronze_ingest_customers}', 'silver', '30 * * * *'),
('gold_customer360', '{silver_process_customers}', 'gold', '0 2 * * *');  -- Daily at 2 AM

3. Data Consistency

Ensuring consistency when reading across layers during updates.

sql
-- Use transactions for multi-layer updates
BEGIN;

-- Update Silver
UPDATE silver_customers SET segment = 'Premium' WHERE customer_id = 'CUST-123';

-- Immediately update Gold to maintain consistency
UPDATE gold_customer_360 SET segment = 'Premium' WHERE customer_id = 'CUST-123';

COMMIT;

-- Or use read-consistent views
CREATE VIEW consistent_customer_view AS
SELECT
    s.*,
    g.total_orders,
    g.lifetime_value
FROM silver_customers s
LEFT JOIN gold_customer_360 g ON s.customer_id = g.customer_id
WHERE s.is_current = true;

4. Query Performance

Querying Bronze layer (especially JSON) can be slow.

sql
-- ❌ Slow: Querying raw JSON in Bronze
SELECT COUNT(*)
FROM bronze_customer_events
WHERE raw_payload->>'segment' = 'Premium';

-- ✅ Fast: Query structured Silver layer
SELECT COUNT(*)
FROM silver_customers
WHERE segment = 'Premium'
  AND is_current = true;

-- Solution: Create indexes on frequently queried JSON paths
CREATE INDEX idx_bronze_customer_segment
ON bronze_customer_events ((raw_payload->>'segment'));

-- Or use generated columns
ALTER TABLE bronze_customer_events
ADD COLUMN segment_extracted VARCHAR(50)
GENERATED ALWAYS AS (raw_payload->>'segment') STORED;

CREATE INDEX idx_bronze_segment_extracted
ON bronze_customer_events(segment_extracted);

Best Practices

1. Establish Clear Layer Boundaries

Define what belongs in each layer and enforce separation.

sql
-- ❌ Bad: Business logic in Bronze
CREATE TABLE bronze_orders (
    raw_data JSONB,
    calculated_profit DECIMAL(10,2) -- Don't calculate in Bronze!
);

-- ✅ Good: Keep Bronze raw, calculate in Silver/Gold
CREATE TABLE bronze_orders (
    raw_data JSONB,
    ingestion_timestamp TIMESTAMP
);

CREATE TABLE silver_orders (
    order_id VARCHAR(50),
    revenue DECIMAL(10,2),
    cost DECIMAL(10,2)
);

CREATE TABLE gold_order_analytics (
    order_id VARCHAR(50),
    revenue DECIMAL(10,2),
    cost DECIMAL(10,2),
    profit DECIMAL(10,2) GENERATED ALWAYS AS (revenue - cost) STORED
);

2. Implement Idempotent Pipelines

Ensure pipelines can be safely re-run without duplicating data.

sql
-- Use UPSERT patterns in Silver
INSERT INTO silver_customers (customer_id, customer_name, email)
SELECT
    raw_data->>'customer_id',
    raw_data->>'name',
    raw_data->>'email'
FROM bronze_customer_events
WHERE ingestion_timestamp >= :last_watermark
ON CONFLICT (customer_id) DO UPDATE
SET customer_name = EXCLUDED.customer_name,
    email = EXCLUDED.email,
    updated_at = CURRENT_TIMESTAMP;

-- Use MERGE patterns in Gold
MERGE INTO gold_customer_360 AS target
USING (SELECT /* aggregation from silver */) AS source
ON target.customer_id = source.customer_id
WHEN MATCHED THEN
    UPDATE SET total_orders = source.total_orders,
               updated_at = CURRENT_TIMESTAMP
WHEN NOT MATCHED THEN
    INSERT (customer_id, total_orders, created_at)
    VALUES (source.customer_id, source.total_orders, CURRENT_TIMESTAMP);

3. Version Your Transformations

Track transformation logic versions for reproducibility.

sql
-- Add version tracking to Gold tables
CREATE TABLE gold_customer_360 (
    customer_id VARCHAR(50) PRIMARY KEY,
    /* other columns */,
    transformation_version VARCHAR(20),
    calculated_at TIMESTAMP,
    calculation_logic_hash VARCHAR(64)
);

-- Track transformation code
CREATE TABLE transformation_versions (
    version VARCHAR(20) PRIMARY KEY,
    layer VARCHAR(20),
    table_name VARCHAR(100),
    transformation_sql TEXT,
    deployed_at TIMESTAMP,
    deployed_by VARCHAR(100)
);

4. Monitor Data Quality Metrics

Track quality across all layers.

sql
-- Quality monitoring dashboard
CREATE VIEW v_data_quality_dashboard AS
SELECT
    'Bronze' as layer,
    COUNT(*) as total_records,
    COUNT(*) FILTER (WHERE raw_data IS NULL) as null_records,
    (COUNT(*) FILTER (WHERE raw_data IS NULL)::DECIMAL / COUNT(*)) * 100 as null_pct
FROM bronze_customer_events
WHERE ingestion_timestamp >= CURRENT_DATE - 7
UNION ALL
SELECT
    'Silver' as layer,
    COUNT(*) as total_records,
    COUNT(*) FILTER (WHERE data_quality_score < 50) as poor_quality_records,
    (COUNT(*) FILTER (WHERE data_quality_score < 50)::DECIMAL / COUNT(*)) * 100 as poor_quality_pct
FROM silver_customers
WHERE is_current = true
UNION ALL
SELECT
    'Gold' as layer,
    COUNT(*) as total_records,
    COUNT(*) FILTER (WHERE calculated_at < CURRENT_DATE - 1) as stale_records,
    (COUNT(*) FILTER (WHERE calculated_at < CURRENT_DATE - 1)::DECIMAL / COUNT(*)) * 100 as stale_pct
FROM gold_customer_360;

5. Use Partitioning for Performance

Partition large tables by date for efficient querying and maintenance.

sql
-- Partition Bronze by ingestion date
CREATE TABLE bronze_events (
    event_id BIGSERIAL,
    event_data JSONB,
    ingestion_timestamp TIMESTAMP
) PARTITION BY RANGE (ingestion_timestamp);

CREATE TABLE bronze_events_2024_01
PARTITION OF bronze_events
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

-- Partition Silver by logical date
CREATE TABLE silver_orders (
    order_id VARCHAR(50),
    order_date DATE,
    /* other columns */
) PARTITION BY RANGE (order_date);

-- Partition Gold by calculation date
CREATE TABLE gold_daily_summary (
    summary_date DATE,
    /* metrics */
) PARTITION BY RANGE (summary_date);

Medallion vs. Traditional Architectures

AspectMedallionData Warehouse (Kimball)Data Lake
StructureMulti-layered (Bronze/Silver/Gold)Star/Snowflake schemasFlat storage
SchemaSchema-on-write (Silver+)Schema-on-writeSchema-on-read
Data FormatMixed (JSON → Tables → Aggregates)Relational tablesFiles (Parquet, CSV, JSON)
ProcessingIncremental, layeredBatch ETLBatch/Stream
FlexibilityHigh (evolve each layer)Medium (schema changes hard)Very High (no schema)
Query PerformanceGold: Fast, Bronze: SlowFast (optimized for BI)Slow (full scans)
Data QualityProgressive refinementHigh (enforced at load)Variable
Use CasesML + BI + AnalyticsBusiness reportingData science, exploration
CostMedium-High (storage x3)MediumLow (cheap storage)
GovernanceLayer-basedCentralizedMinimal

See Also

Released under the MIT License.