mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-12 12:06:29 +03:00
Engineering: backend-engineering, frontend-engineering, data-engineering, ml-engineering, platform-engineering, qa-methodology Executive: go-to-market, legal-strategy, operational-design, org-design, product-strategy ml-engineering: added missing training-infrastructure.md reference qa-methodology: added test-data-management, performance-testing, security-testing references All frontmatter converted to agent-skills convention. Source: https://github.com/magnus919/hermes-profiles
1267 lines
42 KiB
Markdown
1267 lines
42 KiB
Markdown
# Data Engineering SQL & Relational Database Reference
|
|
|
|
**Purpose:** A thorough reference for data engineers covering analytical SQL patterns,
|
|
ETL/ELT patterns, query performance, data modeling, testing, and engine comparisons.
|
|
This is methodology-level guidance — not a tutorial, but a field manual.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Analytical SQL Patterns](#1-analytical-sql-patterns)
|
|
2. [ETL/ELT SQL Patterns](#2-etlelt-sql-patterns)
|
|
3. [Query Performance Patterns](#3-query-performance-patterns)
|
|
4. [Data Modeling for Analytics](#4-data-modeling-for-analytics)
|
|
5. [SQL Testing & Validation Patterns](#5-sql-testing--validation-patterns)
|
|
6. [Analytical SQL Engine Comparison](#6-analytical-sql-engine-comparison)
|
|
|
|
---
|
|
|
|
## 1. Analytical SQL Patterns
|
|
|
|
### 1.1 Window Functions
|
|
|
|
Window functions perform calculations across a set of rows related to the current
|
|
row, without collapsing rows into a single output (unlike GROUP BY).
|
|
|
|
**Syntax anatomy:**
|
|
```sql
|
|
<function>() OVER (
|
|
[PARTITION BY col1, col2, ...]
|
|
[ORDER BY col1 [ASC|DESC], ...]
|
|
[frame_spec]
|
|
)
|
|
```
|
|
|
|
**Frame specifications (critical for correctness):**
|
|
| Clause | Behavior |
|
|
|---|---|
|
|
| `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` | Physical — counts actual rows regardless of value ties |
|
|
| `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` | Logical — includes peers (rows with same ORDER BY value) |
|
|
| `ROWS BETWEEN n PRECEDING AND n FOLLOWING` | Sliding physical window of 2n+1 rows |
|
|
| `RANGE BETWEEN INTERVAL '7' DAY PRECEDING AND CURRENT ROW` | Time-based frame (Date/Time ORDER BY) |
|
|
| `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING` | Entire partition (like SUM with no frame) |
|
|
|
|
**Window function families:**
|
|
|
|
| Family | Functions | Use Case |
|
|
|---|---|---|
|
|
| **Ranking** | `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`, `NTILE(n)` | Dedup, pagination, top-N-per-group |
|
|
| **Value** | `LAG(col, n)`, `LEAD(col, n)`, `FIRST_VALUE()`, `LAST_VALUE()`, `NTH_VALUE()` | Time-series shifts, YoY comparison, filling gaps |
|
|
| **Aggregate** | `SUM()`, `AVG()`, `COUNT()`, `MIN()`, `MAX()` over window | Running totals, moving averages, cumulative stats |
|
|
| **Distribution** | `PERCENT_RANK()`, `CUME_DIST()`, `PERCENTILE_CONT()`, `PERCENTILE_DISC()` | Statistical distributions, median calculation |
|
|
|
|
**Running total (cumulative sum):**
|
|
```sql
|
|
SELECT
|
|
order_date,
|
|
amount,
|
|
SUM(amount) OVER (ORDER BY order_date
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
|
|
FROM orders;
|
|
```
|
|
|
|
**Moving average (7-day):**
|
|
```sql
|
|
SELECT
|
|
date,
|
|
revenue,
|
|
AVG(revenue) OVER (ORDER BY date
|
|
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7d
|
|
FROM daily_revenue;
|
|
```
|
|
|
|
**First value in partition (fill-forward):**
|
|
```sql
|
|
SELECT
|
|
user_id,
|
|
login_date,
|
|
FIRST_VALUE(login_date) OVER (PARTITION BY user_id
|
|
ORDER BY login_date
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS first_login
|
|
FROM user_logins;
|
|
```
|
|
|
|
**Deduplication with ROW_NUMBER:**
|
|
```sql
|
|
WITH ranked AS (
|
|
SELECT *,
|
|
ROW_NUMBER() OVER (PARTITION BY id ORDER BY updated_at DESC) AS rn
|
|
FROM raw_table
|
|
)
|
|
SELECT * FROM ranked WHERE rn = 1;
|
|
```
|
|
|
|
---
|
|
|
|
### 1.2 Common Table Expressions (CTEs)
|
|
|
|
CTEs improve query readability, enable recursion, and allow stepwise logic.
|
|
|
|
**Non-recursive CTE:**
|
|
```sql
|
|
WITH monthly_sales AS (
|
|
SELECT
|
|
DATE_TRUNC('month', order_date) AS month,
|
|
SUM(amount) AS total
|
|
FROM orders
|
|
WHERE order_date >= '2024-01-01'
|
|
GROUP BY 1
|
|
),
|
|
ranked_months AS (
|
|
SELECT *,
|
|
RANK() OVER (ORDER BY total DESC) AS rank
|
|
FROM monthly_sales
|
|
)
|
|
SELECT * FROM ranked_months WHERE rank <= 5;
|
|
```
|
|
|
|
**Recursive CTE (hierarchy traversal — org chart, bill of materials):**
|
|
```sql
|
|
WITH RECURSIVE org_tree AS (
|
|
-- Anchor: top-level
|
|
SELECT id, name, manager_id, 1 AS level
|
|
FROM employees
|
|
WHERE manager_id IS NULL
|
|
|
|
UNION ALL
|
|
|
|
-- Recursive step
|
|
SELECT e.id, e.name, e.manager_id, t.level + 1
|
|
FROM employees e
|
|
JOIN org_tree t ON e.manager_id = t.id
|
|
)
|
|
SELECT * FROM org_tree;
|
|
```
|
|
|
|
**CTE vs subquery guidance:**
|
|
- Use CTEs for readability when the same subquery is referenced multiple times.
|
|
- CTEs are **optimization fences** in some engines (PostgreSQL materializes them by
|
|
default; BigQuery inlines them). Test performance with real data.
|
|
- In Snowflake and DuckDB, CTEs are usually inlined unless forced with materialization hints.
|
|
|
|
---
|
|
|
|
### 1.3 Pivot / Unpivot
|
|
|
|
**Pivot (rows to columns):**
|
|
|
|
Most engines provide a `PIVOT` or `CROSSTAB` function. The fallback is conditional aggregation.
|
|
|
|
*Explicit PIVOT (Snowflake, BigQuery, SQL Server):*
|
|
```sql
|
|
SELECT *
|
|
FROM sales
|
|
PIVOT (
|
|
SUM(amount)
|
|
FOR category IN ('Electronics', 'Clothing', 'Food')
|
|
) AS p;
|
|
```
|
|
|
|
*Conditional aggregation fallback (works everywhere):*
|
|
```sql
|
|
SELECT
|
|
region,
|
|
SUM(CASE WHEN category = 'Electronics' THEN amount ELSE 0 END) AS electronics,
|
|
SUM(CASE WHEN category = 'Clothing' THEN amount ELSE 0 END) AS clothing,
|
|
SUM(CASE WHEN category = 'Food' THEN amount ELSE 0 END) AS food
|
|
FROM sales
|
|
GROUP BY region;
|
|
```
|
|
|
|
**Unpivot (columns to rows):**
|
|
|
|
*Explicit UNPIVOT (Snowflake, BigQuery, SQL Server):*
|
|
```sql
|
|
SELECT region, category, amount
|
|
FROM regional_sales
|
|
UNPIVOT (
|
|
amount FOR category IN (electronics, clothing, food)
|
|
);
|
|
```
|
|
|
|
*CROSS JOIN LATERAL / UNION ALL fallback:*
|
|
```sql
|
|
SELECT region, 'electronics' AS category, electronics AS amount FROM regional_sales
|
|
UNION ALL
|
|
SELECT region, 'clothing' AS category, clothing AS amount FROM regional_sales
|
|
UNION ALL
|
|
SELECT region, 'food' AS category, food AS amount FROM regional_sales;
|
|
```
|
|
|
|
---
|
|
|
|
### 1.4 Rolling Aggregates
|
|
|
|
Rolling aggregates extend window functions for time-series analytics.
|
|
|
|
**Year-over-year comparison:**
|
|
```sql
|
|
SELECT
|
|
month,
|
|
revenue,
|
|
LAG(revenue, 12) OVER (ORDER BY month) AS revenue_12m_ago,
|
|
(revenue - LAG(revenue, 12) OVER (ORDER BY month))
|
|
/ NULLIF(LAG(revenue, 12) OVER (ORDER BY month), 0) * 100 AS yoy_pct
|
|
FROM monthly_revenue;
|
|
```
|
|
|
|
**Rolling 30-day sum (period-to-date-style):**
|
|
```sql
|
|
SELECT
|
|
date,
|
|
amount,
|
|
SUM(amount) OVER (ORDER BY date
|
|
RANGE BETWEEN INTERVAL '29' DAY PRECEDING AND CURRENT ROW) AS rolling_30d
|
|
FROM daily_data;
|
|
```
|
|
|
|
**Sessionized aggregates (reset per partition):**
|
|
```sql
|
|
SELECT
|
|
user_id,
|
|
event_time,
|
|
SUM(value) OVER (PARTITION BY user_id
|
|
ORDER BY event_time
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_running_total
|
|
FROM user_events;
|
|
```
|
|
|
|
---
|
|
|
|
### 1.5 Date/Time Bucketing
|
|
|
|
Bucketing dates into intervals is essential for rollups and time-series.
|
|
|
|
**DATE_TRUNC (standard in PostgreSQL, DuckDB, Snowflake, BigQuery):**
|
|
```sql
|
|
-- Bucket to hour, day, week, month, quarter, year
|
|
SELECT
|
|
DATE_TRUNC('month', event_timestamp) AS bucket,
|
|
COUNT(*) AS events
|
|
FROM events
|
|
GROUP BY 1
|
|
ORDER BY 1;
|
|
```
|
|
|
|
**Custom bucket sizes (DuckDB: `date_bin`):**
|
|
```sql
|
|
SELECT
|
|
date_bin(INTERVAL '15 minutes', event_timestamp, TIMESTAMP '2024-01-01') AS bucket_15min,
|
|
COUNT(*) AS events
|
|
FROM events
|
|
GROUP BY 1;
|
|
```
|
|
|
|
**ISO week and year extraction:**
|
|
```sql
|
|
SELECT
|
|
EXTRACT(YEAR FROM order_date) AS yr,
|
|
EXTRACT(WEEK FROM order_date) AS wk,
|
|
SUM(amount) AS total
|
|
FROM orders
|
|
GROUP BY yr, wk;
|
|
```
|
|
|
|
**Fiscal calendar bucketing (when standard months don't fit):**
|
|
```sql
|
|
SELECT
|
|
CASE
|
|
WHEN EXTRACT(MONTH FROM order_date) >= 2 THEN EXTRACT(YEAR FROM order_date)
|
|
ELSE EXTRACT(YEAR FROM order_date) - 1
|
|
END AS fiscal_year,
|
|
SUM(amount) AS total
|
|
FROM orders
|
|
GROUP BY fiscal_year;
|
|
```
|
|
|
|
**Period-over-period difference using DATE_TRUNC and LAG:**
|
|
```sql
|
|
WITH weekly AS (
|
|
SELECT
|
|
DATE_TRUNC('week', order_date) AS week,
|
|
SUM(amount) AS revenue
|
|
FROM orders
|
|
GROUP BY 1
|
|
)
|
|
SELECT
|
|
week,
|
|
revenue,
|
|
LAG(revenue) OVER (ORDER BY week) AS prev_week_rev,
|
|
revenue - LAG(revenue) OVER (ORDER BY week) AS wow_change
|
|
FROM weekly;
|
|
```
|
|
|
|
---
|
|
|
|
## 2. ETL/ELT SQL Patterns
|
|
|
|
### 2.1 Incremental Loading
|
|
|
|
**Watermark / High-Water Mark pattern:**
|
|
|
|
Use a monotonically increasing column (timestamp, auto-increment ID) to track what
|
|
has already been loaded.
|
|
|
|
```sql
|
|
-- Extract: pull rows newer than the last watermark
|
|
INSERT INTO target_table (id, col1, col2, loaded_at)
|
|
SELECT id, col1, col2, CURRENT_TIMESTAMP
|
|
FROM source_table
|
|
WHERE updated_at > (SELECT MAX(loaded_at) FROM target_table);
|
|
```
|
|
|
|
**Last-modified pattern with checksum for changed detection:**
|
|
```sql
|
|
WITH source AS (
|
|
SELECT id, MD5(col1 || col2) AS row_hash, updated_at
|
|
FROM source_table
|
|
WHERE updated_at > (SELECT MAX(watermark_ts) FROM load_watermarks WHERE table_name = 'target')
|
|
)
|
|
SELECT s.*
|
|
FROM source s
|
|
LEFT JOIN target_table t ON s.id = t.id
|
|
WHERE t.id IS NULL OR s.row_hash != t.row_hash;
|
|
```
|
|
|
|
**Best practices:**
|
|
- Store watermarks in a control table (`table_name`, `watermark_ts`, `row_count`, `run_id`).
|
|
- Use `BEGIN`/`COMMIT` to make extract-and-update-watermark atomic.
|
|
- Prefer timestamp columns that are indexed in the source.
|
|
- For append-only sources (event logs), use an auto-increment ID as the watermark.
|
|
|
|
---
|
|
|
|
### 2.2 Merge / Upsert (MERGE / INSERT ON CONFLICT)
|
|
|
|
**PostgreSQL (`INSERT ... ON CONFLICT DO UPDATE`):**
|
|
```sql
|
|
INSERT INTO target (id, col1, col2, updated_at)
|
|
VALUES (1, 'val1', 'val2', NOW())
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
col1 = EXCLUDED.col1,
|
|
col2 = EXCLUDED.col2,
|
|
updated_at = EXCLUDED.updated_at;
|
|
```
|
|
|
|
**Standard SQL MERGE (Snowflake, BigQuery, SQL Server, DuckDB):**
|
|
```sql
|
|
MERGE INTO target AS t
|
|
USING source AS s
|
|
ON t.id = s.id
|
|
WHEN MATCHED AND (
|
|
t.col1 != s.col1 OR t.col2 != s.col2 OR (t.col1 IS NULL AND s.col1 IS NOT NULL)
|
|
) THEN UPDATE SET
|
|
col1 = s.col1,
|
|
col2 = s.col2,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHEN NOT MATCHED THEN
|
|
INSERT (id, col1, col2, created_at, updated_at)
|
|
VALUES (s.id, s.col1, s.col2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
|
```
|
|
|
|
**BigQuery MERGE (with DML):**
|
|
```sql
|
|
MERGE INTO `project.dataset.target` AS t
|
|
USING `project.dataset.source` AS s
|
|
ON t.id = s.id
|
|
WHEN MATCHED THEN
|
|
UPDATE SET col1 = s.col1, col2 = s.col2
|
|
WHEN NOT MATCHED THEN
|
|
INSERT (id, col1, col2) VALUES (id, col1, col2);
|
|
```
|
|
|
|
**DuckDB MERGE (note: single UPDATE/DELETE per WHEN MATCHED):**
|
|
```sql
|
|
MERGE INTO target AS t
|
|
USING source AS s
|
|
ON t.id = s.id
|
|
WHEN MATCHED AND s._is_deleted THEN DELETE
|
|
WHEN MATCHED THEN UPDATE SET col1 = s.col1, col2 = s.col2
|
|
WHEN NOT MATCHED THEN INSERT (id, col1, col2) VALUES (s.id, s.col1, s.col2);
|
|
```
|
|
|
|
**Engine-specific notes:**
|
|
| Engine | Upsert Method | Notes |
|
|
|---|---|---|
|
|
| PostgreSQL | `INSERT ... ON CONFLICT DO UPDATE` | Also supports `DO NOTHING`; requires unique index |
|
|
| Snowflake | `MERGE` | Also supports `INSERT OVERWRITE` for tables |
|
|
| BigQuery | `MERGE` | Charges for all bytes processed, even if no rows change |
|
|
| DuckDB | `INSERT OR REPLACE` or `MERGE` | DuckDB v1.3+: `MERGE` with single action per clause |
|
|
| Redshift | `MERGE` (via `UPDATE`/`INSERT` or `MERGE` since RA3) | Older versions: separate UPDATE then INSERT |
|
|
| ClickHouse | `ReplacingMergeTree` engine or `ALTER TABLE DELETE` | ClickHouse is append-optimized; upserts are not idiomatic |
|
|
|
|
---
|
|
|
|
### 2.3 Change Data Capture (CDC) Patterns
|
|
|
|
**1. Debezium-style (log-based CDC):**
|
|
- Source database captures changes via transaction log (PostgreSQL WAL, MySQL binlog).
|
|
- Events streamed to Kafka -> consumed and written to staging tables.
|
|
- Target SQL: merge staged changes into the final table.
|
|
|
|
```sql
|
|
-- Staging table holds INSERT, UPDATE, DELETE events
|
|
WITH latest_changes AS (
|
|
SELECT DISTINCT ON (id) id, col1, col2, op, change_ts
|
|
FROM cdc_staging
|
|
ORDER BY id, change_ts DESC
|
|
)
|
|
MERGE INTO target t
|
|
USING latest_changes s ON t.id = s.id
|
|
WHEN MATCHED AND s.op = 'DELETE' THEN DELETE
|
|
WHEN MATCHED AND s.op IN ('INSERT', 'UPDATE') THEN UPDATE SET col1 = s.col1, col2 = s.col2
|
|
WHEN NOT MATCHED AND s.op IN ('INSERT', 'UPDATE') THEN INSERT (id, col1, col2)
|
|
VALUES (s.id, s.col1, s.col2);
|
|
```
|
|
|
|
**2. Audit-column CDC (watermark + last-modified):**
|
|
- Source table has `updated_at` and optionally a version column.
|
|
- Periodic poll queries `WHERE updated_at > last_watermark`.
|
|
- Works for sources that cannot stream logs.
|
|
|
|
**3. Trigger-based CDC (SQL Server, PostgreSQL):**
|
|
- Database triggers write changes to a change-tracking table.
|
|
- Downstream reads the change table and clears processed rows.
|
|
|
|
```sql
|
|
-- PostgreSQL trigger-captured changes
|
|
CREATE TABLE _audit_accounts (
|
|
audit_id BIGSERIAL PRIMARY KEY,
|
|
op TEXT, -- 'INSERT', 'UPDATE', 'DELETE'
|
|
old_row JSONB,
|
|
new_row JSONB,
|
|
changed_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
```
|
|
|
|
**4. Snapshot-diff CDC:**
|
|
- Periodically snapshot the entire source table.
|
|
- Compare the new snapshot with the previous snapshot to find changes.
|
|
- Works for small reference tables; wasteful for large fact tables.
|
|
|
|
---
|
|
|
|
### 2.4 Full Refresh vs Incremental Decision Matrix
|
|
|
|
| Scenario | Strategy |
|
|
|---|---|
|
|
| Small dimension tables (< 10K rows) | Full refresh (simpler, idempotent) |
|
|
| Large fact tables (millions of rows) | Incremental with watermark |
|
|
| Append-only event streams | Incremental by ID or timestamp |
|
|
| Slow-changing reference data | Full refresh on schedule |
|
|
| Source has no reliable watermark column | Full refresh or snapshot-diff CDC |
|
|
| Source supports CDC (logical replication) | Stream-based CDC (lowest latency) |
|
|
|
|
---
|
|
|
|
## 3. Query Performance Patterns
|
|
|
|
### 3.1 Execution Plan Analysis
|
|
|
|
**Reading EXPLAIN output:**
|
|
|
|
Every plan is a tree of *nodes*. Each node has cost estimates and actuals (with ANALYZE).
|
|
|
|
```
|
|
Seq Scan on orders (cost=0.00..1234.56 rows=56789 width=32)
|
|
Filter: (amount > 100)
|
|
```
|
|
|
|
| Component | Meaning |
|
|
|---|---|
|
|
| `cost=0.00..1234.56` | Startup cost .. total cost (arbitrary units) |
|
|
| `rows=56789` | Estimated rows produced by this node |
|
|
| `width=32` | Average row width in bytes |
|
|
| `actual time=12.3..45.6` | (With EXPLAIN ANALYZE) actual timing in ms |
|
|
|
|
**Node types you'll see (PostgreSQL):**
|
|
|
|
| Node | Meaning | Usually okay? |
|
|
|---|---|---|
|
|
| `Seq Scan` | Full table scan | Yes for small tables, bad for large filtered queries |
|
|
| `Index Scan` | Single index lookup | Good for point queries |
|
|
| `Index Only Scan` | All needed data in index | Excellent (avoids heap fetch) |
|
|
| `Bitmap Heap Scan` + `Bitmap Index Scan` | Reads index, builds bitmap, then fetches pages | Good for medium-selectivity queries |
|
|
| `Nested Loop` | For each outer row, probe inner index | Good with small outer set |
|
|
| `Hash Join` | Build hash table on one side, probe with other | Good for medium-large joins |
|
|
| `Merge Join` | Sort both sides, merge | Good for pre-sorted data |
|
|
| `Sort` / `Incremental Sort` | Ordering operation | Expensive; avoid if possible |
|
|
| `Aggregate` (Hash/GroupAgg) | GROUP BY or aggregation | HashAgg is faster; GroupAgg requires sorted input |
|
|
|
|
**Red flags in execution plans:**
|
|
- Sequential scans on large tables (>1M rows) with selective filters (<1% of rows)
|
|
- Nested Loop joins where the outer input is large (tens of thousands+)
|
|
- Sort operations on unindexed columns driving GROUP BY or ORDER BY
|
|
- `rows` estimates far off from `actual rows` (sign of stale statistics)
|
|
- Spilling to disk (temp files) for sort/hash operations
|
|
|
|
**EXPLAIN ANALYZE checklist:**
|
|
```sql
|
|
-- 1. Check estimated vs actual row counts (accuracy)
|
|
-- 2. Check actual time (where is the most time spent?)
|
|
-- 3. Check for sequential scans on large tables
|
|
-- 4. Check for sorts that could use indexes
|
|
-- 5. Check for loops in Nested Loop (high loop count = bad)
|
|
EXPLAIN (ANALYZE, BUFFERS, TIMING) SELECT ...
|
|
```
|
|
|
|
---
|
|
|
|
### 3.2 Index Strategies for Analytical Queries
|
|
|
|
**Type comparison:**
|
|
|
|
| Index Type | Best For | Avoid When |
|
|
|---|---|---|
|
|
| **B-Tree** | Equality + range queries, primary keys, foreign keys | High-cardinality columns with wide values (text blobs) |
|
|
| **BRIN** (Block Range Index) | Large, append-only, naturally ordered tables (time-series, logs) | Randomly distributed data, high-update tables |
|
|
| **Hash Index** | Exact-equality lookups only | Anything with range/order |
|
|
| **GIN** (Generalized Inverted Index) | Array columns, full-text search, JSONB | Simple = lookups on scalar columns |
|
|
| **GiST** | Geometric/geospatial data, range overlap, full-text | General-purpose analytical queries |
|
|
| **Z-ordering** (Delta/BigQuery) | Multi-dimensional range queries on several columns | Single-column queries (use simple sort instead) |
|
|
|
|
**Analytical index patterns:**
|
|
|
|
*Covering index (index-only scans):*
|
|
```sql
|
|
-- Avoid heap fetches by including all needed columns
|
|
CREATE INDEX idx_sales_date_amount ON sales (sale_date) INCLUDE (amount, product_id);
|
|
```
|
|
|
|
*Partial index (filtered):*
|
|
```sql
|
|
-- Only index active records
|
|
CREATE INDEX idx_orders_active ON orders (order_date) WHERE status = 'active';
|
|
```
|
|
|
|
*Composite B-Tree for analytical filter patterns:*
|
|
```sql
|
|
-- Order columns by: equality -> range -> group/order
|
|
CREATE INDEX idx_sales_region_date ON sales (region, sale_date);
|
|
-- Supports: WHERE region = 'US' AND sale_date BETWEEN '2024-01-01' AND '2024-06-30'
|
|
```
|
|
|
|
*BRIN for time-series (low maintenance, tiny index):*
|
|
```sql
|
|
-- 10x smaller than B-Tree on ordered timestamps
|
|
CREATE INDEX idx_events_ts_brin ON events USING brin(created_at)
|
|
WITH (pages_per_range = 32);
|
|
```
|
|
|
|
**Indexing anti-patterns for analytics:**
|
|
- Don't index every column — write throughput suffers.
|
|
- Don't index low-cardinality columns alone (e.g., `gender`) — full scan is faster.
|
|
- Don't use B-Tree on timestamp columns in append-only tables — use BRIN.
|
|
- Don't forget `VACUUM`/`ANALYZE` after bulk loads — stale stats cause bad plans.
|
|
|
|
---
|
|
|
|
### 3.3 Partitioning
|
|
|
|
**When to partition:**
|
|
- Table > 100 GB or > 100M rows
|
|
- Queries always filter by a partition key (e.g., `order_date`)
|
|
- Old data can be dropped by dropping partitions (time-series retention)
|
|
- Maintenance operations (VACUUM, index rebuild) can target individual partitions
|
|
|
|
**Partition strategies:**
|
|
|
|
| Strategy | Key | Use Case |
|
|
|---|---|---|
|
|
| **Range** | Date, timestamp | Time-series data, event logs |
|
|
| **List** | Region, status, category | Discrete value partitions |
|
|
| **Hash** | ID, customer_id | Even data distribution, parallelism |
|
|
|
|
**PostgreSQL range partitioning:**
|
|
```sql
|
|
CREATE TABLE orders (
|
|
id BIGSERIAL,
|
|
order_date DATE NOT NULL,
|
|
amount NUMERIC
|
|
) PARTITION BY RANGE (order_date);
|
|
|
|
CREATE TABLE orders_2024_q1 PARTITION OF orders
|
|
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
|
|
CREATE TABLE orders_2024_q2 PARTITION OF orders
|
|
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
|
|
```
|
|
|
|
**BigQuery partitioning (table creation):**
|
|
```sql
|
|
CREATE TABLE `project.dataset.orders`
|
|
PARTITION BY DATE(order_timestamp)
|
|
CLUSTER BY region, product_id
|
|
OPTIONS(require_partition_filter=true);
|
|
```
|
|
|
|
**Snowflake clustering (automatic):**
|
|
```sql
|
|
ALTER TABLE orders CLUSTER BY (order_date, region);
|
|
```
|
|
|
|
**Partition pruning verification:**
|
|
```sql
|
|
-- PostgreSQL: check for "Append" node showing only relevant partitions
|
|
EXPLAIN SELECT * FROM orders WHERE order_date = '2024-02-15';
|
|
```
|
|
|
|
**Key rules:**
|
|
- Aim for 100-500 partitions (too few = no benefit; too many = metadata overhead).
|
|
- Always filter queries by the partition key.
|
|
- Use partition pruning verification after implementation.
|
|
- Consider declarative partitioning over manual table inheritance.
|
|
|
|
---
|
|
|
|
### 3.4 Clustering (within-partition ordering)
|
|
|
|
Clustering physically co-locates rows with similar cluster-key values. This reduces
|
|
the amount of data scanned by filter/aggregation queries.
|
|
|
|
| Engine | Feature | Notes |
|
|
|---|---|---|
|
|
| BigQuery | `CLUSTER BY` | Automatic re-clustering; no maintenance |
|
|
| Snowflake | `CLUSTER BY` | Automatic, but reclustering costs credits |
|
|
| Redshift | `SORTKEY` compound/interleaved | Manual; re-sort with `VACUUM SORT ONLY` |
|
|
| DuckDB | `ORDER BY` within `CREATE TABLE AS` | Manual; use WITH clause or ordering |
|
|
| PostgreSQL | CLUSTER command | One-time reorder; not maintained automatically |
|
|
|
|
**Strategy:**
|
|
```sql
|
|
-- BigQuery
|
|
CREATE TABLE `project.dataset.orders`
|
|
PARTITION BY DATE(order_date)
|
|
CLUSTER BY customer_id, region;
|
|
```
|
|
|
|
```sql
|
|
-- Redshift
|
|
CREATE TABLE orders (
|
|
id BIGINT,
|
|
order_date DATE,
|
|
customer_id BIGINT,
|
|
region VARCHAR(50)
|
|
) SORTKEY (customer_id, order_date);
|
|
```
|
|
|
|
**Cluster key ordering rules:**
|
|
- High-cardinality filter columns first.
|
|
- Equality filter columns before range filter columns.
|
|
- Columns frequently used in GROUP BY or ORDER BY.
|
|
- Avoid columns that are monotonically increasing (like timestamps) as the
|
|
*first* cluster key if the table is also partitioned by time — it adds no extra benefit.
|
|
|
|
---
|
|
|
|
### 3.5 Materialized Views
|
|
|
|
Materialized views pre-compute and store query results. They trade storage for
|
|
query speed.
|
|
|
|
**PostgreSQL materialized view:**
|
|
```sql
|
|
CREATE MATERIALIZED VIEW mv_monthly_sales AS
|
|
SELECT
|
|
DATE_TRUNC('month', order_date) AS month,
|
|
region,
|
|
SUM(amount) AS total_sales,
|
|
COUNT(*) AS order_count
|
|
FROM orders
|
|
GROUP BY 1, 2;
|
|
|
|
-- Refresh (blocking — table locked during refresh)
|
|
REFRESH MATERIALIZED VIEW mv_monthly_sales;
|
|
|
|
-- Concurrent refresh (non-blocking, requires unique index)
|
|
CREATE UNIQUE INDEX idx_mv_monthly_sales_key ON mv_monthly_sales (month, region);
|
|
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_sales;
|
|
```
|
|
|
|
**BigQuery materialized views (auto-refreshed):**
|
|
```sql
|
|
CREATE MATERIALIZED VIEW `project.dataset.monthly_sales`
|
|
AS
|
|
SELECT
|
|
DATE_TRUNC(order_date, MONTH) AS month,
|
|
region,
|
|
SUM(amount) AS total_sales
|
|
FROM `project.dataset.orders`
|
|
GROUP BY 1, 2;
|
|
```
|
|
|
|
**Snowflake materialized views (auto-maintained, credits incurred):**
|
|
```sql
|
|
CREATE MATERIALIZED VIEW mv_monthly_sales AS
|
|
SELECT
|
|
DATE_TRUNC('month', order_date) AS month,
|
|
region,
|
|
SUM(amount) AS total_sales
|
|
FROM orders
|
|
GROUP BY 1, 2;
|
|
```
|
|
|
|
**When to use materialized views:**
|
|
- Slow-running aggregations that are queried frequently.
|
|
- Dashboard/report queries with known filter patterns.
|
|
- Pre-joined dimension+fact denormalizations.
|
|
- Data that changes infrequently (or you can tolerate stale data).
|
|
|
|
**When NOT to use materialized views:**
|
|
- Highly volatile data (refresh cost exceeds query savings).
|
|
- Ad-hoc query workloads with unpredictable filter patterns.
|
|
- Tables under 50M rows (incremental query is often fast enough).
|
|
- When the view depends on tables with complex streaming updates.
|
|
|
|
---
|
|
|
|
### 3.6 Sorting within Analytical Engines
|
|
|
|
| Engine | Default Physical Sort | Notes |
|
|
|---|---|---|
|
|
| PostgreSQL | Heap-organized (CTID = physical order of insertion) | CLUSTER reorders once |
|
|
| DuckDB | Row-group columnar layout | `ORDER BY` in `COPY` or `CREATE TABLE AS` optimizes scan |
|
|
| ClickHouse | ORDER BY columns specified in table engine | Primary key determines sort |
|
|
| BigQuery | Capacitor columnar format, no physical sort guarantee | `CLUSTER BY` controls block layout |
|
|
| Snowflake | Micro-partition metadata tracks column min/max | Automatic via clustering |
|
|
| Redshift | SORTKEY determines block order | Compound vs interleaved |
|
|
|
|
---
|
|
|
|
## 4. Data Modeling for Analytics
|
|
|
|
### 4.1 Star Schema
|
|
|
|
**Structure:** One central *fact table* surrounded by *dimension tables*.
|
|
|
|
```
|
|
+--------------+
|
|
| Date (Dim) |
|
|
| date_key |<-------+
|
|
+--------------+ |
|
|
|
|
|
+----------------+ +------------------+
|
|
| Product (Dim) | | Sales (Fact) |
|
|
| product_key |<-----------------| product_key (FK) |
|
|
| product_name | | customer_key (FK)|
|
|
| category | | date_key (FK) |
|
|
+----------------+ | store_key (FK) |
|
|
| quantity |
|
|
+----------------+ | unit_price |
|
|
| Store (Dim) | | discount |
|
|
| store_key |<-----------------+------------------+
|
|
| store_name |
|
|
| region | +------------------+
|
|
+----------------+ | Customer (Dim) |
|
|
| customer_key (FK)|
|
|
+------------------+
|
|
```
|
|
|
|
**Fact table design rules:**
|
|
- Grain: explicitly define what one row represents (e.g., one row per product per store per day).
|
|
- Foreign keys: reference dimension surrogate keys, not natural keys.
|
|
- Measures: additive (quantity, amount), semi-additive (balance), non-additive (ratio).
|
|
- Avoid storing NULLs in numeric measure columns — use 0 if meaningful.
|
|
|
|
**Dimension table design rules:**
|
|
- Surrogate key (auto-increment or UUID) as primary key.
|
|
- Natural key stored as a separate attribute (business key).
|
|
- Split hierarchical attributes into role-playing dimensions where appropriate.
|
|
- Include descriptive text, codes, and categorization columns.
|
|
|
|
---
|
|
|
|
### 4.2 Snowflake Schema
|
|
|
|
**Structure:** Dimensions are normalized into multiple related tables.
|
|
|
|
```
|
|
+----------------+ +------------------+ +------------------+
|
|
| Category | | Subcategory | | Product |
|
|
| category_id |<---| category_id (FK) |<---| subcategory_id |
|
|
| category_name | | subcategory_id | | product_key |
|
|
+----------------+ | subcategory_name | | product_name |
|
|
+------------------+ +------------------+
|
|
```
|
|
|
|
**Star vs Snowflake decision:**
|
|
|
|
| Factor | Star | Snowflake |
|
|
|---|---|---|
|
|
| Query simplicity | Simple (fewer joins) | Complex (more joins) |
|
|
| Storage | Redundant (denormalized) space | Normalized (less space) |
|
|
| ETL complexity | Simple (single table) | Complex (multiple related tables) |
|
|
| BI tool performance | Fast (fewer joins) | Slower (more joins) |
|
|
| Maintenance | Update all rows in denormalized table | Update one row in normalized table |
|
|
| Dimensional hierarchy | Flattened into one table | Separate tables per level |
|
|
|
|
**Rule of thumb:** Start with star schema. Only normalize to snowflake when:
|
|
- Dimension has more than 5 hierarchical levels.
|
|
- Dimension rows are shared across multiple fact tables.
|
|
- Storage cost savings from normalization are significant.
|
|
- The ETL/maintenance overhead of snowflake is acceptable.
|
|
|
|
---
|
|
|
|
### 4.3 Dimensional Modeling (Kimball)
|
|
|
|
Kimball's four-step dimensional design process:
|
|
|
|
1. **Select the business process** (e.g., sales, inventory, customer orders).
|
|
2. **Declare the grain** (e.g., one row per product per store per day).
|
|
3. **Identify the dimensions** (who, what, where, when, why).
|
|
4. **Identify the facts** (measures: how many, how much).
|
|
|
|
**Conformed dimensions:** Dimensions that are shared across multiple fact tables
|
|
with the same keys, attributes, and meanings. This enables cross-process analysis
|
|
(e.g., compare sales to inventory by product).
|
|
|
|
**Degenerate dimensions:** Dimension attributes stored in the fact table because
|
|
they have no separate dimension table (e.g., order number for a line-item fact).
|
|
|
|
**Junk dimensions:** A single dimension table combining multiple low-cardinality
|
|
flags and indicators (e.g., `is_new_customer`, `is_express_shipping`, `is_promo`)
|
|
into one table to keep the fact table lean.
|
|
|
|
**Fact table types:**
|
|
|
|
| Type | Description | Example |
|
|
|---|---|---|
|
|
| **Transactional** | One row per event | Line-item sales, web clicks |
|
|
| **Periodic Snapshot** | One row per period | Daily account balance, monthly inventory |
|
|
| **Accumulating Snapshot** | One row per process lifecycle | Order fulfillment (order -> ship -> deliver) |
|
|
|
|
---
|
|
|
|
### 4.4 Slowly Changing Dimensions (SCD)
|
|
|
|
**SCD Type 0 — Retain original:**
|
|
- Dimension attributes never change once written.
|
|
- Use for immutable reference data (date of birth, timestamp).
|
|
|
|
**SCD Type 1 — Overwrite:**
|
|
- No history; current value overwrites the old value.
|
|
```sql
|
|
UPDATE customer_dim
|
|
SET email = 'new@email.com'
|
|
WHERE customer_id = 123;
|
|
```
|
|
|
|
**SCD Type 2 — Add new row (most common for analytics):**
|
|
- Each change creates a new row with effective dates.
|
|
```sql
|
|
UPDATE customer_dim
|
|
SET end_date = CURRENT_DATE - 1
|
|
WHERE customer_id = 123 AND end_date IS NULL; -- expire old
|
|
|
|
INSERT INTO customer_dim (customer_id, name, email, start_date, end_date)
|
|
VALUES (123, 'John', 'new@email.com', CURRENT_DATE, NULL); -- add new
|
|
```
|
|
|
|
*Additional columns for Type 2:*
|
|
- `start_date`, `end_date` — effective date range
|
|
- `is_current` — boolean flag for active row
|
|
- `version_number` — incrementing version
|
|
|
|
**SCD Type 3 — Add new column:**
|
|
- Track limited history by adding a "previous value" column.
|
|
```sql
|
|
ALTER TABLE customer_dim ADD COLUMN previous_email VARCHAR(255);
|
|
UPDATE customer_dim
|
|
SET previous_email = email, email = 'new@email.com'
|
|
WHERE customer_id = 123;
|
|
```
|
|
|
|
**SCD Type 4 — Mini-dimension:**
|
|
- Rapidly changing attributes are split into a separate dimension table.
|
|
- The main dimension stores the current value; the mini-dimension tracks changes.
|
|
- Useful when attributes change faster than the dimension can accommodate Type 2.
|
|
|
|
**SCD Type 6 (Hybrid 1+2+3):**
|
|
- Combines Type 1 (current value), Type 2 (history via rows), and Type 3 (previous value column).
|
|
- Useful for "as-is" and "as-was" reporting in the same table.
|
|
|
|
**Decision table:**
|
|
|
|
| SCD Type | Use When |
|
|
|---|---|
|
|
| 0 | Attribute never changes (birth date, original SKU) |
|
|
| 1 | History not needed, audit not required (email, phone) |
|
|
| 2 | Full history required (address, department) |
|
|
| 3 | Quick access to previous value only (territory assignment) |
|
|
| 4 | Attributes change very frequently (credit score, loyalty tier) |
|
|
| 6 | Need both current and historical in same query (compliance) |
|
|
|
|
---
|
|
|
|
### 4.5 Fact Table Design — Advanced
|
|
|
|
**Additive vs Semi-Additive vs Non-Additive:**
|
|
|
|
| Measure Type | Add Across All Dims | Add Across Time | Example |
|
|
|---|---|---|---|
|
|
| Additive | Yes | Yes | Sales amount, quantity |
|
|
| Semi-additive | Yes | No | Account balance, inventory level |
|
|
| Non-additive | No | No | Ratio, percentage, unit price |
|
|
|
|
*Semi-additive handling:* Use `SUM()` across other dimensions, but `AVG()` or
|
|
`LAST_VALUE()` across time.
|
|
|
|
**Null handling in facts:**
|
|
- Numeric facts: use 0 for additive nulls (quantity, amount). Use NULL for
|
|
non-applicable values (e.g., discount on non-promotional sale).
|
|
- Foreign keys: avoid NULLs — use a "Unknown" dimension row (key = -1).
|
|
|
|
**Factless fact tables:**
|
|
- A fact table with only foreign keys and no measures.
|
|
- Records an event or relationship (e.g., product-to-campaign assignment, student attendance).
|
|
|
|
**Transaction header + line-item fact modeling:**
|
|
- Grain = line item.
|
|
- Header-level attributes (order date, customer, store) are degenerate dimensions.
|
|
- Headers with multiple grains may split into separate fact tables.
|
|
|
|
---
|
|
|
|
## 5. SQL Testing & Validation Patterns
|
|
|
|
### 5.1 Data Quality Testing with SQL
|
|
|
|
**Category: Uniqueness / Primary Key**
|
|
```sql
|
|
-- EXPECT: 0 rows (all IDs are unique)
|
|
SELECT id, COUNT(*)
|
|
FROM target_table
|
|
GROUP BY id
|
|
HAVING COUNT(*) > 1;
|
|
```
|
|
|
|
**Category: Not Null**
|
|
```sql
|
|
-- EXPECT: 0 rows (no nulls in required columns)
|
|
SELECT COUNT(*) AS null_count
|
|
FROM target_table
|
|
WHERE required_column IS NULL;
|
|
```
|
|
|
|
**Category: Referential Integrity**
|
|
```sql
|
|
-- EXPECT: 0 rows (all foreign keys exist in parent)
|
|
SELECT DISTINCT ft.fk_column
|
|
FROM fact_table ft
|
|
LEFT JOIN dim_table dt ON ft.fk_column = dt.pk
|
|
WHERE dt.pk IS NULL;
|
|
```
|
|
|
|
**Category: Accepted Values (enum/dimension)**
|
|
```sql
|
|
-- EXPECT: 0 rows (all values in allowed set)
|
|
SELECT DISTINCT status
|
|
FROM target_table
|
|
WHERE status NOT IN ('active', 'inactive', 'pending', 'cancelled');
|
|
```
|
|
|
|
**Category: Freshness (data recency)**
|
|
```sql
|
|
-- EXPECT: max date within acceptable lag
|
|
SELECT MAX(loaded_at) AS last_load
|
|
FROM target_table;
|
|
-- Alert if last_load < CURRENT_TIMESTAMP - INTERVAL '24 hours'
|
|
```
|
|
|
|
**Category: Row Count Consistency**
|
|
```sql
|
|
-- EXPECT: row counts match (within tolerance)
|
|
SELECT 'source' AS source, COUNT(*) AS cnt FROM source_table
|
|
UNION ALL
|
|
SELECT 'target', COUNT(*) FROM target_table;
|
|
```
|
|
|
|
**Category: Distribution / Outlier Detection**
|
|
```sql
|
|
-- EXPECT: no rows outside 3 standard deviations
|
|
WITH stats AS (
|
|
SELECT
|
|
AVG(amount) AS avg,
|
|
STDDEV(amount) AS std
|
|
FROM orders
|
|
)
|
|
SELECT *
|
|
FROM orders, stats
|
|
WHERE ABS(orders.amount - stats.avg) > 3 * stats.std;
|
|
```
|
|
|
|
**Category: Duplicate Detection (multi-column)**
|
|
```sql
|
|
-- EXPECT: 0 rows
|
|
SELECT natural_key_1, natural_key_2, COUNT(*)
|
|
FROM target_table
|
|
GROUP BY natural_key_1, natural_key_2
|
|
HAVING COUNT(*) > 1;
|
|
```
|
|
|
|
---
|
|
|
|
### 5.2 dbt Test Patterns
|
|
|
|
dbt provides four built-in generic tests:
|
|
|
|
```yaml
|
|
# schema.yml
|
|
version: 2
|
|
models:
|
|
- name: orders
|
|
columns:
|
|
- name: order_id
|
|
tests:
|
|
- unique
|
|
- not_null
|
|
- name: status
|
|
tests:
|
|
- accepted_values:
|
|
values: ['placed', 'shipped', 'completed', 'cancelled']
|
|
- name: customer_id
|
|
tests:
|
|
- not_null
|
|
- relationships:
|
|
to: ref('customers')
|
|
field: customer_id
|
|
```
|
|
|
|
**Custom singular tests (dbt):**
|
|
```sql
|
|
-- tests/custom/positive_revenue.sql
|
|
-- EXPECT: 0 rows returned
|
|
SELECT order_id, revenue
|
|
FROM {{ ref('orders') }}
|
|
WHERE revenue < 0;
|
|
```
|
|
|
|
**Custom generic tests (dbt):**
|
|
```sql
|
|
-- tests/generic/test_is_positive.sql
|
|
{% test is_positive(model, column_name) %}
|
|
SELECT *
|
|
FROM {{ model }}
|
|
WHERE {{ column_name }} < 0
|
|
{% endtest %}
|
|
```
|
|
|
|
---
|
|
|
|
### 5.3 Testing Pipeline Patterns
|
|
|
|
**Unit testing (transformation logic):**
|
|
|
|
```sql
|
|
-- Given: a known input
|
|
WITH test_data AS (
|
|
SELECT 'US' AS country, 100 AS amount, DATE '2024-01-15' AS order_date
|
|
UNION ALL
|
|
SELECT 'UK', 200, DATE '2024-02-20'
|
|
)
|
|
-- When: apply transformation
|
|
, transformed AS (
|
|
SELECT
|
|
country,
|
|
amount,
|
|
CASE WHEN country = 'US' THEN amount * 1.0 ELSE amount * 1.2 END AS amount_usd
|
|
FROM test_data
|
|
)
|
|
-- Then: assert expected output
|
|
SELECT *
|
|
FROM transformed
|
|
WHERE (country = 'US' AND amount_usd != 100)
|
|
OR (country = 'UK' AND amount_usd != 240);
|
|
```
|
|
|
|
**Regression testing (compare output across versions):**
|
|
|
|
- Store known-good output as a reference table or CSV.
|
|
- Run the new version of the query.
|
|
- EXPECT: row-perfect match (or within delta for floating-point).
|
|
|
|
**Schema drift detection:**
|
|
```sql
|
|
-- Compare column schemas between source and target
|
|
SELECT column_name, data_type
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'source'
|
|
EXCEPT
|
|
SELECT column_name, data_type
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'target';
|
|
```
|
|
|
|
**Reconciliation (cross-system):**
|
|
|
|
```sql
|
|
SELECT
|
|
COALESCE(a.order_id, b.order_id) AS order_id,
|
|
a.total AS source_total,
|
|
b.total AS target_total,
|
|
COALESCE(a.total, 0) - COALESCE(b.total, 0) AS diff
|
|
FROM source_system.orders a
|
|
FULL OUTER JOIN target_system.orders b
|
|
ON a.order_id = b.order_id
|
|
WHERE a.total IS DISTINCT FROM b.total;
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Analytical SQL Engine Comparison
|
|
|
|
### 6.1 Engine Overview
|
|
|
|
| Feature | PostgreSQL | DuckDB | ClickHouse | BigQuery | Snowflake | Redshift |
|
|
|---|---|---|---|---|---|---|
|
|
| **Architecture** | Row-store, monolithic | Columnar, embedded | Columnar, MPP | Serverless, columnar | Virtual warehouses, columnar | Columnar, MPP |
|
|
| **Deployment** | Self-hosted / managed | Embedded / MotherDuck cloud | Self-hosted / ClickHouse Cloud | GCP only | AWS / Azure / GCP | AWS only |
|
|
| **SQL dialect** | SQL:2011 | SQL:2011 + extensions | Custom SQL (MySQL-like) | GoogleSQL | SnowflakeSQL | PostgreSQL-like |
|
|
| **ACID** | Full | Full | Per-table | Row-level (recent) | Snapshot isolation | Serial isolation |
|
|
| **Concurrency model** | Connection-based | Single-user (per process) | High-concurrency reads | Massive concurrency | Virtual warehouse scale | WLM queues |
|
|
|
|
### 6.2 Performance Characteristics
|
|
|
|
| Metric | PostgreSQL | DuckDB | ClickHouse | BigQuery | Snowflake | Redshift |
|
|
|---|---|---|---|---|---|---|
|
|
| **Scan speed (single node)** | ~50 MB/s | ~500 MB/s | ~2-5 GB/s | ~GB/s (distributed) | ~MB/s per node | ~GB/s per slice |
|
|
| **Aggregation throughput** | Moderate | Very high | Extremely high | Very high | High | High |
|
|
| **JOIN performance** | Excellent (indexed) | Good (hash join) | Good (need careful schema) | Excellent | Excellent | Good |
|
|
| **Sub-second queries** | Yes (small data) | Yes (in-memory) | Yes (columnar) | Yes (with cached) | Yes (with cached) | Yes (with SORTKEY) |
|
|
| **Full table scan** | Slow | Fast | Very fast | Fast | Moderate | Fast |
|
|
| **Concurrent queries** | Good (configurable) | Limited (single process) | Excellent | Excellent | Good (per warehouse) | Good (per WLM queue) |
|
|
|
|
### 6.3 When Each Engine Makes Sense
|
|
|
|
**PostgreSQL — The transactional foundation:**
|
|
- Source of record for OLTP systems.
|
|
- Small-to-medium analytical workloads (< 50 GB).
|
|
- When you need full ACID and complex joins.
|
|
- Data engineering: staging area, metadata store, Airflow backend.
|
|
- NOT for: multi-TB datasets, high-cardinality aggregations on billions of rows.
|
|
|
|
**DuckDB — The embedded analyst:**
|
|
- Local data exploration on Parquet/CSV files.
|
|
- Single-machine analytical workloads (up to ~100 GB comfortably in memory).
|
|
- Data engineering: dbt development, local testing, transform-in-place.
|
|
- Embedded analytics (in-process OLAP).
|
|
- NOT for: multi-user production APIs, concurrent write workloads.
|
|
|
|
**ClickHouse — The real-time powerhouse:**
|
|
- Real-time dashboards and observability.
|
|
- High-ingestion-rate event data (logs, metrics, clickstreams).
|
|
- Sub-second aggregations on billions of rows.
|
|
- Data engineering: time-series analytics, real-time monitoring, product analytics.
|
|
- NOT for: point-lookup queries, frequent small updates/deletes, complex joins.
|
|
|
|
**BigQuery — The serverless warehouse:**
|
|
- When you don't want to manage infrastructure.
|
|
- Petabyte-scale analytics with auto-scaling.
|
|
- Integration with Google Cloud ecosystem (Dataflow, Looker, Vertex AI).
|
|
- Data engineering: ELT-heavy workflows, ad-hoc analysis at scale.
|
|
- NOT for: transactional workloads, predictable monthly spend (cost can be spiky).
|
|
|
|
**Snowflake — The enterprise data cloud:**
|
|
- When multi-cloud or multi-region is required.
|
|
- Data sharing across organizations (Snowflake Marketplace).
|
|
- Separation of compute and storage with automatic scaling.
|
|
- Data engineering: production data warehouses, data sharing, BI backends.
|
|
- NOT for: real-time streaming (ingest latency is seconds), budget-constrained workloads.
|
|
|
|
**Redshift — The AWS-native warehouse:**
|
|
- Heavily invested in the AWS ecosystem.
|
|
- Predictable performance for well-defined workloads.
|
|
- Integration with S3, Glue, Spectrum, QuickSight.
|
|
- Data engineering: large-scale batch processing on AWS, BI workloads.
|
|
- NOT for: ad-hoc multi-user queries without careful WLM tuning, multi-cloud.
|
|
|
|
### 6.4 Key Feature Differences
|
|
|
|
| Feature | PostgreSQL | DuckDB | ClickHouse | BigQuery | Snowflake | Redshift |
|
|
|---|---|---|---|---|---|---|
|
|
| **Materialized views** | Manual REFRESH | Not built-in (use dbt) | Materialized views | Auto-refresh | Auto-refresh, cost credits | Late-binding views |
|
|
| **MERGE support** | `INSERT ON CONFLICT` | `MERGE` (v1.3+) | `ALTER TABLE .. DELETE` + INSERT | `MERGE` | `MERGE` | `MERGE` (RA3+) |
|
|
| **External tables** | FDW (postgres_fdw) | `read_parquet`, `read_csv` | `CREATE TABLE .. ENGINE=Kafka/MySQL` | External tables | External tables | Spectrum |
|
|
| **Window functions** | Full support | Full support | Full support | Full support | Full support | Full support |
|
|
| **Recursive CTEs** | Yes | Yes | No (non-recursive only) | Yes | Yes | Yes |
|
|
| **PIVOT** | `crosstab()` extension | `PIVOT` | No (use `GROUP BY` + arrays) | `PIVOT` | `PIVOT` | No (use CASE) |
|
|
| **Semi-structured** | JSONB | JSON, Struct, Array | JSON, Array, Tuple, Nested | REPEATED, RECORD | VARIANT | SUPER (JSON-like) |
|
|
| **Time travel** | pg_rewind (limited) | Not built-in | Not built-in (use snapshot) | Query any point in 7 days | `AT (TIMESTAMP)` up to 90 days | `RESTORE TABLE` |
|
|
| **Cost model** | Licensing + hardware | Free / MotherDuck consumption | Open source / Cloud credits | Pay per byte scanned | Pay per compute credit | Pay per node-hour |
|
|
|
|
### 6.5 Pricing and Cost Considerations
|
|
|
|
| Engine | Cost Character | Best Cost Profile | Worst Cost Profile |
|
|
|---|---|---|---|
|
|
| PostgreSQL | Fixed (HW/license) | Predictable, moderate volume | Very large datasets (no auto-scale) |
|
|
| DuckDB | Free / MotherDuck usage | Sub-TB workloads, OLAP queries | Multi-user concurrent access |
|
|
| ClickHouse | HW/cloud credits | High-volume, high-throughput real-time | Small workloads (overhead of cluster) |
|
|
| BigQuery | Per-byte scanned | Ad-hoc, infrequent large queries | Repeated full scans of large tables |
|
|
| Snowflake | Per-credit (compute) | Variable workloads with auto-suspend | Always-on large warehouse |
|
|
| Redshift | Per-node-hour (fixed) | Steady-state batch workloads | Idle clusters (pay for what you allocate) |
|
|
|
|
### 6.6 Engine Selection Matrix
|
|
|
|
| Workload Profile | Recommended Engine | Runner-Up |
|
|
|---|---|---|
|
|
| Small team, local analysis | DuckDB | PostgreSQL |
|
|
| Cloud-native analytics, GCP shop | BigQuery | Snowflake |
|
|
| Enterprise data warehouse, multi-cloud | Snowflake | BigQuery |
|
|
| AWS ecosystem, steady workloads | Redshift | Snowflake |
|
|
| Real-time observability, logs | ClickHouse | BigQuery (streaming) |
|
|
| Embedded analytics (SaaS product) | DuckDB | ClickHouse |
|
|
| Transactional + reporting (single system) | PostgreSQL | -- |
|
|
| Petabyte-scale ad-hoc | BigQuery | Snowflake |
|
|
| Budget-constrained, large batch | ClickHouse (self-hosted) | DuckDB (MotherDuck) |
|
|
|
|
---
|
|
|
|
## Appendix: Quick Reference SQL Snippets
|
|
|
|
**Common analytical queries:**
|
|
|
|
```sql
|
|
-- Top-N per group
|
|
SELECT * FROM (
|
|
SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC) AS rn
|
|
FROM sales
|
|
) WHERE rn <= 10;
|
|
|
|
-- Running total
|
|
SELECT date, SUM(amount) OVER (ORDER BY date ROWS UNBOUNDED PRECEDING) AS running_total
|
|
FROM daily;
|
|
|
|
-- Month-over-month change
|
|
WITH monthly AS (
|
|
SELECT DATE_TRUNC('month', date) AS month, SUM(val) AS val
|
|
FROM data GROUP BY 1
|
|
)
|
|
SELECT month, val,
|
|
LAG(val) OVER (ORDER BY month) AS prev,
|
|
(val - LAG(val) OVER (ORDER BY month)) / NULLIF(LAG(val) OVER (ORDER BY month), 0) * 100 AS mom_pct
|
|
FROM monthly;
|
|
|
|
-- Rolling 7-day average
|
|
SELECT date, AVG(amount) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7
|
|
FROM daily_revenue;
|
|
|
|
-- Deduplication (keep latest)
|
|
WITH deduped AS (
|
|
SELECT *, ROW_NUMBER() OVER (PARTITION BY business_key ORDER BY updated_at DESC) AS rn
|
|
FROM raw
|
|
)
|
|
SELECT * FROM deduped WHERE rn = 1;
|
|
|
|
-- Fill forward (last non-null value)
|
|
SELECT
|
|
date,
|
|
amount,
|
|
LAST_VALUE(amount IGNORE NULLS) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS filled
|
|
FROM sparse_data;
|
|
```
|
|
|
|
---
|
|
|
|
*End of reference document.*
|