Progressive tutorial: basic MVs to full Medallion Architecture.
cp .env.example .env
# Edit .env with your connection detailssource .envChoose ONE alias based on your setup:
# Local ClickHouse (no password)
alias ch="clickhouse-client --host=$CLICKHOUSE_HOST --user=$CLICKHOUSE_USER"
# Local ClickHouse (with password)
alias ch="clickhouse-client --host=$CLICKHOUSE_HOST --user=$CLICKHOUSE_USER --password=$CLICKHOUSE_PASSWORD"
# ClickHouse Cloud
alias ch="clickhouse-client --host=$CLICKHOUSE_HOST --port=9440 --user=$CLICKHOUSE_USER --password=$CLICKHOUSE_PASSWORD --secure"ch --query "SELECT version()"Run all examples with validation:
./run_tests.shClean up everything:
ch --queries-file MASTER_CLEANUP.sqlDatabase: mv_demo_basic | Scenario: Page view tracking
Key Concept: A Materialized View in ClickHouse is an INSERT trigger, not a cached query.
%%{init: {'themeVariables': {'primaryTextColor': '#000000', 'lineColor': '#000000'}}}%%
flowchart LR
A[INSERT] --> B[page_views_raw]
B --> C{MV Trigger}
C --> D[page_views_count]
style A fill:#e1f5fe,color:#000000
style B fill:#fff3e0,color:#000000
style C fill:#f3e5f5,color:#000000
style D fill:#e8f5e9,color:#000000
Step-by-Step Instructions
ch --queries-file 01_basic_incremental_mvs/00_setup.sqlThis creates:
- Database
mv_demo_basic - Source table
page_views_raw(MergeTree)
ch --queries-file 01_basic_incremental_mvs/01_basic_mv.sqlThis creates:
- Target table
page_views_count(MergeTree) - Materialized View that counts page views by URL
ch --queries-file 01_basic_incremental_mvs/02_queries.sqlDemonstrates:
- Inserting raw page view events
- Querying aggregated counts (no computation at query time)
ch --queries-file 01_basic_incremental_mvs/03_performance.sqlCompares query performance:
- MV Target (pre-aggregated): ~3ms, reads only aggregated rows
- Raw Table (on-the-fly): ~5ms, must scan 100K+ rows
ch --queries-file 01_basic_incremental_mvs/99_cleanup.sql- MVs trigger automatically on every INSERT
- Results stored in target table - no query-time computation
- Zero orchestration - no Airflow, no cron, no batch jobs
Limitation: MV creates new rows for each INSERT batch. Need manual GROUP BY for totals.
Database: mv_demo_summing | Scenario: Website metrics
Key Concept: SummingMergeTree automatically sums numeric columns when rows with the same ORDER BY key are merged.
%%{init: {'themeVariables': {'primaryTextColor': '#000000', 'lineColor': '#000000'}}}%%
flowchart LR
A[INSERT] --> B[events_raw]
B --> C{MV Trigger}
C --> D[hourly_metrics<br/>SummingMergeTree]
D --> E{Background<br/>Merge}
E --> F[Auto-Summed<br/>Rows]
style A fill:#e1f5fe,color:#000000
style B fill:#fff3e0,color:#000000
style C fill:#f3e5f5,color:#000000
style D fill:#e8f5e9,color:#000000
style E fill:#fce4ec,color:#000000
style F fill:#e8f5e9,color:#000000
Step-by-Step Instructions
ch --queries-file 02_summing_merge_tree/00_setup.sqlThis creates:
- Database
mv_demo_summing - Source table
events_raw(MergeTree)
ch --queries-file 02_summing_merge_tree/01_summing_tables.sqlThis creates:
- Target table
hourly_metrics(SummingMergeTree) - ORDER BY (hour, page) defines the merge key
ch --queries-file 02_summing_merge_tree/02_mvs.sqlThis creates:
- MV that aggregates events into hourly metrics
ch --queries-file 02_summing_merge_tree/03_queries.sqlDemonstrates:
- Multiple INSERT batches creating duplicate keys
- Using
FINALto see merged results before background merge - Using
OPTIMIZE TABLEto force immediate merge
ch --queries-file 02_summing_merge_tree/04_performance.sqlCompares query performance (500K events):
- SummingMergeTree: ~4ms, reads 54 pre-merged rows
- Regular MergeTree: ~3ms, reads 50 rows (no auto-sum)
- Raw Events Table: ~10ms, must scan 500K rows
ch --queries-file 02_summing_merge_tree/99_cleanup.sql- Rows with same ORDER BY key automatically combine
- Use
FINALorGROUP BYfor accurate results before merge completes - Great for counters and simple summations
Limitation: Only handles SUM. Cannot do COUNT DISTINCT or AVG.
Database: mv_demo_aggregating | Scenario: E-commerce metrics with unique customers
Key Concept: AggregateFunction columns store intermediate STATE, enabling COUNT DISTINCT and AVG across INSERT batches.
%%{init: {'themeVariables': {'primaryTextColor': '#000000', 'lineColor': '#000000'}}}%%
flowchart LR
A[INSERT] --> B[orders_raw]
B --> C{MV with<br/>*State functions}
C --> D[hourly_sales<br/>AggregatingMergeTree]
D --> E[Query with<br/>*Merge functions]
E --> F[Final<br/>Results]
style A fill:#e1f5fe,color:#000000
style B fill:#fff3e0,color:#000000
style C fill:#f3e5f5,color:#000000
style D fill:#e8f5e9,color:#000000
style E fill:#fff9c4,color:#000000
style F fill:#e8f5e9,color:#000000
| INSERT Time | QUERY Time |
|---|---|
countState() |
countMerge() |
sumState() |
sumMerge() |
avgState() |
avgMerge() |
uniqState() |
uniqMerge() |
Step-by-Step Instructions
ch --queries-file 03_aggregating_merge_tree/00_setup.sqlThis creates:
- Database
mv_demo_aggregating - Source table
orders_raw(MergeTree)
ch --queries-file 03_aggregating_merge_tree/01_aggregating_tables.sqlThis creates:
- Target table
hourly_saleswith AggregateFunction columns:total_orders AggregateFunction(count, UInt64)total_revenue AggregateFunction(sum, Decimal(10,2))avg_order_value AggregateFunction(avg, Decimal(10,2))unique_customers AggregateFunction(uniq, String)
ch --queries-file 03_aggregating_merge_tree/02_state_merge_mvs.sqlThis creates:
- MV using
countState(),sumState(),avgState(),uniqState()
ch --queries-file 03_aggregating_merge_tree/03_queries.sqlDemonstrates:
- Inserting orders with same customer across multiple batches
- Querying with
countMerge(),sumMerge(),avgMerge(),uniqMerge() - Correct COUNT DISTINCT even across separate INSERT batches
ch --queries-file 03_aggregating_merge_tree/04_performance.sqlCompares query performance (1M orders):
- AggregatingMergeTree + uniqMerge(): ~6ms, reads 14 rows
- Raw Table + COUNT DISTINCT: ~26ms, must scan 1M rows
ch --queries-file 03_aggregating_merge_tree/99_cleanup.sql*State()functions create mergeable intermediate state*Merge()functions finalize results at query time- Enables accurate COUNT DISTINCT across multiple INSERT batches
- Foundation for cascading aggregations
Database: mv_demo_dictionaries | Scenario: E-commerce order enrichment
Key Concept: Dictionaries provide O(1) in-memory lookups, replacing expensive JOINs for dimension data.
%%{init: {'themeVariables': {'primaryTextColor': '#000000', 'lineColor': '#000000'}}}%%
flowchart LR
subgraph Source Tables
P[dim_products]
C[dim_customers]
end
subgraph Dictionaries
PD[products_dict<br/>HASHED layout]
CD[customers_dict<br/>HASHED layout]
end
subgraph Usage
Q[Query with<br/>dictGet]
MV[MV with<br/>dictGet]
end
P -->|SYSTEM RELOAD| PD
C -->|SYSTEM RELOAD| CD
PD -->|O 1 lookup| Q
CD -->|O 1 lookup| Q
PD -->|O 1 lookup| MV
CD -->|O 1 lookup| MV
style P fill:#fff3e0,color:#000000
style C fill:#fff3e0,color:#000000
style PD fill:#e8f5e9,color:#000000
style CD fill:#e8f5e9,color:#000000
style Q fill:#e1f5fe,color:#000000
style MV fill:#f3e5f5,color:#000000
| Function | Description |
|---|---|
dictGet(dict, attr, key) |
Get attribute value by key |
dictGetOrDefault(dict, attr, key, default) |
Get with fallback for missing keys |
dictHas(dict, key) |
Check if key exists |
Step-by-Step Instructions
ch --queries-file 04_dictionaries/00_setup.sqlThis creates:
- Database
mv_demo_dictionaries dim_products- Product catalog with price/costdim_customers- Customer profiles with tierorders_raw- Fact table for orders- Sample dimension data (10 products, 10 customers)
ch --queries-file 04_dictionaries/01_dictionaries.sqlThis creates:
products_dict- HASHED layout for product lookupscustomers_dict- HASHED layout for customer lookups- Dictionaries are loaded into memory for O(1) access
ch --queries-file 04_dictionaries/02_dictget_examples.sqlDemonstrates:
- Basic
dictGet()for single attribute lookup - Multiple attribute lookups in one query
dictGetOrDefault()for handling missing keys- Calculated fields using dictionary values
ch --queries-file 04_dictionaries/03_join_vs_dictget.sqlDemonstrates:
- Traditional JOIN approach (slower)
- dictGet() approach (faster, preferred)
- Why dictionaries are better for dimension lookups
ch --queries-file 04_dictionaries/04_mv_with_dictionaries.sqlThis creates:
orders_enriched- Target table with enriched data- MV that auto-enriches orders using dictGet()
- Analytics queries on enriched data
ch --queries-file 04_dictionaries/05_performance.sqlCompares query performance (500K orders):
- dictGet() - O(1) lookup: ~15ms, constant time regardless of dimension size
- JOIN - table scan: ~25ms, must scan dimension tables for every query
Shows why dictionaries are essential for dimension lookups at scale.
ch --queries-file 04_dictionaries/99_cleanup.sql- Dictionaries are in-memory key-value stores
- O(1) lookup performance vs O(n) for JOINs
dictGet()works seamlessly in Materialized Views- Auto-refresh keeps dictionaries in sync with source tables
- Foundation for real-time data enrichment
Key Insight: Use dictionaries for dimension tables, use JOINs only when you need to join fact tables.
Database: fastmart_demo | Scenario: FastMart e-commerce
Key Concept: Full Bronze -> Silver -> Gold pipeline combining all patterns from Examples 1-4.
%%{init: {'themeVariables': {'primaryTextColor': '#000000', 'lineColor': '#000000'}}}%%
flowchart TB
subgraph Bronze ["Bronze Layer (7d TTL)"]
A[events_raw<br/>JSON blobs]
end
subgraph Silver ["Silver Layer (30d TTL)"]
B[orders_silver<br/>Parsed JSON]
C[orders_enriched<br/>+ dictGet lookups]
end
subgraph Gold ["Gold Layer (90-365d TTL)"]
D[sales_by_minute]
E[sales_by_hour]
F[sales_by_day]
end
A -->|MV: JSON parsing| B
B -->|MV: Dictionary enrichment| C
C -->|MV: State aggregation| D
D -->|MV: Cascading rollup| E
E -->|MV: Cascading rollup| F
style A fill:#cd7f32,color:#000000
style B fill:#c0c0c0,color:#000000
style C fill:#c0c0c0,color:#000000
style D fill:#ffd700,color:#000000
style E fill:#ffd700,color:#000000
style F fill:#ffd700,color:#000000
Step-by-Step Instructions
pip install -r 05_medallion_architecture/scripts/requirements.txtConfigure data generation volume in .env:
# Recommended configurations:
# Quick demo: EVENT_COUNT=500000 EVENT_DAYS=1 (~2 min generation)
# Standard demo: EVENT_COUNT=1000000 EVENT_DAYS=1 (~4 min generation)
# Large scale: EVENT_COUNT=5000000 EVENT_DAYS=1 (~15 min generation)
export EVENT_COUNT=500000 # Number of events (minimum 500K recommended)
export EVENT_DAYS=1 # Time span (1 day recommended for dense aggregations)Higher event counts produce more dramatic performance differences between layers.
ch --queries-file 05_medallion_architecture/01_setup/00_config.sqlThis creates:
- Database
fastmart_demo - Common settings and configurations
ch --queries-file 05_medallion_architecture/01_setup/01_dimensions.sqlThis creates:
dim_products- Product catalogdim_stores- Store locationsdim_customers- Customer profiles
python3 05_medallion_architecture/scripts/generate_dimensions.pyPopulates dimension tables with sample data.
ch --queries-file 05_medallion_architecture/02_bronze/10_bronze_tables.sqlThis creates:
events_raw- Raw JSON event storage with 7-day TTL
ch --queries-file 05_medallion_architecture/03_silver/20_silver_tables.sqlThis creates:
orders_silver- Parsed order dataorders_enriched- Orders with dimension lookups
ch --queries-file 05_medallion_architecture/03_silver/21_incremental_mvs.sqlThis creates:
- MV for JSON parsing (Bronze -> Silver)
- MV for enrichment with dictGet()
ch --queries-file 05_medallion_architecture/03_silver/22_dictionaries.sqlThis creates:
- Dictionaries for O(1) dimension lookups
ch --queries-file 05_medallion_architecture/04_gold/30_gold_minute.sqlThis creates:
sales_by_minutewith State functions- MV from Silver -> Gold minute
ch --queries-file 05_medallion_architecture/04_gold/31_gold_hourly.sqlThis creates:
sales_by_hour- Cascading from minutesales_by_day- Cascading from hour
# Uses EVENT_COUNT and EVENT_DAYS from .env (default: 500K events, 1 day)
python3 05_medallion_architecture/scripts/generate_events.pyGenerates test events using configuration from .env. Default 500K events over 1 day provides sufficient data density for meaningful aggregation comparisons.
python3 05_medallion_architecture/scripts/stream_simulator.py --rate 500Continuously streams events at 500/second.
ch --queries-file 05_medallion_architecture/05_queries/43_performance.sqlCompares query performance across all layers:
- Gold Layer (sales_by_day): ~2ms, reads pre-aggregated rows - FASTEST
- Gold Layer (sales_by_hour): ~3ms, slightly more rows
- Silver Layer (orders_enriched): ~15ms, must scan enriched rows
- Bronze Layer (events_raw): ~50ms, JSON parsing + dictionary lookups - SLOWEST
Demonstrates:
- Data volume reduction at each layer
- Query time improvement from layered aggregation
- Scalability analysis (10x, 100x data projections)
ch --queries-file 05_medallion_architecture/06_cleanup/99_cleanup.sql%%{init: {'themeVariables': {'primaryTextColor': '#000000', 'lineColor': '#000000'}}}%%
flowchart LR
subgraph Input
API[API Events]
Kafka[Kafka Stream]
end
subgraph Processing
B[Bronze<br/>events_raw]
S1[Silver<br/>orders_silver]
S2[Silver<br/>orders_enriched]
G1[Gold<br/>sales_by_minute]
G2[Gold<br/>sales_by_hour]
G3[Gold<br/>sales_by_day]
end
subgraph Lookups
D1[dict: products]
D2[dict: stores]
D3[dict: customers]
end
API --> B
Kafka --> B
B -->|JSON parse| S1
S1 --> S2
D1 -.->|dictGet| S2
D2 -.->|dictGet| S2
D3 -.->|dictGet| S2
S2 -->|*State| G1
G1 -->|cascade| G2
G2 -->|cascade| G3
| Pattern | File | Description |
|---|---|---|
| JSON parsing | 03_silver/21_incremental_mvs.sql |
Extract fields from raw JSON |
| Dictionary enrichment | 03_silver/22_dictionaries.sql |
O(1) lookups via dictGet() |
| State functions | 04_gold/30_gold_minute.sql |
Aggregate with *State() |
| Cascading aggregation | 04_gold/31_gold_hourly.sql |
Rollup minute->hour->day |
Update .env for cloud connections:
CLICKHOUSE_HOST=abc123.us-east-1.aws.clickhouse.cloud
CLICKHOUSE_PORT=8443
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=your-password
CLICKHOUSE_SECURE=trueThen source and create alias:
source .env
alias ch="clickhouse-client --host=$CLICKHOUSE_HOST --port=9440 --user=$CLICKHOUSE_USER --password=$CLICKHOUSE_PASSWORD --secure"incremental_materialized_views/
|-- 01_basic_incremental_mvs/
|-- 02_summing_merge_tree/
|-- 03_aggregating_merge_tree/
|-- 04_dictionaries/
|-- 05_medallion_architecture/
| |-- 01_setup/
| |-- 02_bronze/
| |-- 03_silver/
| |-- 04_gold/
| |-- 05_queries/
| |-- 06_cleanup/
| |-- scripts/
|-- demo/ # Single-file demo combining all concepts
| |-- README.md
| |-- webinar_demo.sql
|-- .env.example
|-- .env
|-- MASTER_CLEANUP.sql
|-- run_tests.sh
|-- README.md