
Senior Data Engineer
- 946 installs
- 23.5k repo stars
- Updated July 17, 2026
- alirezarezvani/claude-skills
senior-data-engineer is an agent skill that guides production-grade data pipeline design, ETL/ELT implementation, and DataOps optimization for developers who need reliable analytics infrastructure.
About
senior-data-engineer is a Claude Code skill for building scalable, production-grade data systems with Python, SQL, Spark, Airflow, dbt, and Kafka. The skill walks through data modeling, pipeline orchestration, data quality checks, governance patterns, and troubleshooting when workflows break or scale poorly. Developers reach for senior-data-engineer when designing new data architectures, optimizing batch or streaming pipelines, or hardening ETL jobs before they feed dashboards and downstream services. It encodes senior-level patterns for reliability, observability, and maintainability across the modern data stack rather than one-off scripts.
- Trigger-based activation for pipeline design, architecture decisions, data modeling, and data quality tasks
- Includes complete workflows for ETL/ELT, orchestration, and DataOps
- Architecture Decision Framework covering batch vs streaming and Lambda vs Kappa
- Reference implementations using Python, SQL, Spark, Airflow, dbt, and Kafka
- Troubleshooting guides for common data pipeline failures
Senior Data Engineer by the numbers
- 946 all-time installs (skills.sh)
- +7 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #305 of 2,065 Data Science & ML skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/alirezarezvani/claude-skills --skill senior-data-engineerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 946 |
|---|---|
| repo stars | ★ 23.5k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 17, 2026 |
| Repository | alirezarezvani/claude-skills ↗ |
How do you design production ETL pipelines with Airflow and dbt?
Get expert guidance on designing, building, and optimizing production-grade data pipelines and infrastructure.
Who is it for?
Backend and data engineers shipping batch or streaming pipelines on Spark, Airflow, dbt, and Kafka.
Skip if: Teams needing only ad-hoc SQL queries or frontend dashboard wiring without pipeline infrastructure work.
When should I use this skill?
User asks to design data architectures, build ETL/ELT, optimize Spark/Airflow workflows, or troubleshoot pipeline failures.
What you get
Pipeline architecture plans, dbt models, Airflow DAGs, data quality checks, and governance recommendations
- pipeline architecture
- dbt models
- orchestration plan
Files
Senior Data Engineer
Production-grade data engineering skill for building scalable, reliable data systems.
Table of Contents
1. Trigger Phrases 2. Quick Start 3. Workflows 4. Architecture Decision Framework 5. Tech Stack 6. Reference Documentation 7. Troubleshooting
---
Trigger Phrases
Activate this skill when you see:
Pipeline Design:
- "Design a data pipeline for..."
- "Build an ETL/ELT process..."
- "How should I ingest data from..."
- "Set up data extraction from..."
Architecture:
- "Should I use batch or streaming?"
- "Lambda vs Kappa architecture"
- "How to handle late-arriving data"
- "Design a data lakehouse"
Data Modeling:
- "Create a dimensional model..."
- "Star schema vs snowflake"
- "Implement slowly changing dimensions"
- "Design a data vault"
Data Quality:
- "Add data validation to..."
- "Set up data quality checks"
- "Monitor data freshness"
- "Implement data contracts"
Performance:
- "Optimize this Spark job"
- "Query is running slow"
- "Reduce pipeline execution time"
- "Tune Airflow DAG"
---
Quick Start
Core Tools
# Generate pipeline orchestration config
python scripts/pipeline_orchestrator.py generate \
--type airflow \
--source postgres \
--destination snowflake \
--schedule "0 5 * * *"
# Validate data quality
python scripts/data_quality_validator.py validate \
--input data/sales.parquet \
--schema schemas/sales.json \
--checks freshness,completeness,uniqueness
# Optimize ETL performance
python scripts/etl_performance_optimizer.py analyze \
--query queries/daily_aggregation.sql \
--engine spark \
--recommend---
Workflows
→ See references/workflows.md for details
Architecture Decision Framework
Use this framework to choose the right approach for your data pipeline.
Batch vs Streaming
| Criteria | Batch | Streaming |
|---|---|---|
| Latency requirement | Hours to days | Seconds to minutes |
| Data volume | Large historical datasets | Continuous event streams |
| Processing complexity | Complex transformations, ML | Simple aggregations, filtering |
| Cost sensitivity | More cost-effective | Higher infrastructure cost |
| Error handling | Easier to reprocess | Requires careful design |
Decision Tree:
Is real-time insight required?
├── Yes → Use streaming
│ └── Is exactly-once semantics needed?
│ ├── Yes → Kafka + Flink/Spark Structured Streaming
│ └── No → Kafka + consumer groups
└── No → Use batch
└── Is data volume > 1TB daily?
├── Yes → Spark/Databricks
└── No → dbt + warehouse computeLambda vs Kappa Architecture
| Aspect | Lambda | Kappa |
|---|---|---|
| Complexity | Two codebases (batch + stream) | Single codebase |
| Maintenance | Higher (sync batch/stream logic) | Lower |
| Reprocessing | Native batch layer | Replay from source |
| Use case | ML training + real-time serving | Pure event-driven |
When to choose Lambda:
- Need to train ML models on historical data
- Complex batch transformations not feasible in streaming
- Existing batch infrastructure
When to choose Kappa:
- Event-sourced architecture
- All processing can be expressed as stream operations
- Starting fresh without legacy systems
Data Warehouse vs Data Lakehouse
| Feature | Warehouse (Snowflake/BigQuery) | Lakehouse (Delta/Iceberg) |
|---|---|---|
| Best for | BI, SQL analytics | ML, unstructured data |
| Storage cost | Higher (proprietary format) | Lower (open formats) |
| Flexibility | Schema-on-write | Schema-on-read |
| Performance | Excellent for SQL | Good, improving |
| Ecosystem | Mature BI tools | Growing ML tooling |
---
Tech Stack
| Category | Technologies |
|---|---|
| Languages | Python, SQL, Scala |
| Orchestration | Airflow, Prefect, Dagster |
| Transformation | dbt, Spark, Flink |
| Streaming | Kafka, Kinesis, Pub/Sub |
| Storage | S3, GCS, Delta Lake, Iceberg |
| Warehouses | Snowflake, BigQuery, Redshift, Databricks |
| Quality | Great Expectations, dbt tests, Monte Carlo |
| Monitoring | Prometheus, Grafana, Datadog |
---
Reference Documentation
1. Data Pipeline Architecture
See references/data_pipeline_architecture.md for:
- Lambda vs Kappa architecture patterns
- Batch processing with Spark and Airflow
- Stream processing with Kafka and Flink
- Exactly-once semantics implementation
- Error handling and dead letter queues
2. Data Modeling Patterns
See references/data_modeling_patterns.md for:
- Dimensional modeling (Star/Snowflake)
- Slowly Changing Dimensions (SCD Types 1-6)
- Data Vault modeling
- dbt best practices
- Partitioning and clustering
3. DataOps Best Practices
See references/dataops_best_practices.md for:
- Data testing frameworks
- Data contracts and schema validation
- CI/CD for data pipelines
- Observability and lineage
- Incident response
---
Troubleshooting
→ See references/troubleshooting.md for details
Data Modeling Patterns
Comprehensive guide to data modeling for analytics and data warehousing.
Table of Contents
1. Dimensional Modeling 2. Slowly Changing Dimensions 3. Data Vault Modeling 4. dbt Best Practices 5. Partitioning and Clustering 6. Schema Evolution
---
Dimensional Modeling
Star Schema
The most common pattern for analytical data models. One fact table surrounded by dimension tables.
┌─────────────┐
│ dim_product │
└──────┬──────┘
│
┌─────────────┐ ┌───────▼───────┐ ┌─────────────┐
│ dim_customer│◄───│ fct_sales │───►│ dim_date │
└─────────────┘ └───────┬───────┘ └─────────────┘
│
┌──────▼──────┐
│ dim_store │
└─────────────┘Fact Table (fct_sales):
CREATE TABLE fct_sales (
sale_id BIGINT PRIMARY KEY,
-- Foreign keys to dimensions
customer_key INT REFERENCES dim_customer(customer_key),
product_key INT REFERENCES dim_product(product_key),
store_key INT REFERENCES dim_store(store_key),
date_key INT REFERENCES dim_date(date_key),
-- Degenerate dimension (no separate table)
order_number VARCHAR(50),
-- Measures (facts)
quantity INT,
unit_price DECIMAL(10,2),
discount_amount DECIMAL(10,2),
net_amount DECIMAL(10,2),
tax_amount DECIMAL(10,2),
total_amount DECIMAL(10,2),
-- Audit columns
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Partition by date for query performance
ALTER TABLE fct_sales
PARTITION BY RANGE (date_key);Dimension Table (dim_customer):
CREATE TABLE dim_customer (
customer_key INT PRIMARY KEY, -- Surrogate key
customer_id VARCHAR(50), -- Natural/business key
-- Attributes
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(50),
-- Hierarchies
city VARCHAR(100),
state VARCHAR(100),
country VARCHAR(100),
region VARCHAR(50),
-- SCD tracking
effective_date DATE,
expiration_date DATE,
is_current BOOLEAN,
-- Audit
created_at TIMESTAMP,
updated_at TIMESTAMP
);Date Dimension:
CREATE TABLE dim_date (
date_key INT PRIMARY KEY, -- YYYYMMDD format
full_date DATE,
-- Day attributes
day_of_week INT,
day_of_month INT,
day_of_year INT,
day_name VARCHAR(10),
is_weekend BOOLEAN,
is_holiday BOOLEAN,
-- Week attributes
week_of_year INT,
week_start_date DATE,
week_end_date DATE,
-- Month attributes
month_number INT,
month_name VARCHAR(10),
month_start_date DATE,
month_end_date DATE,
-- Quarter attributes
quarter_number INT,
quarter_name VARCHAR(10),
-- Year attributes
year_number INT,
fiscal_year INT,
fiscal_quarter INT,
-- Relative flags
is_current_day BOOLEAN,
is_current_week BOOLEAN,
is_current_month BOOLEAN,
is_current_quarter BOOLEAN,
is_current_year BOOLEAN
);
-- Generate date dimension
INSERT INTO dim_date
SELECT
TO_CHAR(d, 'YYYYMMDD')::INT as date_key,
d as full_date,
EXTRACT(DOW FROM d) as day_of_week,
EXTRACT(DAY FROM d) as day_of_month,
EXTRACT(DOY FROM d) as day_of_year,
TO_CHAR(d, 'Day') as day_name,
EXTRACT(DOW FROM d) IN (0, 6) as is_weekend,
FALSE as is_holiday, -- Update from holiday calendar
EXTRACT(WEEK FROM d) as week_of_year,
DATE_TRUNC('week', d) as week_start_date,
DATE_TRUNC('week', d) + INTERVAL '6 days' as week_end_date,
EXTRACT(MONTH FROM d) as month_number,
TO_CHAR(d, 'Month') as month_name,
DATE_TRUNC('month', d) as month_start_date,
(DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day')::DATE as month_end_date,
EXTRACT(QUARTER FROM d) as quarter_number,
'Q' || EXTRACT(QUARTER FROM d) as quarter_name,
EXTRACT(YEAR FROM d) as year_number,
-- Fiscal year (assuming July start)
CASE WHEN EXTRACT(MONTH FROM d) >= 7 THEN EXTRACT(YEAR FROM d) + 1
ELSE EXTRACT(YEAR FROM d) END as fiscal_year,
CASE WHEN EXTRACT(MONTH FROM d) >= 7 THEN CEIL((EXTRACT(MONTH FROM d) - 6) / 3.0)
ELSE CEIL((EXTRACT(MONTH FROM d) + 6) / 3.0) END as fiscal_quarter,
d = CURRENT_DATE as is_current_day,
d >= DATE_TRUNC('week', CURRENT_DATE) AND d < DATE_TRUNC('week', CURRENT_DATE) + INTERVAL '7 days' as is_current_week,
DATE_TRUNC('month', d) = DATE_TRUNC('month', CURRENT_DATE) as is_current_month,
DATE_TRUNC('quarter', d) = DATE_TRUNC('quarter', CURRENT_DATE) as is_current_quarter,
EXTRACT(YEAR FROM d) = EXTRACT(YEAR FROM CURRENT_DATE) as is_current_year
FROM generate_series('2020-01-01'::DATE, '2030-12-31'::DATE, '1 day'::INTERVAL) d;Snowflake Schema
Normalized dimensions for reduced storage and update anomalies.
┌─────────────┐
│ dim_category│
└──────┬──────┘
│
┌─────────────┐ ┌───────────▼────┐ ┌─────────────┐
│ dim_customer│◄───│ fct_sales │───►│ dim_product │
└──────┬──────┘ └───────┬────────┘ └──────┬──────┘
│ │ │
┌──────▼──────┐ ┌───────▼───────┐ ┌──────▼──────┐
│ dim_geography│ │ dim_date │ │ dim_brand │
└─────────────┘ └───────────────┘ └─────────────┘When to use Snowflake vs Star:
| Criteria | Star Schema | Snowflake Schema |
|---|---|---|
| Query complexity | Simple JOINs | More JOINs required |
| Query performance | Faster (fewer JOINs) | Slower |
| Storage | Higher (denormalized) | Lower (normalized) |
| ETL complexity | Higher | Lower |
| Dimension updates | Multiple places | Single place |
| Best for | BI/reporting | Storage-constrained |
One Big Table (OBT)
Fully denormalized single table - gaining popularity with modern columnar warehouses.
CREATE TABLE obt_sales AS
SELECT
-- Fact measures
s.sale_id,
s.quantity,
s.unit_price,
s.total_amount,
-- Customer attributes (denormalized)
c.customer_id,
c.first_name,
c.last_name,
c.email,
c.city,
c.state,
c.country,
-- Product attributes (denormalized)
p.product_id,
p.product_name,
p.category,
p.subcategory,
p.brand,
-- Date attributes (denormalized)
d.full_date as sale_date,
d.year_number,
d.quarter_number,
d.month_name,
d.week_of_year,
d.is_weekend
FROM fct_sales s
JOIN dim_customer c ON s.customer_key = c.customer_key AND c.is_current
JOIN dim_product p ON s.product_key = p.product_key AND p.is_current
JOIN dim_date d ON s.date_key = d.date_key;OBT Tradeoffs:
| Pros | Cons |
|---|---|
| Simple queries (no JOINs) | Storage bloat |
| Fast for analytics | Harder to maintain |
| Great with columnar storage | Stale data risk |
| Self-documenting | Update anomalies |
---
Slowly Changing Dimensions
Type 0: Fixed Dimension
No changes allowed - original value preserved forever.
-- Type 0: Never update these fields
CREATE TABLE dim_customer_type0 (
customer_key INT PRIMARY KEY,
customer_id VARCHAR(50),
original_signup_date DATE, -- Never changes
original_source VARCHAR(50) -- Never changes
);Type 1: Overwrite
Simply overwrite old value with new. No history preserved.
-- Type 1: Update in place
UPDATE dim_customer
SET
email = 'new.email@example.com',
updated_at = CURRENT_TIMESTAMP
WHERE customer_id = 'CUST001';
-- dbt implementation (Type 1)
-- models/dim_customer_type1.sql
{{
config(
materialized='table',
unique_key='customer_id'
)
}}
SELECT
customer_id,
first_name,
last_name,
email, -- Current value only
phone,
address,
CURRENT_TIMESTAMP as updated_at
FROM {{ source('raw', 'customers') }}Type 2: Add New Row
Create new record with new values. Full history preserved.
-- Type 2 dimension structure
CREATE TABLE dim_customer_scd2 (
customer_key SERIAL PRIMARY KEY, -- Surrogate key
customer_id VARCHAR(50), -- Natural key
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(255),
city VARCHAR(100),
state VARCHAR(100),
-- SCD2 tracking columns
effective_start_date TIMESTAMP,
effective_end_date TIMESTAMP,
is_current BOOLEAN,
-- Hash for change detection
row_hash VARCHAR(64)
);
-- SCD2 merge logic
MERGE INTO dim_customer_scd2 AS target
USING (
SELECT
customer_id,
first_name,
last_name,
email,
city,
state,
MD5(CONCAT(first_name, last_name, email, city, state)) as row_hash
FROM staging_customers
) AS source
ON target.customer_id = source.customer_id AND target.is_current = TRUE
-- Close existing record if changed
WHEN MATCHED AND target.row_hash != source.row_hash THEN
UPDATE SET
effective_end_date = CURRENT_TIMESTAMP,
is_current = FALSE
-- Insert new record for changes
WHEN NOT MATCHED OR (MATCHED AND target.row_hash != source.row_hash) THEN
INSERT (customer_id, first_name, last_name, email, city, state,
effective_start_date, effective_end_date, is_current, row_hash)
VALUES (source.customer_id, source.first_name, source.last_name, source.email,
source.city, source.state, CURRENT_TIMESTAMP, '9999-12-31', TRUE, source.row_hash);dbt SCD2 Implementation:
-- models/dim_customer_scd2.sql
{{
config(
materialized='incremental',
unique_key='customer_key',
strategy='check',
check_cols=['first_name', 'last_name', 'email', 'city', 'state']
)
}}
WITH source_data AS (
SELECT
customer_id,
first_name,
last_name,
email,
city,
state,
MD5(CONCAT_WS('|', first_name, last_name, email, city, state)) as row_hash,
CURRENT_TIMESTAMP as extracted_at
FROM {{ source('raw', 'customers') }}
),
{% if is_incremental() %}
-- Get current records that have changed
changed_records AS (
SELECT
s.*,
t.customer_key as existing_key
FROM source_data s
LEFT JOIN {{ this }} t
ON s.customer_id = t.customer_id
AND t.is_current = TRUE
WHERE t.customer_key IS NULL -- New record
OR t.row_hash != s.row_hash -- Changed record
)
{% endif %}
SELECT
{{ dbt_utils.generate_surrogate_key(['customer_id', 'extracted_at']) }} as customer_key,
customer_id,
first_name,
last_name,
email,
city,
state,
extracted_at as effective_start_date,
CAST('9999-12-31' AS TIMESTAMP) as effective_end_date,
TRUE as is_current,
row_hash
{% if is_incremental() %}
FROM changed_records
{% else %}
FROM source_data
{% endif %}Type 3: Add New Column
Add column for previous value. Limited history (usually just prior value).
-- Type 3: Previous value column
CREATE TABLE dim_customer_scd3 (
customer_key INT PRIMARY KEY,
customer_id VARCHAR(50),
city VARCHAR(100),
previous_city VARCHAR(100), -- Previous value
city_change_date DATE,
state VARCHAR(100),
previous_state VARCHAR(100),
state_change_date DATE
);
-- Update Type 3
UPDATE dim_customer_scd3
SET
previous_city = city,
city = 'New York',
city_change_date = CURRENT_DATE
WHERE customer_id = 'CUST001';Type 4: Mini-Dimension
Separate rapidly changing attributes into a mini-dimension.
-- Main customer dimension (slowly changing)
CREATE TABLE dim_customer (
customer_key INT PRIMARY KEY,
customer_id VARCHAR(50),
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(255)
);
-- Mini-dimension for rapidly changing attributes
CREATE TABLE dim_customer_profile (
profile_key INT PRIMARY KEY,
age_band VARCHAR(20), -- '18-24', '25-34', etc.
income_band VARCHAR(20), -- 'Low', 'Medium', 'High'
loyalty_tier VARCHAR(20) -- 'Bronze', 'Silver', 'Gold'
);
-- Fact table references both
CREATE TABLE fct_sales (
sale_id BIGINT PRIMARY KEY,
customer_key INT REFERENCES dim_customer,
profile_key INT REFERENCES dim_customer_profile, -- Current profile at time of sale
...
);Type 6: Hybrid (1 + 2 + 3)
Combines Types 1, 2, and 3 for maximum flexibility.
-- Type 6: Combined approach
CREATE TABLE dim_customer_scd6 (
customer_key INT PRIMARY KEY,
customer_id VARCHAR(50),
-- Current values (Type 1 - always updated)
current_city VARCHAR(100),
current_state VARCHAR(100),
-- Historical values (Type 2 - row versioned)
historical_city VARCHAR(100),
historical_state VARCHAR(100),
-- Previous values (Type 3)
previous_city VARCHAR(100),
-- SCD2 tracking
effective_start_date TIMESTAMP,
effective_end_date TIMESTAMP,
is_current BOOLEAN
);---
Data Vault Modeling
Core Concepts
Data Vault provides:
- Full historization
- Parallel loading
- Flexibility for changing business rules
- Auditability
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Hub_Customer│◄───│Link_Customer│───►│ Hub_Order │
│ │ │ _Order │ │ │
└──────┬───────┘ └─────────────┘ └──────┬──────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│Sat_Customer │ │ Sat_Order │
│ _Details │ │ _Details │
└─────────────┘ └─────────────┘Hub Tables
Business keys and surrogate keys only.
-- Hub: Business entity identifier
CREATE TABLE hub_customer (
hub_customer_key VARCHAR(64) PRIMARY KEY, -- Hash of business key
customer_id VARCHAR(50), -- Business key
load_date TIMESTAMP,
record_source VARCHAR(100)
);
-- Hub loading (idempotent insert)
INSERT INTO hub_customer (hub_customer_key, customer_id, load_date, record_source)
SELECT
MD5(customer_id) as hub_customer_key,
customer_id,
CURRENT_TIMESTAMP as load_date,
'SOURCE_CRM' as record_source
FROM staging_customers s
WHERE NOT EXISTS (
SELECT 1 FROM hub_customer h
WHERE h.customer_id = s.customer_id
);Satellite Tables
Descriptive attributes with full history.
-- Satellite: Attributes with history
CREATE TABLE sat_customer_details (
hub_customer_key VARCHAR(64),
load_date TIMESTAMP,
load_end_date TIMESTAMP,
-- Descriptive attributes
first_name VARCHAR(100),
last_name VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(50),
-- Change detection
hash_diff VARCHAR(64),
record_source VARCHAR(100),
PRIMARY KEY (hub_customer_key, load_date),
FOREIGN KEY (hub_customer_key) REFERENCES hub_customer
);
-- Satellite loading (delta detection)
INSERT INTO sat_customer_details
SELECT
MD5(s.customer_id) as hub_customer_key,
CURRENT_TIMESTAMP as load_date,
NULL as load_end_date,
s.first_name,
s.last_name,
s.email,
s.phone,
MD5(CONCAT_WS('|', s.first_name, s.last_name, s.email, s.phone)) as hash_diff,
'SOURCE_CRM' as record_source
FROM staging_customers s
LEFT JOIN sat_customer_details sat
ON MD5(s.customer_id) = sat.hub_customer_key
AND sat.load_end_date IS NULL
WHERE sat.hub_customer_key IS NULL -- New customer
OR sat.hash_diff != MD5(CONCAT_WS('|', s.first_name, s.last_name, s.email, s.phone)); -- Changed
-- Close previous satellite records
UPDATE sat_customer_details
SET load_end_date = CURRENT_TIMESTAMP
WHERE hub_customer_key IN (
SELECT MD5(customer_id) FROM staging_customers
)
AND load_end_date IS NULL
AND load_date < CURRENT_TIMESTAMP;Link Tables
Relationships between hubs.
-- Link: Relationship between entities
CREATE TABLE link_customer_order (
link_customer_order_key VARCHAR(64) PRIMARY KEY,
hub_customer_key VARCHAR(64),
hub_order_key VARCHAR(64),
load_date TIMESTAMP,
record_source VARCHAR(100),
FOREIGN KEY (hub_customer_key) REFERENCES hub_customer,
FOREIGN KEY (hub_order_key) REFERENCES hub_order
);
-- Link loading
INSERT INTO link_customer_order
SELECT
MD5(CONCAT(s.customer_id, '|', s.order_id)) as link_customer_order_key,
MD5(s.customer_id) as hub_customer_key,
MD5(s.order_id) as hub_order_key,
CURRENT_TIMESTAMP as load_date,
'SOURCE_ORDERS' as record_source
FROM staging_orders s
WHERE NOT EXISTS (
SELECT 1 FROM link_customer_order l
WHERE l.hub_customer_key = MD5(s.customer_id)
AND l.hub_order_key = MD5(s.order_id)
);---
dbt Best Practices
Model Organization
models/
├── staging/ # 1:1 with source tables
│ ├── stg_orders.sql
│ ├── stg_customers.sql
│ └── _staging.yml
├── intermediate/ # Business logic transformations
│ ├── int_orders_enriched.sql
│ └── _intermediate.yml
└── marts/ # Business-facing models
├── core/
│ ├── dim_customers.sql
│ ├── fct_orders.sql
│ └── _core.yml
└── marketing/
├── mrt_customer_segments.sql
└── _marketing.ymlStaging Models
-- models/staging/stg_orders.sql
{{
config(
materialized='view'
)
}}
WITH source AS (
SELECT * FROM {{ source('ecommerce', 'orders') }}
),
renamed AS (
SELECT
-- Primary key
id as order_id,
-- Foreign keys
customer_id,
product_id,
-- Timestamps
created_at as order_created_at,
updated_at as order_updated_at,
-- Measures
quantity,
CAST(unit_price AS DECIMAL(10,2)) as unit_price,
CAST(discount AS DECIMAL(5,2)) as discount_percent,
-- Status
UPPER(status) as order_status
FROM source
)
SELECT * FROM renamedIntermediate Models
-- models/intermediate/int_orders_enriched.sql
{{
config(
materialized='ephemeral' -- Not persisted, just CTE
)
}}
WITH orders AS (
SELECT * FROM {{ ref('stg_orders') }}
),
customers AS (
SELECT * FROM {{ ref('stg_customers') }}
),
products AS (
SELECT * FROM {{ ref('stg_products') }}
),
enriched AS (
SELECT
o.order_id,
o.order_created_at,
o.order_status,
-- Customer info
c.customer_id,
c.customer_name,
c.customer_segment,
-- Product info
p.product_id,
p.product_name,
p.category,
-- Calculated fields
o.quantity,
o.unit_price,
o.quantity * o.unit_price as gross_amount,
o.quantity * o.unit_price * (1 - COALESCE(o.discount_percent, 0) / 100) as net_amount
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id
LEFT JOIN products p ON o.product_id = p.product_id
)
SELECT * FROM enrichedIncremental Models
-- models/marts/fct_orders.sql
{{
config(
materialized='incremental',
unique_key='order_id',
incremental_strategy='merge',
on_schema_change='sync_all_columns',
cluster_by=['order_date']
)
}}
WITH orders AS (
SELECT * FROM {{ ref('int_orders_enriched') }}
{% if is_incremental() %}
-- Only process new/changed records
WHERE order_updated_at > (
SELECT COALESCE(MAX(order_updated_at), '1900-01-01')
FROM {{ this }}
)
{% endif %}
),
final AS (
SELECT
order_id,
customer_id,
product_id,
DATE(order_created_at) as order_date,
order_created_at,
order_updated_at,
order_status,
quantity,
unit_price,
gross_amount,
net_amount,
CURRENT_TIMESTAMP as _loaded_at
FROM orders
)
SELECT * FROM finalTesting
# models/marts/_core.yml
version: 2
models:
- name: fct_orders
description: "Order fact table"
columns:
- name: order_id
tests:
- unique
- not_null
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: net_amount
tests:
- not_null
- dbt_utils.accepted_range:
min_value: 0
inclusive: true
- name: order_date
tests:
- not_null
- dbt_utils.recency:
datepart: day
field: order_date
interval: 1Macros
-- macros/generate_surrogate_key.sql
{% macro generate_surrogate_key(columns) %}
{{ dbt_utils.generate_surrogate_key(columns) }}
{% endmacro %}
-- macros/cents_to_dollars.sql
{% macro cents_to_dollars(column_name) %}
ROUND({{ column_name }} / 100.0, 2)
{% endmacro %}
-- macros/safe_divide.sql
{% macro safe_divide(numerator, denominator, default=0) %}
CASE
WHEN {{ denominator }} = 0 OR {{ denominator }} IS NULL THEN {{ default }}
ELSE {{ numerator }} / {{ denominator }}
END
{% endmacro %}
-- Usage in models:
-- {{ safe_divide('revenue', 'orders') }} as avg_order_value---
Partitioning and Clustering
Partitioning Strategies
Time-based Partitioning (Most Common):
-- BigQuery
CREATE TABLE fct_events
PARTITION BY DATE(event_timestamp)
CLUSTER BY user_id, event_type
AS SELECT * FROM raw_events;
-- Snowflake (automatic micro-partitioning)
-- Explicit clustering for optimization
ALTER TABLE fct_events CLUSTER BY (event_date, user_id);
-- Spark/Delta Lake
df.write \
.format("delta") \
.partitionBy("event_date") \
.save("/path/to/table")Partition Pruning:
-- Query with partition filter (fast)
SELECT * FROM fct_events
WHERE event_date = '2024-01-15'; -- Scans only 1 partition
-- Query without partition filter (slow - full scan)
SELECT * FROM fct_events
WHERE user_id = '12345'; -- Scans all partitionsPartition Size Guidelines:
| Partition | Size Target | Notes |
|---|---|---|
| Daily | 1-10 GB | Ideal for most cases |
| Hourly | 100 MB - 1 GB | High-volume streaming |
| Monthly | 10-100 GB | Infrequent access |
Clustering
-- BigQuery clustering (up to 4 columns)
CREATE TABLE fct_sales
PARTITION BY DATE(sale_date)
CLUSTER BY customer_id, product_id
AS SELECT * FROM raw_sales;
-- Snowflake clustering
CREATE TABLE fct_sales (
sale_id INT,
customer_id VARCHAR(50),
product_id VARCHAR(50),
sale_date DATE,
amount DECIMAL(10,2)
)
CLUSTER BY (customer_id, sale_date);
-- Delta Lake Z-ordering
OPTIMIZE events ZORDER BY (user_id, event_type);When to Cluster:
| Column Type | Cluster? | Notes |
|---|---|---|
| High cardinality filter columns | Yes | customer_id, product_id |
| Join keys | Yes | Improves join performance |
| Low cardinality | Maybe | status, type (limited benefit) |
| Frequently updated | No | Clustering breaks on updates |
---
Schema Evolution
Adding Columns
-- Safe: Add nullable column
ALTER TABLE fct_orders ADD COLUMN discount_amount DECIMAL(10,2);
-- With default
ALTER TABLE fct_orders ADD COLUMN currency VARCHAR(3) DEFAULT 'USD';
-- dbt handling
{{
config(
materialized='incremental',
on_schema_change='append_new_columns'
)
}}Handling in Spark/Delta
# Delta Lake schema evolution
df.write \
.format("delta") \
.mode("append") \
.option("mergeSchema", "true") \
.save("/path/to/table")
# Explicit schema enforcement
spark.sql("""
ALTER TABLE delta.`/path/to/table`
ADD COLUMNS (new_column STRING)
""")
# Schema merge on read
df = spark.read \
.option("mergeSchema", "true") \
.format("delta") \
.load("/path/to/table")Backward Compatibility
-- Create view for backward compatibility
CREATE VIEW orders_v1 AS
SELECT
order_id,
customer_id,
amount,
-- Map new columns to old schema
COALESCE(discount_amount, 0) as discount,
COALESCE(currency, 'USD') as currency
FROM orders_v2;
-- Deprecation pattern
CREATE VIEW orders_deprecated AS
SELECT * FROM orders_v1;
-- Add comment: "DEPRECATED: Use orders_v2. Will be removed 2024-06-01"Data Contracts for Schema Changes
# contracts/orders_contract.yaml
name: orders
version: "2.0.0"
owner: data-team@company.com
schema:
order_id:
type: string
required: true
breaking_change: never
customer_id:
type: string
required: true
breaking_change: never
amount:
type: decimal
precision: 10
scale: 2
required: true
# New in v2.0.0
discount_amount:
type: decimal
precision: 10
scale: 2
required: false
added_in: "2.0.0"
default: 0
# Deprecated in v2.0.0
legacy_status:
type: string
deprecated: true
removed_in: "3.0.0"
migration: "Use order_status instead"
compatibility:
backward: true # v2 readers can read v1 data
forward: true # v1 readers can read v2 dataData Pipeline Architecture
Comprehensive guide to designing and implementing production data pipelines.
Table of Contents
1. Architecture Patterns 2. Batch Processing 3. Stream Processing 4. Exactly-Once Semantics 5. Error Handling 6. Data Ingestion Patterns 7. Orchestration
---
Architecture Patterns
Lambda Architecture
The Lambda architecture combines batch and stream processing for comprehensive data handling.
┌─────────────────────────────────────┐
│ Data Sources │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Message Queue (Kafka) │
└───────┬─────────────────┬───────────┘
│ │
┌─────────────▼─────┐ ┌───────▼─────────────┐
│ Batch Layer │ │ Speed Layer │
│ (Spark/Airflow) │ │ (Flink/Spark SS) │
└─────────────┬─────┘ └───────┬─────────────┘
│ │
┌─────────────▼─────┐ ┌───────▼─────────────┐
│ Master Dataset │ │ Real-time Views │
│ (Data Lake) │ │ (Redis/Druid) │
└─────────────┬─────┘ └───────┬─────────────┘
│ │
┌───────▼─────────────────▼───────┐
│ Serving Layer │
│ (Merged Batch + Real-time) │
└─────────────────────────────────┘Components:
1. Batch Layer
- Processes complete historical data
- Creates precomputed batch views
- Handles complex transformations, ML training
- Reprocessable from raw data
2. Speed Layer
- Processes real-time data stream
- Creates real-time views for recent data
- Low latency, simpler transformations
- Compensates for batch layer delay
3. Serving Layer
- Merges batch and real-time views
- Responds to queries
- Provides unified interface
Implementation Example:
# Batch layer: Daily aggregation with Spark
def batch_daily_aggregation(spark, date):
"""Process full day of data for batch views."""
raw_df = spark.read.parquet(f"s3://data-lake/raw/events/date={date}")
aggregated = raw_df.groupBy("user_id", "event_type") \
.agg(
count("*").alias("event_count"),
sum("revenue").alias("total_revenue"),
max("timestamp").alias("last_event")
)
aggregated.write \
.mode("overwrite") \
.partitionBy("event_type") \
.parquet(f"s3://data-lake/batch-views/daily_agg/date={date}")
# Speed layer: Real-time aggregation with Spark Structured Streaming
def speed_realtime_aggregation(spark):
"""Process streaming data for real-time views."""
stream_df = spark.readStream \
.format("kafka") \
.option("kafka.bootstrap.servers", "kafka:9092") \
.option("subscribe", "events") \
.load()
parsed = stream_df.select(
from_json(col("value").cast("string"), event_schema).alias("data")
).select("data.*")
aggregated = parsed \
.withWatermark("timestamp", "5 minutes") \
.groupBy(
window("timestamp", "1 minute"),
"user_id",
"event_type"
) \
.agg(count("*").alias("event_count"))
query = aggregated.writeStream \
.format("redis") \
.option("host", "redis") \
.outputMode("update") \
.start()
return queryKappa Architecture
Kappa simplifies Lambda by using only stream processing with replay capability.
┌─────────────────────────────────────┐
│ Data Sources │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Immutable Log (Kafka/Kinesis) │
│ (Long retention) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Stream Processor │
│ (Flink/Spark Streaming) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Serving Layer │
│ (Database/Data Warehouse) │
└─────────────────────────────────────┘Key Principles:
1. Single Processing Path: All data processed as streams 2. Immutable Log: Kafka/Kinesis as source of truth with long retention 3. Reprocessing via Replay: Re-run stream processor from beginning when needed
Reprocessing Strategy:
# Reprocessing in Kappa architecture
class KappaReprocessor:
"""Handle reprocessing by replaying from Kafka."""
def __init__(self, kafka_config, flink_job):
self.kafka = kafka_config
self.job = flink_job
def reprocess(self, from_timestamp: str):
"""Reprocess all data from a specific timestamp."""
# 1. Start new consumer group reading from timestamp
new_consumer_group = f"reprocess-{uuid.uuid4()}"
# 2. Configure stream processor with new group
self.job.set_config({
"group.id": new_consumer_group,
"auto.offset.reset": "none" # We'll set offset manually
})
# 3. Seek to timestamp
offsets = self._get_offsets_for_timestamp(from_timestamp)
self.job.seek_to_offsets(offsets)
# 4. Write to new output table/topic
output_table = f"events_reprocessed_{datetime.now().strftime('%Y%m%d')}"
self.job.set_output(output_table)
# 5. Run until caught up
self.job.run_until_caught_up()
# 6. Swap output tables atomically
self._atomic_table_swap("events", output_table)
def _get_offsets_for_timestamp(self, timestamp):
"""Get Kafka offsets for a specific timestamp."""
consumer = KafkaConsumer(bootstrap_servers=self.kafka["brokers"])
partitions = consumer.partitions_for_topic("events")
offsets = {}
for partition in partitions:
tp = TopicPartition("events", partition)
offset = consumer.offsets_for_times({tp: timestamp})
offsets[tp] = offset[tp].offset
return offsetsMedallion Architecture (Bronze/Silver/Gold)
Common in data lakehouses (Databricks, Delta Lake).
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Bronze │────▶│ Silver │────▶│ Gold │
│ (Raw Data) │ │ (Cleansed) │ │ (Analytics) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
Landing zone Validated, Aggregated,
Append-only deduplicated, business-ready
Schema evolution standardized Star schemaImplementation with Delta Lake:
# Bronze: Raw ingestion
def ingest_to_bronze(spark, source_path, bronze_path):
"""Ingest raw data to bronze layer."""
df = spark.read.format("json").load(source_path)
# Add metadata
df = df.withColumn("_ingested_at", current_timestamp()) \
.withColumn("_source_file", input_file_name())
df.write \
.format("delta") \
.mode("append") \
.option("mergeSchema", "true") \
.save(bronze_path)
# Silver: Cleansing and validation
def bronze_to_silver(spark, bronze_path, silver_path):
"""Transform bronze to silver with cleansing."""
bronze_df = spark.read.format("delta").load(bronze_path)
# Read last processed version
last_version = get_last_processed_version(silver_path, "bronze")
# Get only new records
new_records = bronze_df.filter(col("_commit_version") > last_version)
# Cleanse and validate
silver_df = new_records \
.filter(col("user_id").isNotNull()) \
.filter(col("event_type").isin(["click", "view", "purchase"])) \
.withColumn("event_date", to_date("timestamp")) \
.dropDuplicates(["event_id"])
# Merge to silver (upsert)
silver_table = DeltaTable.forPath(spark, silver_path)
silver_table.alias("target") \
.merge(
silver_df.alias("source"),
"target.event_id = source.event_id"
) \
.whenMatchedUpdateAll() \
.whenNotMatchedInsertAll() \
.execute()
# Gold: Business aggregations
def silver_to_gold(spark, silver_path, gold_path):
"""Create business-ready aggregations in gold layer."""
silver_df = spark.read.format("delta").load(silver_path)
# Daily user metrics
daily_metrics = silver_df \
.groupBy("user_id", "event_date") \
.agg(
count("*").alias("total_events"),
countDistinct("session_id").alias("sessions"),
sum(when(col("event_type") == "purchase", col("revenue")).otherwise(0)).alias("revenue"),
max("timestamp").alias("last_activity")
)
# Write as gold table
daily_metrics.write \
.format("delta") \
.mode("overwrite") \
.partitionBy("event_date") \
.save(gold_path + "/daily_user_metrics")---
Batch Processing
Apache Spark Best Practices
Memory Management
# Optimal Spark configuration for batch jobs
spark = SparkSession.builder \
.appName("BatchETL") \
.config("spark.executor.memory", "8g") \
.config("spark.executor.cores", "4") \
.config("spark.driver.memory", "4g") \
.config("spark.sql.shuffle.partitions", "200") \
.config("spark.sql.adaptive.enabled", "true") \
.config("spark.sql.adaptive.coalescePartitions.enabled", "true") \
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer") \
.getOrCreate()Memory Tuning Guidelines:
| Data Size | Executors | Memory/Executor | Cores/Executor |
|---|---|---|---|
| < 10 GB | 2-4 | 4-8 GB | 2-4 |
| 10-100 GB | 10-20 | 8-16 GB | 4-8 |
| 100+ GB | 50+ | 16-32 GB | 4-8 |
Partition Optimization
# Repartition vs Coalesce
# Repartition: Full shuffle, use for increasing partitions
df_repartitioned = df.repartition(100, "date") # Partition by column
# Coalesce: No shuffle, use for decreasing partitions
df_coalesced = df.coalesce(10) # Reduce partitions without shuffle
# Optimal partition size: 128-256 MB each
# Calculate partitions:
# num_partitions = total_data_size_mb / 200
# Check current partitions
print(f"Current partitions: {df.rdd.getNumPartitions()}")
# Repartition for optimal join performance
large_df = large_df.repartition(200, "join_key")
small_df = small_df.repartition(200, "join_key")
result = large_df.join(small_df, "join_key")Join Optimization
# Broadcast join for small tables (< 10MB by default)
from pyspark.sql.functions import broadcast
# Explicit broadcast hint
result = large_df.join(broadcast(small_df), "key")
# Increase broadcast threshold if needed
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "100m")
# Sort-merge join for large tables
spark.conf.set("spark.sql.join.preferSortMergeJoin", "true")
# Bucket tables for frequent joins
df.write \
.bucketBy(100, "customer_id") \
.sortBy("customer_id") \
.mode("overwrite") \
.saveAsTable("bucketed_orders")Caching Strategy
# Cache when:
# 1. DataFrame is used multiple times
# 2. After expensive transformations
# 3. Before iterative operations
# Use MEMORY_AND_DISK for large datasets
from pyspark import StorageLevel
df.persist(StorageLevel.MEMORY_AND_DISK)
# Cache only necessary columns
df.select("id", "value").cache()
# Unpersist when done
df.unpersist()
# Check storage
spark.catalog.clearCache() # Clear all cachesAirflow DAG Patterns
Idempotent Tasks
# Always design idempotent tasks
from airflow.decorators import dag, task
from airflow.utils.dates import days_ago
from datetime import timedelta
@dag(
schedule_interval="@daily",
start_date=days_ago(7),
catchup=True,
default_args={
"retries": 3,
"retry_delay": timedelta(minutes=5),
}
)
def idempotent_etl():
@task
def extract(execution_date=None):
"""Idempotent extraction - same date always returns same data."""
date_str = execution_date.strftime("%Y-%m-%d")
# Query for specific date only
query = f"""
SELECT * FROM source_table
WHERE DATE(created_at) = '{date_str}'
"""
return query_database(query)
@task
def transform(data):
"""Pure function - no side effects."""
return [transform_record(r) for r in data]
@task
def load(data, execution_date=None):
"""Idempotent load - delete before insert or use MERGE."""
date_str = execution_date.strftime("%Y-%m-%d")
# Option 1: Delete and reinsert
execute_sql(f"DELETE FROM target WHERE date = '{date_str}'")
insert_data(data)
# Option 2: Use MERGE/UPSERT
# MERGE INTO target USING source ON target.id = source.id
# WHEN MATCHED THEN UPDATE
# WHEN NOT MATCHED THEN INSERT
raw = extract()
transformed = transform(raw)
load(transformed)
dag = idempotent_etl()Backfill Pattern
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.utils.dates import days_ago
from datetime import datetime, timedelta
def process_date(ds, **kwargs):
"""Process a single date - supports backfill."""
logical_date = datetime.strptime(ds, "%Y-%m-%d")
# Always process specific date, not "latest"
data = extract_for_date(logical_date)
transformed = transform(data)
# Use partition/date-specific target
load_to_partition(transformed, partition=ds)
with DAG(
"backfillable_etl",
schedule_interval="@daily",
start_date=datetime(2024, 1, 1),
catchup=True, # Enable backfill
max_active_runs=3, # Limit parallel backfills
) as dag:
process = PythonOperator(
task_id="process",
python_callable=process_date,
provide_context=True,
)
# Backfill command:
# airflow dags backfill -s 2024-01-01 -e 2024-01-31 backfillable_etl---
Stream Processing
Apache Kafka Architecture
Topic Design
# Create topic with proper configuration
kafka-topics.sh --create \
--bootstrap-server localhost:9092 \
--topic user-events \
--partitions 24 \
--replication-factor 3 \
--config retention.ms=604800000 \ # 7 days
--config retention.bytes=107374182400 \ # 100GB
--config cleanup.policy=delete \
--config min.insync.replicas=2 \ # Durability
--config segment.bytes=1073741824 # 1GB segmentsPartition Count Guidelines:
| Throughput | Partitions | Notes |
|---|---|---|
| < 10K msg/s | 6-12 | Single consumer can handle |
| 10K-100K msg/s | 24-48 | Multiple consumers needed |
| > 100K msg/s | 100+ | Scale consumers with partitions |
Partition Key Selection:
# Good partition keys: Even distribution, related data together
# For user events: user_id (events for same user on same partition)
# For orders: order_id (if no ordering needed) or customer_id (if needed)
from kafka import KafkaProducer
import json
producer = KafkaProducer(
bootstrap_servers=['localhost:9092'],
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
key_serializer=lambda k: k.encode('utf-8')
)
def send_event(event):
# Use user_id as key for user-based partitioning
producer.send(
topic='user-events',
key=event['user_id'], # Partition key
value=event
)Spark Structured Streaming
Watermarks and Late Data
from pyspark.sql.functions import window, col
# Read stream
events = spark.readStream \
.format("kafka") \
.option("kafka.bootstrap.servers", "localhost:9092") \
.option("subscribe", "events") \
.load() \
.select(from_json(col("value").cast("string"), schema).alias("data")) \
.select("data.*")
# Add watermark for late data handling
# Data arriving more than 10 minutes late will be dropped
windowed_counts = events \
.withWatermark("event_time", "10 minutes") \
.groupBy(
window("event_time", "5 minutes", "1 minute"), # 5-min windows, 1-min slide
"event_type"
) \
.count()
# Write with append mode (only final results for complete windows)
query = windowed_counts.writeStream \
.format("delta") \
.outputMode("append") \
.option("checkpointLocation", "/checkpoints/windowed_counts") \
.start()Watermark Behavior:
Timeline: ─────────────────────────────────────────▶
Events: E1 E2 E3 E4(late) E5
│ │ │ │ │
Time: 10:00 10:02 10:05 10:03 10:15
▲ ▲
│ │
Current Arrives at 10:15
watermark but event_time=10:03
= max_event_time
- threshold
= 10:05 - 10min If watermark > event_time:
= 9:55 Event is dropped (too late)Stateful Operations
from pyspark.sql.functions import pandas_udf, PandasUDFType
from pyspark.sql.streaming.state import GroupState, GroupStateTimeout
# Session windows using flatMapGroupsWithState
def session_aggregation(key, events, state):
"""Aggregate events into sessions with 30-minute timeout."""
# Get or initialize state
if state.exists:
session = state.get
else:
session = {"start": None, "events": [], "total": 0}
# Process new events
for event in events:
if session["start"] is None:
session["start"] = event.timestamp
session["events"].append(event)
session["total"] += event.value
# Set timeout (session expires after 30 min of inactivity)
state.setTimeoutDuration("30 minutes")
# Check if session should close
if state.hasTimedOut():
# Emit completed session
output = {
"user_id": key,
"session_start": session["start"],
"event_count": len(session["events"]),
"total_value": session["total"]
}
state.remove()
yield output
else:
# Update state
state.update(session)
# Apply stateful operation
sessions = events \
.groupByKey(lambda e: e.user_id) \
.flatMapGroupsWithState(
session_aggregation,
outputMode="append",
stateTimeout=GroupStateTimeout.ProcessingTimeTimeout()
)---
Exactly-Once Semantics
Producer Idempotence
from kafka import KafkaProducer
# Enable idempotent producer
producer = KafkaProducer(
bootstrap_servers=['localhost:9092'],
acks='all', # Wait for all replicas
enable_idempotence=True, # Exactly-once per partition
max_in_flight_requests_per_connection=5, # Max with idempotence
retries=2147483647, # Infinite retries
value_serializer=lambda v: json.dumps(v).encode('utf-8')
)
# Producer will deduplicate based on sequence numbers
for i in range(100):
producer.send('topic', {'id': i, 'data': 'value'})
producer.flush()Transactional Processing
from kafka import KafkaProducer, KafkaConsumer
from kafka.errors import KafkaError
# Transactional producer
producer = KafkaProducer(
bootstrap_servers=['localhost:9092'],
transactional_id='my-transactional-id', # Enable transactions
enable_idempotence=True,
acks='all'
)
producer.init_transactions()
def process_with_transactions(consumer, producer):
"""Read-process-write with exactly-once semantics."""
try:
producer.begin_transaction()
# Read
records = consumer.poll(timeout_ms=1000)
for tp, messages in records.items():
for message in messages:
# Process
result = transform(message.value)
# Write to output topic
producer.send('output-topic', result)
# Commit offsets and transaction atomically
producer.send_offsets_to_transaction(
consumer.position(consumer.assignment()),
consumer.group_id
)
producer.commit_transaction()
except KafkaError as e:
producer.abort_transaction()
raiseSpark Exactly-Once to External Systems
# Use foreachBatch with idempotent writes
def write_to_database_idempotent(batch_df, batch_id):
"""Write batch with exactly-once semantics."""
# Add batch_id for deduplication
batch_with_id = batch_df.withColumn("batch_id", lit(batch_id))
# Use MERGE for idempotent writes
batch_with_id.write \
.format("jdbc") \
.option("url", "jdbc:postgresql://localhost/db") \
.option("dbtable", "staging_events") \
.option("driver", "org.postgresql.Driver") \
.mode("append") \
.save()
# Merge staging to final (idempotent)
execute_sql("""
MERGE INTO events AS target
USING staging_events AS source
ON target.event_id = source.event_id
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *
""")
# Clean staging
execute_sql("TRUNCATE staging_events")
query = events.writeStream \
.foreachBatch(write_to_database_idempotent) \
.option("checkpointLocation", "/checkpoints/to-postgres") \
.start()---
Error Handling
Dead Letter Queue (DLQ)
class DeadLetterQueue:
"""Handle failed records with dead letter queue pattern."""
def __init__(self, dlq_topic: str, producer: KafkaProducer):
self.dlq_topic = dlq_topic
self.producer = producer
def send_to_dlq(self, record, error: Exception, context: dict):
"""Send failed record to DLQ with error metadata."""
dlq_record = {
"original_record": record,
"error_type": type(error).__name__,
"error_message": str(error),
"timestamp": datetime.utcnow().isoformat(),
"context": context,
"retry_count": context.get("retry_count", 0)
}
self.producer.send(
self.dlq_topic,
value=json.dumps(dlq_record).encode('utf-8')
)
def process_with_dlq(consumer, processor, dlq):
"""Process records with DLQ for failures."""
for message in consumer:
try:
result = processor.process(message.value)
# Success - commit offset
consumer.commit()
except ValidationError as e:
# Non-retryable - send to DLQ immediately
dlq.send_to_dlq(
message.value,
e,
{"topic": message.topic, "partition": message.partition}
)
consumer.commit() # Don't retry
except TemporaryError as e:
# Retryable - don't commit, let consumer retry
# After max retries, send to DLQ
retry_count = message.headers.get("retry_count", 0)
if retry_count >= MAX_RETRIES:
dlq.send_to_dlq(message.value, e, {"retry_count": retry_count})
consumer.commit()
else:
raise # Will be retriedCircuit Breaker
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum
import threading
class CircuitState(Enum):
CLOSED = "closed" # Normal operation
OPEN = "open" # Failing, reject calls
HALF_OPEN = "half_open" # Testing if recovered
@dataclass
class CircuitBreaker:
"""Circuit breaker for external service calls."""
failure_threshold: int = 5
recovery_timeout: timedelta = timedelta(seconds=30)
success_threshold: int = 3
def __post_init__(self):
self.state = CircuitState.CLOSED
self.failure_count = 0
self.success_count = 0
self.last_failure_time = None
self.lock = threading.Lock()
def call(self, func, *args, **kwargs):
"""Execute function with circuit breaker protection."""
with self.lock:
if self.state == CircuitState.OPEN:
if self._should_attempt_reset():
self.state = CircuitState.HALF_OPEN
else:
raise CircuitOpenError("Circuit is open")
try:
result = func(*args, **kwargs)
self._record_success()
return result
except Exception as e:
self._record_failure()
raise
def _record_success(self):
with self.lock:
if self.state == CircuitState.HALF_OPEN:
self.success_count += 1
if self.success_count >= self.success_threshold:
self.state = CircuitState.CLOSED
self.failure_count = 0
self.success_count = 0
elif self.state == CircuitState.CLOSED:
self.failure_count = 0
def _record_failure(self):
with self.lock:
self.failure_count += 1
self.last_failure_time = datetime.now()
if self.state == CircuitState.HALF_OPEN:
self.state = CircuitState.OPEN
self.success_count = 0
elif self.failure_count >= self.failure_threshold:
self.state = CircuitState.OPEN
def _should_attempt_reset(self):
if self.last_failure_time is None:
return True
return datetime.now() - self.last_failure_time >= self.recovery_timeout
# Usage
circuit = CircuitBreaker(failure_threshold=5, recovery_timeout=timedelta(seconds=60))
def call_external_api(data):
return circuit.call(external_api.process, data)---
Data Ingestion Patterns
Change Data Capture (CDC)
# Using Debezium with Kafka Connect
# connector-config.json
{
"name": "postgres-cdc-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "debezium",
"database.password": "password",
"database.dbname": "source_db",
"database.server.name": "source",
"table.include.list": "public.orders,public.customers",
"plugin.name": "pgoutput",
"publication.name": "dbz_publication",
"slot.name": "debezium_slot",
"transforms": "unwrap",
"transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
"transforms.unwrap.drop.tombstones": "false"
}
}Processing CDC Events:
def process_cdc_event(event):
"""Process Debezium CDC event."""
operation = event.get("op")
if operation == "c": # Create (INSERT)
after = event.get("after")
return {"action": "insert", "data": after}
elif operation == "u": # Update
before = event.get("before")
after = event.get("after")
return {"action": "update", "before": before, "after": after}
elif operation == "d": # Delete
before = event.get("before")
return {"action": "delete", "data": before}
elif operation == "r": # Read (snapshot)
after = event.get("after")
return {"action": "snapshot", "data": after}Bulk Ingestion
# Efficient bulk loading to data warehouse
from concurrent.futures import ThreadPoolExecutor
import boto3
class BulkIngester:
"""Bulk ingest data to Snowflake via S3."""
def __init__(self, s3_bucket: str, snowflake_conn):
self.s3 = boto3.client('s3')
self.bucket = s3_bucket
self.snowflake = snowflake_conn
def ingest_dataframe(self, df, table_name: str, mode: str = "append"):
"""Bulk ingest DataFrame to Snowflake."""
# 1. Write to S3 as Parquet (compressed, columnar)
s3_path = f"s3://{self.bucket}/staging/{table_name}/{uuid.uuid4()}"
df.write.parquet(s3_path)
# 2. Create external stage if not exists
self.snowflake.execute(f"""
CREATE STAGE IF NOT EXISTS {table_name}_stage
URL = '{s3_path}'
CREDENTIALS = (AWS_KEY_ID='...' AWS_SECRET_KEY='...')
FILE_FORMAT = (TYPE = 'PARQUET')
""")
# 3. COPY INTO (much faster than INSERT)
if mode == "overwrite":
self.snowflake.execute(f"TRUNCATE TABLE {table_name}")
self.snowflake.execute(f"""
COPY INTO {table_name}
FROM @{table_name}_stage
FILE_FORMAT = (TYPE = 'PARQUET')
MATCH_BY_COLUMN_NAME = CASE_INSENSITIVE
ON_ERROR = 'CONTINUE'
""")
# 4. Cleanup staging files
self._cleanup_s3(s3_path)---
Orchestration
Dependency Management
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.sensors.external_task import ExternalTaskSensor
from airflow.utils.task_group import TaskGroup
from datetime import timedelta
with DAG("complex_pipeline") as dag:
# Wait for upstream DAG
wait_for_source = ExternalTaskSensor(
task_id="wait_for_source_etl",
external_dag_id="source_etl_dag",
external_task_id="final_task",
execution_delta=timedelta(hours=0),
timeout=3600,
mode="poke",
poke_interval=60,
)
# Parallel extraction group
with TaskGroup("extract") as extract_group:
extract_orders = PythonOperator(
task_id="extract_orders",
python_callable=extract_orders_func,
)
extract_customers = PythonOperator(
task_id="extract_customers",
python_callable=extract_customers_func,
)
extract_products = PythonOperator(
task_id="extract_products",
python_callable=extract_products_func,
)
# Sequential transformation
with TaskGroup("transform") as transform_group:
join_data = PythonOperator(
task_id="join_data",
python_callable=join_func,
)
aggregate = PythonOperator(
task_id="aggregate",
python_callable=aggregate_func,
)
join_data >> aggregate
# Load
load = PythonOperator(
task_id="load",
python_callable=load_func,
)
# Define dependencies
wait_for_source >> extract_group >> transform_group >> loadDynamic DAG Generation
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime
import yaml
def create_etl_dag(config: dict) -> DAG:
"""Factory function to create ETL DAGs from config."""
dag = DAG(
dag_id=f"etl_{config['source']}_{config['destination']}",
schedule_interval=config.get('schedule', '@daily'),
start_date=datetime(2024, 1, 1),
catchup=False,
tags=['etl', 'auto-generated'],
)
with dag:
extract = PythonOperator(
task_id='extract',
python_callable=create_extract_func(config['source']),
)
transform = PythonOperator(
task_id='transform',
python_callable=create_transform_func(config['transformations']),
)
load = PythonOperator(
task_id='load',
python_callable=create_load_func(config['destination']),
)
extract >> transform >> load
return dag
# Load configurations
with open('/config/etl_pipelines.yaml') as f:
configs = yaml.safe_load(f)
# Generate DAGs
for config in configs['pipelines']:
dag_id = f"etl_{config['source']}_{config['destination']}"
globals()[dag_id] = create_etl_dag(config)DataOps Best Practices
Comprehensive guide to DataOps practices for production data systems.
Table of Contents
1. Data Testing Frameworks 2. Data Contracts 3. CI/CD for Data Pipelines 4. Observability and Lineage 5. Incident Response 6. Cost Optimization
---
Data Testing Frameworks
Great Expectations
# great_expectations_suite.py
import great_expectations as gx
from great_expectations.core.batch import BatchRequest
# Initialize context
context = gx.get_context()
# Create expectation suite
suite = context.add_expectation_suite("orders_suite")
# Get validator
validator = context.get_validator(
batch_request=BatchRequest(
datasource_name="warehouse",
data_asset_name="orders",
),
expectation_suite_name="orders_suite"
)
# Schema expectations
validator.expect_table_columns_to_match_set(
column_set=["order_id", "customer_id", "amount", "created_at", "status"],
exact_match=True
)
# Completeness expectations
validator.expect_column_values_to_not_be_null(
column="order_id",
mostly=1.0 # 100% must be non-null
)
validator.expect_column_values_to_not_be_null(
column="customer_id",
mostly=0.99 # 99% must be non-null
)
# Uniqueness expectations
validator.expect_column_values_to_be_unique("order_id")
# Type expectations
validator.expect_column_values_to_be_of_type("amount", "FLOAT")
validator.expect_column_values_to_be_of_type("created_at", "TIMESTAMP")
# Range expectations
validator.expect_column_values_to_be_between(
column="amount",
min_value=0,
max_value=1000000,
mostly=0.999
)
# Categorical expectations
validator.expect_column_values_to_be_in_set(
column="status",
value_set=["pending", "confirmed", "shipped", "delivered", "cancelled"]
)
# Distribution expectations
validator.expect_column_mean_to_be_between(
column="amount",
min_value=50,
max_value=500
)
# Freshness expectations
validator.expect_column_max_to_be_between(
column="created_at",
min_value={"$PARAMETER": "now() - interval '24 hours'"},
max_value={"$PARAMETER": "now()"}
)
# Cross-table expectations (referential integrity)
validator.expect_column_pair_values_to_be_in_set(
column_A="customer_id",
column_B="customer_status",
value_pairs_set=[
("cust_001", "active"),
("cust_002", "active"),
# ...
]
)
# Save suite
validator.save_expectation_suite(discard_failed_expectations=False)
# Run validation
checkpoint = context.add_or_update_checkpoint(
name="orders_checkpoint",
validations=[
{
"batch_request": {
"datasource_name": "warehouse",
"data_asset_name": "orders",
},
"expectation_suite_name": "orders_suite",
}
],
)
results = checkpoint.run()
print(f"Validation success: {results.success}")dbt Tests
# models/marts/schema.yml
version: 2
models:
- name: fct_orders
description: "Order fact table with comprehensive testing"
# Model-level tests
tests:
# Row count consistency
- dbt_utils.equal_rowcount:
compare_model: ref('stg_orders')
# Expression test
- dbt_utils.expression_is_true:
expression: "net_amount >= 0"
# Recency test
- dbt_utils.recency:
datepart: hour
field: _loaded_at
interval: 24
columns:
- name: order_id
description: "Primary key - unique order identifier"
tests:
- unique
- not_null
- dbt_expectations.expect_column_values_to_match_regex:
regex: "^ORD-[0-9]{10}$"
- name: customer_id
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
severity: warn # Don't fail, just warn
- name: order_date
tests:
- not_null
- dbt_expectations.expect_column_values_to_be_between:
min_value: "'2020-01-01'"
max_value: "current_date"
- name: net_amount
tests:
- not_null
- dbt_utils.accepted_range:
min_value: 0
max_value: 1000000
inclusive: true
- name: quantity
tests:
- dbt_expectations.expect_column_values_to_be_between:
min_value: 1
max_value: 1000
row_condition: "status != 'cancelled'"
- name: status
tests:
- accepted_values:
values: ['pending', 'confirmed', 'shipped', 'delivered', 'cancelled']
- name: dim_customers
columns:
- name: customer_id
tests:
- unique
- not_null
- name: email
tests:
- unique:
where: "is_current = true"
- dbt_expectations.expect_column_values_to_match_regex:
regex: "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$"
# Custom generic test
# tests/generic/test_no_orphan_records.sql
{% test no_orphan_records(model, column_name, parent_model, parent_column) %}
SELECT {{ column_name }}
FROM {{ model }}
WHERE {{ column_name }} NOT IN (
SELECT {{ parent_column }}
FROM {{ parent_model }}
)
{% endtest %}Custom Data Quality Checks
# data_quality/quality_checks.py
from dataclasses import dataclass
from typing import List, Dict, Any, Callable
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
@dataclass
class QualityCheck:
name: str
description: str
severity: str # "critical", "warning", "info"
check_func: Callable
threshold: float = 1.0
@dataclass
class QualityResult:
check_name: str
passed: bool
actual_value: float
threshold: float
message: str
timestamp: datetime
class DataQualityValidator:
"""Comprehensive data quality validation framework."""
def __init__(self, connection):
self.conn = connection
self.checks: List[QualityCheck] = []
self.results: List[QualityResult] = []
def add_check(self, check: QualityCheck):
self.checks.append(check)
# Built-in check generators
def add_null_check(self, table: str, column: str, max_null_rate: float = 0.0):
def check_nulls():
query = f"""
SELECT
COUNT(*) as total,
SUM(CASE WHEN {column} IS NULL THEN 1 ELSE 0 END) as nulls
FROM {table}
"""
result = self.conn.execute(query).fetchone()
null_rate = result[1] / result[0] if result[0] > 0 else 0
return null_rate <= max_null_rate, null_rate
self.add_check(QualityCheck(
name=f"null_check_{table}_{column}",
description=f"Check null rate for {table}.{column}",
severity="critical" if max_null_rate == 0 else "warning",
check_func=check_nulls,
threshold=max_null_rate
))
def add_uniqueness_check(self, table: str, column: str):
def check_unique():
query = f"""
SELECT
COUNT(*) as total,
COUNT(DISTINCT {column}) as distinct_count
FROM {table}
"""
result = self.conn.execute(query).fetchone()
is_unique = result[0] == result[1]
duplicate_rate = 1 - (result[1] / result[0]) if result[0] > 0 else 0
return is_unique, duplicate_rate
self.add_check(QualityCheck(
name=f"uniqueness_check_{table}_{column}",
description=f"Check uniqueness for {table}.{column}",
severity="critical",
check_func=check_unique,
threshold=0.0
))
def add_freshness_check(self, table: str, timestamp_column: str, max_hours: int):
def check_freshness():
query = f"""
SELECT MAX({timestamp_column}) as latest
FROM {table}
"""
result = self.conn.execute(query).fetchone()
if result[0] is None:
return False, float('inf')
hours_old = (datetime.now() - result[0]).total_seconds() / 3600
return hours_old <= max_hours, hours_old
self.add_check(QualityCheck(
name=f"freshness_check_{table}",
description=f"Check data freshness for {table}",
severity="critical",
check_func=check_freshness,
threshold=max_hours
))
def add_range_check(self, table: str, column: str, min_val: float, max_val: float):
def check_range():
query = f"""
SELECT
COUNT(*) as total,
SUM(CASE WHEN {column} < {min_val} OR {column} > {max_val} THEN 1 ELSE 0 END) as out_of_range
FROM {table}
"""
result = self.conn.execute(query).fetchone()
violation_rate = result[1] / result[0] if result[0] > 0 else 0
return violation_rate == 0, violation_rate
self.add_check(QualityCheck(
name=f"range_check_{table}_{column}",
description=f"Check range [{min_val}, {max_val}] for {table}.{column}",
severity="warning",
check_func=check_range,
threshold=0.0
))
def add_referential_integrity_check(self, child_table: str, child_column: str,
parent_table: str, parent_column: str):
def check_referential():
query = f"""
SELECT COUNT(*)
FROM {child_table} c
LEFT JOIN {parent_table} p ON c.{child_column} = p.{parent_column}
WHERE p.{parent_column} IS NULL AND c.{child_column} IS NOT NULL
"""
result = self.conn.execute(query).fetchone()
orphan_count = result[0]
return orphan_count == 0, orphan_count
self.add_check(QualityCheck(
name=f"referential_integrity_{child_table}_{child_column}",
description=f"Check FK {child_table}.{child_column} -> {parent_table}.{parent_column}",
severity="warning",
check_func=check_referential,
threshold=0
))
def run_all_checks(self) -> Dict[str, Any]:
"""Execute all quality checks and return results."""
self.results = []
for check in self.checks:
try:
passed, actual_value = check.check_func()
result = QualityResult(
check_name=check.name,
passed=passed,
actual_value=actual_value,
threshold=check.threshold,
message=f"{'PASSED' if passed else 'FAILED'}: {check.description}",
timestamp=datetime.now()
)
except Exception as e:
result = QualityResult(
check_name=check.name,
passed=False,
actual_value=-1,
threshold=check.threshold,
message=f"ERROR: {str(e)}",
timestamp=datetime.now()
)
self.results.append(result)
logger.info(result.message)
# Summary
total = len(self.results)
passed = sum(1 for r in self.results if r.passed)
failed = total - passed
critical_failures = [
r for r, c in zip(self.results, self.checks)
if not r.passed and c.severity == "critical"
]
return {
"total_checks": total,
"passed": passed,
"failed": failed,
"success_rate": passed / total if total > 0 else 0,
"critical_failures": len(critical_failures),
"results": self.results,
"overall_passed": len(critical_failures) == 0
}---
Data Contracts
Contract Definition
# contracts/orders_v2.yaml
contract:
name: orders
version: "2.0.0"
owner: data-platform@company.com
team: Data Engineering
slack_channel: "#data-platform-alerts"
description: |
Order events from the e-commerce platform.
Contains all customer orders with line items.
schema:
type: object
required:
- order_id
- customer_id
- created_at
- total_amount
properties:
order_id:
type: string
format: uuid
description: "Unique order identifier"
pii: false
breaking_change: never
customer_id:
type: string
description: "Customer identifier (foreign key)"
pii: true
retention_days: 365
created_at:
type: timestamp
format: "ISO8601"
timezone: "UTC"
description: "Order creation timestamp"
total_amount:
type: decimal
precision: 10
scale: 2
minimum: 0
description: "Total order amount in USD"
status:
type: string
enum: ["pending", "confirmed", "shipped", "delivered", "cancelled"]
default: "pending"
line_items:
type: array
items:
type: object
properties:
product_id:
type: string
quantity:
type: integer
minimum: 1
unit_price:
type: decimal
# Quality SLAs
quality:
freshness:
max_delay_minutes: 60
check_frequency: "*/15 * * * *" # Every 15 minutes
completeness:
required_fields_null_rate: 0.0
optional_fields_null_rate: 0.05
uniqueness:
order_id: true
combination: ["order_id", "line_item_id"]
validity:
total_amount:
min: 0
max: 1000000
status:
allowed_values: ["pending", "confirmed", "shipped", "delivered", "cancelled"]
volume:
min_daily_records: 1000
max_daily_records: 1000000
anomaly_threshold: 0.5 # 50% deviation from average
# Semantic versioning rules
versioning:
breaking_changes:
- removing_required_field
- changing_field_type
- renaming_field
non_breaking_changes:
- adding_optional_field
- adding_enum_value
- changing_description
# Consumers
consumers:
- name: analytics-dashboard
team: Analytics
contact: analytics@company.com
usage: "Daily KPI dashboards"
required_fields: ["order_id", "customer_id", "total_amount", "created_at"]
- name: ml-churn-prediction
team: ML Platform
contact: ml-team@company.com
usage: "Customer churn prediction model"
required_fields: ["customer_id", "created_at", "total_amount"]
- name: finance-reporting
team: Finance
contact: finance@company.com
usage: "Revenue reconciliation"
required_fields: ["order_id", "total_amount", "status"]
# Change management
change_process:
notification_lead_time_days: 14
approval_required_from:
- data-platform-lead
- affected-consumer-teams
rollback_plan_required: trueContract Validation
# contracts/validator.py
import yaml
import json
from dataclasses import dataclass
from typing import Dict, List, Any, Optional
from datetime import datetime
import jsonschema
@dataclass
class ContractValidationResult:
contract_name: str
version: str
timestamp: datetime
passed: bool
schema_valid: bool
quality_checks_passed: bool
sla_checks_passed: bool
violations: List[Dict[str, Any]]
class ContractValidator:
"""Validate data against contract definitions."""
def __init__(self, contract_path: str):
with open(contract_path) as f:
self.contract = yaml.safe_load(f)
self.contract_name = self.contract['contract']['name']
self.version = self.contract['contract']['version']
def validate_schema(self, data: List[Dict]) -> List[Dict]:
"""Validate data against JSON schema."""
violations = []
schema = self.contract['schema']
for i, record in enumerate(data):
try:
jsonschema.validate(record, schema)
except jsonschema.ValidationError as e:
violations.append({
"type": "schema_violation",
"record_index": i,
"field": e.path[0] if e.path else None,
"message": e.message
})
return violations
def validate_quality_slas(self, connection, table_name: str) -> List[Dict]:
"""Validate quality SLAs."""
violations = []
quality = self.contract.get('quality', {})
# Freshness check
if 'freshness' in quality:
max_delay = quality['freshness']['max_delay_minutes']
query = f"SELECT MAX(created_at) FROM {table_name}"
result = connection.execute(query).fetchone()
if result[0]:
age_minutes = (datetime.now() - result[0]).total_seconds() / 60
if age_minutes > max_delay:
violations.append({
"type": "freshness_violation",
"sla": f"max_delay_minutes: {max_delay}",
"actual": f"{age_minutes:.0f} minutes old",
"severity": "critical"
})
# Completeness check
if 'completeness' in quality:
for field in self.contract['schema'].get('required', []):
query = f"""
SELECT
COUNT(*) as total,
SUM(CASE WHEN {field} IS NULL THEN 1 ELSE 0 END) as nulls
FROM {table_name}
"""
result = connection.execute(query).fetchone()
null_rate = result[1] / result[0] if result[0] > 0 else 0
max_rate = quality['completeness']['required_fields_null_rate']
if null_rate > max_rate:
violations.append({
"type": "completeness_violation",
"field": field,
"sla": f"null_rate <= {max_rate}",
"actual": f"null_rate = {null_rate:.4f}",
"severity": "critical"
})
# Uniqueness check
if 'uniqueness' in quality:
for field, should_be_unique in quality['uniqueness'].items():
if field == 'combination':
continue
if should_be_unique:
query = f"""
SELECT COUNT(*) - COUNT(DISTINCT {field})
FROM {table_name}
"""
result = connection.execute(query).fetchone()
if result[0] > 0:
violations.append({
"type": "uniqueness_violation",
"field": field,
"duplicates": result[0],
"severity": "critical"
})
# Volume check
if 'volume' in quality:
query = f"SELECT COUNT(*) FROM {table_name} WHERE DATE(created_at) = CURRENT_DATE"
result = connection.execute(query).fetchone()
daily_count = result[0]
if daily_count < quality['volume']['min_daily_records']:
violations.append({
"type": "volume_violation",
"sla": f"min_daily_records: {quality['volume']['min_daily_records']}",
"actual": daily_count,
"severity": "warning"
})
return violations
def validate(self, connection, table_name: str, sample_data: List[Dict] = None) -> ContractValidationResult:
"""Run full contract validation."""
violations = []
# Schema validation (on sample data)
schema_violations = []
if sample_data:
schema_violations = self.validate_schema(sample_data)
violations.extend(schema_violations)
# Quality SLA validation
quality_violations = self.validate_quality_slas(connection, table_name)
violations.extend(quality_violations)
return ContractValidationResult(
contract_name=self.contract_name,
version=self.version,
timestamp=datetime.now(),
passed=len([v for v in violations if v.get('severity') == 'critical']) == 0,
schema_valid=len(schema_violations) == 0,
quality_checks_passed=len([v for v in quality_violations if v.get('severity') == 'critical']) == 0,
sla_checks_passed=True, # Add SLA timing checks
violations=violations
)---
CI/CD for Data Pipelines
GitHub Actions Workflow
# .github/workflows/data-pipeline-ci.yml
name: Data Pipeline CI/CD
on:
push:
branches: [main, develop]
paths:
- 'dbt/**'
- 'airflow/**'
- 'tests/**'
pull_request:
branches: [main]
env:
DBT_PROFILES_DIR: ./dbt
SNOWFLAKE_ACCOUNT: ${{ secrets.SNOWFLAKE_ACCOUNT }}
SNOWFLAKE_USER: ${{ secrets.SNOWFLAKE_USER }}
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install sqlfluff dbt-core dbt-snowflake
- name: Lint SQL
run: |
sqlfluff lint dbt/models --dialect snowflake
- name: Lint dbt project
run: |
cd dbt && dbt deps && dbt compile
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install dbt-core dbt-snowflake pytest great-expectations
- name: Run dbt tests on CI schema
run: |
cd dbt
dbt deps
dbt seed --target ci
dbt run --target ci --select state:modified+
dbt test --target ci --select state:modified+
- name: Run data contract tests
run: |
pytest tests/contracts/ -v
- name: Run Great Expectations validation
run: |
great_expectations checkpoint run ci_checkpoint
deploy-staging:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- uses: actions/checkout@v4
- name: Deploy to staging
run: |
cd dbt
dbt deps
dbt run --target staging
dbt test --target staging
- name: Run data quality checks
run: |
python scripts/run_quality_checks.py --env staging
deploy-production:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
cd dbt
dbt deps
dbt run --target prod --full-refresh-models tag:full_refresh
dbt run --target prod
dbt test --target prod
- name: Notify on success
if: success()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-H 'Content-type: application/json' \
-d '{"text":"dbt production deployment successful!"}'
- name: Notify on failure
if: failure()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-H 'Content-type: application/json' \
-d '{"text":"dbt production deployment FAILED!"}'dbt CI Configuration
# dbt_project.yml
name: 'analytics'
version: '1.0.0'
config-version: 2
profile: 'analytics'
model-paths: ["models"]
analysis-paths: ["analyses"]
test-paths: ["tests"]
seed-paths: ["seeds"]
macro-paths: ["macros"]
snapshot-paths: ["snapshots"]
target-path: "target"
clean-targets: ["target", "dbt_packages"]
# Slim CI configuration
on-run-start:
- "{{ dbt_utils.log_info('Starting dbt run') }}"
on-run-end:
- "{{ dbt_utils.log_info('dbt run complete') }}"
vars:
# CI testing with limited data
ci_limit: "{{ 1000 if target.name == 'ci' else none }}"
# Model configurations
models:
analytics:
staging:
+materialized: view
+schema: staging
intermediate:
+materialized: ephemeral
marts:
+materialized: table
+schema: marts
core:
+tags: ['core', 'daily']
marketing:
+tags: ['marketing', 'daily']Slim CI with State Comparison
# scripts/slim_ci.sh
#!/bin/bash
set -e
# Download production manifest for state comparison
aws s3 cp s3://dbt-artifacts/prod/manifest.json ./target/prod_manifest.json
# Run only modified models and their downstream dependencies
dbt run \
--target ci \
--select state:modified+ \
--state ./target/prod_manifest.json
# Test only affected models
dbt test \
--target ci \
--select state:modified+ \
--state ./target/prod_manifest.json
# Upload CI artifacts
dbt docs generate
aws s3 sync ./target s3://dbt-artifacts/ci/${GITHUB_SHA}/---
Observability and Lineage
Data Lineage with OpenLineage
# lineage/openlineage_emitter.py
from openlineage.client import OpenLineageClient
from openlineage.client.run import Run, RunEvent, RunState, Job, Dataset
from openlineage.client.facet import (
SchemaDatasetFacet,
SchemaField,
SqlJobFacet,
DataQualityMetricsInputDatasetFacet
)
from datetime import datetime
import uuid
class DataLineageEmitter:
"""Emit data lineage events to OpenLineage."""
def __init__(self, api_url: str, namespace: str = "data-platform"):
self.client = OpenLineageClient(url=api_url)
self.namespace = namespace
def emit_job_start(self, job_name: str, inputs: list, outputs: list,
sql: str = None) -> str:
"""Emit job start event."""
run_id = str(uuid.uuid4())
# Build input datasets
input_datasets = [
Dataset(
namespace=self.namespace,
name=inp['name'],
facets={
"schema": SchemaDatasetFacet(
fields=[
SchemaField(name=f['name'], type=f['type'])
for f in inp.get('schema', [])
]
)
}
)
for inp in inputs
]
# Build output datasets
output_datasets = [
Dataset(
namespace=self.namespace,
name=out['name'],
facets={
"schema": SchemaDatasetFacet(
fields=[
SchemaField(name=f['name'], type=f['type'])
for f in out.get('schema', [])
]
)
}
)
for out in outputs
]
# Build job facets
job_facets = {}
if sql:
job_facets["sql"] = SqlJobFacet(query=sql)
# Create and emit event
event = RunEvent(
eventType=RunState.START,
eventTime=datetime.utcnow().isoformat() + "Z",
run=Run(runId=run_id),
job=Job(namespace=self.namespace, name=job_name, facets=job_facets),
inputs=input_datasets,
outputs=output_datasets
)
self.client.emit(event)
return run_id
def emit_job_complete(self, job_name: str, run_id: str,
output_metrics: dict = None):
"""Emit job completion event."""
output_facets = {}
if output_metrics:
output_facets["dataQuality"] = DataQualityMetricsInputDatasetFacet(
rowCount=output_metrics.get('row_count'),
bytes=output_metrics.get('bytes')
)
event = RunEvent(
eventType=RunState.COMPLETE,
eventTime=datetime.utcnow().isoformat() + "Z",
run=Run(runId=run_id),
job=Job(namespace=self.namespace, name=job_name),
inputs=[],
outputs=[]
)
self.client.emit(event)
def emit_job_fail(self, job_name: str, run_id: str, error_message: str):
"""Emit job failure event."""
event = RunEvent(
eventType=RunState.FAIL,
eventTime=datetime.utcnow().isoformat() + "Z",
run=Run(runId=run_id, facets={
"errorMessage": {"message": error_message}
}),
job=Job(namespace=self.namespace, name=job_name),
inputs=[],
outputs=[]
)
self.client.emit(event)
# Usage example
emitter = DataLineageEmitter("http://marquez:5000/api/v1/lineage")
run_id = emitter.emit_job_start(
job_name="transform_orders",
inputs=[
{"name": "raw.orders", "schema": [
{"name": "id", "type": "string"},
{"name": "amount", "type": "decimal"}
]}
],
outputs=[
{"name": "analytics.fct_orders", "schema": [
{"name": "order_id", "type": "string"},
{"name": "net_amount", "type": "decimal"}
]}
],
sql="SELECT id as order_id, amount as net_amount FROM raw.orders"
)
# After job completes
emitter.emit_job_complete(
job_name="transform_orders",
run_id=run_id,
output_metrics={"row_count": 1500000, "bytes": 125000000}
)Pipeline Monitoring with Prometheus
# monitoring/metrics.py
from prometheus_client import Counter, Gauge, Histogram, start_http_server
from functools import wraps
import time
# Define metrics
PIPELINE_RUNS = Counter(
'pipeline_runs_total',
'Total number of pipeline runs',
['pipeline_name', 'status']
)
PIPELINE_DURATION = Histogram(
'pipeline_duration_seconds',
'Pipeline execution duration',
['pipeline_name'],
buckets=[60, 300, 600, 1800, 3600, 7200]
)
ROWS_PROCESSED = Counter(
'rows_processed_total',
'Total rows processed by pipeline',
['pipeline_name', 'table_name']
)
DATA_FRESHNESS = Gauge(
'data_freshness_hours',
'Hours since last data update',
['table_name']
)
DATA_QUALITY_SCORE = Gauge(
'data_quality_score',
'Data quality score (0-1)',
['table_name', 'check_type']
)
def track_pipeline(pipeline_name: str):
"""Decorator to track pipeline execution."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
PIPELINE_RUNS.labels(pipeline_name=pipeline_name, status='success').inc()
return result
except Exception as e:
PIPELINE_RUNS.labels(pipeline_name=pipeline_name, status='failure').inc()
raise
finally:
duration = time.time() - start_time
PIPELINE_DURATION.labels(pipeline_name=pipeline_name).observe(duration)
return wrapper
return decorator
def record_rows_processed(pipeline_name: str, table_name: str, row_count: int):
"""Record number of rows processed."""
ROWS_PROCESSED.labels(pipeline_name=pipeline_name, table_name=table_name).inc(row_count)
def update_freshness(table_name: str, hours_since_update: float):
"""Update data freshness metric."""
DATA_FRESHNESS.labels(table_name=table_name).set(hours_since_update)
def update_quality_score(table_name: str, check_type: str, score: float):
"""Update data quality score."""
DATA_QUALITY_SCORE.labels(table_name=table_name, check_type=check_type).set(score)
# Start metrics server
if __name__ == '__main__':
start_http_server(8000)Alerting Configuration
# alerting/prometheus_rules.yml
groups:
- name: data_quality_alerts
rules:
- alert: DataFreshnessAlert
expr: data_freshness_hours > 24
for: 15m
labels:
severity: critical
team: data-platform
annotations:
summary: "Data freshness SLA violated"
description: "Table {{ $labels.table_name }} has not been updated for {{ $value }} hours"
- alert: DataQualityDegraded
expr: data_quality_score < 0.95
for: 10m
labels:
severity: warning
team: data-platform
annotations:
summary: "Data quality below threshold"
description: "Table {{ $labels.table_name }} quality score is {{ $value }}"
- alert: PipelineFailure
expr: increase(pipeline_runs_total{status="failure"}[1h]) > 0
for: 5m
labels:
severity: critical
team: data-platform
annotations:
summary: "Pipeline failure detected"
description: "Pipeline {{ $labels.pipeline_name }} has failed"
- alert: PipelineSlowdown
expr: histogram_quantile(0.95, rate(pipeline_duration_seconds_bucket[1h])) > 3600
for: 30m
labels:
severity: warning
team: data-platform
annotations:
summary: "Pipeline execution time degraded"
description: "Pipeline {{ $labels.pipeline_name }} p95 duration is {{ $value }} seconds"
- alert: LowRowCount
expr: increase(rows_processed_total[24h]) < 1000
for: 1h
labels:
severity: warning
team: data-platform
annotations:
summary: "Unusually low row count"
description: "Pipeline {{ $labels.pipeline_name }} processed only {{ $value }} rows in 24h"---
Incident Response
Runbook Template
# Incident Runbook: Data Pipeline Failure
## Overview
This runbook covers procedures for handling data pipeline failures.
## Severity Levels
- **P1 (Critical)**: Data older than 24 hours, revenue-impacting
- **P2 (High)**: Data older than 4 hours, customer-facing dashboards affected
- **P3 (Medium)**: Data older than 1 hour, internal reports delayed
- **P4 (Low)**: Non-critical pipeline, no business impact
## Initial Response (First 15 minutes)
### 1. Acknowledge the AlertAcknowledge in PagerDuty
curl -X POST https://api.pagerduty.com/incidents/{incident_id}/acknowledge
Post in #data-incidents Slack channel
### 2. Assess Impact
- Which tables are affected?
- Which downstream consumers are impacted?
- What is the data freshness currently?
-- Check data freshness SELECT table_name, MAX(updated_at) as last_update, DATEDIFF(hour, MAX(updated_at), CURRENT_TIMESTAMP) as hours_stale FROM information_schema.tables WHERE table_schema = 'analytics' GROUP BY table_name ORDER BY hours_stale DESC;
### 3. Identify Root Cause
#### Check Pipeline StatusAirflow
airflow dags list-runs -d <dag_id> --state failed
dbt
dbt debug dbt run --select state:failed
Spark
spark-submit --status <application_id>
#### Common Failure Modes
| Symptom | Likely Cause | Fix |
|---------|--------------|-----|
| OOM errors | Data volume spike | Increase memory, add partitioning |
| Timeout | Slow query | Optimize query, check locks |
| Connection refused | Network/auth | Check credentials, VPC rules |
| Schema mismatch | Source change | Update schema, add contract |
| Duplicate key | Upstream bug | Deduplicate, fix source |
## Resolution Procedures
### Restart Failed PipelineClear failed Airflow task
airflow tasks clear <dag_id> -t <task_id> -s <start_date> -e <end_date>
Rerun dbt model
dbt run --select <model_name>+
Resubmit Spark job
spark-submit --deploy-mode cluster <job.py>
### Backfill Missing DataAirflow backfill
airflow dags backfill -s 2024-01-01 -e 2024-01-02 <dag_id>
dbt incremental refresh
dbt run --full-refresh --select <model_name>
### Rollback Proceduredbt rollback (use previous version)
git checkout <previous_sha> -- models/<model>.sql dbt run --select <model_name>
Delta Lake time travel
spark.sql(""" RESTORE TABLE analytics.orders TO VERSION AS OF 10 """)
## Post-Incident
### 1. Write Incident Report
- Timeline of events
- Root cause analysis
- Impact assessment
- Remediation steps taken
- Follow-up action items
### 2. Update Monitoring
- Add missing alerts
- Adjust thresholds
- Improve documentation
### 3. Share Learnings
- Post in #data-engineering
- Update runbooks
- Schedule blameless postmortem if P1/P2---
Cost Optimization
Query Cost Analysis
-- Snowflake query cost analysis
SELECT
query_id,
user_name,
warehouse_name,
execution_time / 1000 as execution_seconds,
bytes_scanned / 1e9 as gb_scanned,
credits_used_cloud_services,
query_text
FROM snowflake.account_usage.query_history
WHERE start_time > DATEADD(day, -7, CURRENT_TIMESTAMP)
ORDER BY credits_used_cloud_services DESC
LIMIT 20;
-- BigQuery cost analysis
SELECT
user_email,
query,
total_bytes_processed / 1e12 as tb_processed,
total_bytes_processed / 1e12 * 5 as estimated_cost_usd, -- $5/TB
creation_time
FROM `project.region-us.INFORMATION_SCHEMA.JOBS_BY_USER`
WHERE creation_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
ORDER BY total_bytes_processed DESC
LIMIT 20;Cost Optimization Strategies
# cost/optimizer.py
from dataclasses import dataclass
from typing import List, Dict
import pandas as pd
@dataclass
class CostRecommendation:
category: str
current_cost: float
potential_savings: float
recommendation: str
priority: str
class CostOptimizer:
"""Analyze and optimize data platform costs."""
def __init__(self, connection):
self.conn = connection
def analyze_query_costs(self) -> List[CostRecommendation]:
"""Identify expensive queries and optimization opportunities."""
recommendations = []
# Find queries scanning full tables
full_scans = self.conn.execute("""
SELECT
query_text,
COUNT(*) as execution_count,
AVG(bytes_scanned) as avg_bytes,
SUM(credits_used) as total_credits
FROM query_history
WHERE bytes_scanned > 1e10 -- > 10GB
AND start_time > DATEADD(day, -7, CURRENT_TIMESTAMP)
GROUP BY query_text
HAVING COUNT(*) > 10
ORDER BY total_credits DESC
""").fetchall()
for query, count, avg_bytes, credits in full_scans:
recommendations.append(CostRecommendation(
category="Query Optimization",
current_cost=credits,
potential_savings=credits * 0.7, # Estimate 70% savings
recommendation=f"Add WHERE clause or partitioning to reduce scan. Query runs {count}x/week, scans {avg_bytes/1e9:.1f}GB each time.",
priority="high"
))
return recommendations
def analyze_storage_costs(self) -> List[CostRecommendation]:
"""Identify storage optimization opportunities."""
recommendations = []
# Find large unused tables
unused_tables = self.conn.execute("""
SELECT
table_name,
bytes / 1e9 as size_gb,
last_accessed
FROM table_metadata
WHERE last_accessed < DATEADD(day, -90, CURRENT_TIMESTAMP)
AND bytes > 1e9 -- > 1GB
ORDER BY bytes DESC
""").fetchall()
for table, size, last_accessed in unused_tables:
monthly_cost = size * 0.023 # $0.023/GB/month for S3
recommendations.append(CostRecommendation(
category="Storage",
current_cost=monthly_cost,
potential_savings=monthly_cost,
recommendation=f"Table {table} ({size:.1f}GB) not accessed since {last_accessed}. Consider archiving or deleting.",
priority="medium"
))
# Find tables without partitioning
unpartitioned = self.conn.execute("""
SELECT table_name, bytes / 1e9 as size_gb
FROM table_metadata
WHERE partition_column IS NULL
AND bytes > 10e9 -- > 10GB
""").fetchall()
for table, size in unpartitioned:
recommendations.append(CostRecommendation(
category="Storage",
current_cost=0,
potential_savings=size * 0.1, # Estimate 10% query cost savings
recommendation=f"Table {table} ({size:.1f}GB) is not partitioned. Add partitioning to reduce query costs.",
priority="high"
))
return recommendations
def analyze_compute_costs(self) -> List[CostRecommendation]:
"""Identify compute optimization opportunities."""
recommendations = []
# Find oversized warehouses
warehouse_util = self.conn.execute("""
SELECT
warehouse_name,
warehouse_size,
AVG(avg_running_queries) as avg_queries,
AVG(credits_used) as avg_credits
FROM warehouse_metering_history
WHERE start_time > DATEADD(day, -7, CURRENT_TIMESTAMP)
GROUP BY warehouse_name, warehouse_size
""").fetchall()
for wh, size, avg_queries, avg_credits in warehouse_util:
if avg_queries < 1 and size not in ['X-Small', 'Small']:
recommendations.append(CostRecommendation(
category="Compute",
current_cost=avg_credits * 7, # Weekly
potential_savings=avg_credits * 7 * 0.5,
recommendation=f"Warehouse {wh} ({size}) has low utilization ({avg_queries:.1f} avg queries). Consider downsizing.",
priority="high"
))
return recommendations
def generate_report(self) -> Dict:
"""Generate comprehensive cost optimization report."""
all_recommendations = (
self.analyze_query_costs() +
self.analyze_storage_costs() +
self.analyze_compute_costs()
)
total_current = sum(r.current_cost for r in all_recommendations)
total_savings = sum(r.potential_savings for r in all_recommendations)
return {
"total_current_monthly_cost": total_current,
"total_potential_savings": total_savings,
"savings_percentage": total_savings / total_current * 100 if total_current > 0 else 0,
"recommendations": [
{
"category": r.category,
"current_cost": r.current_cost,
"potential_savings": r.potential_savings,
"recommendation": r.recommendation,
"priority": r.priority
}
for r in sorted(all_recommendations, key=lambda x: -x.potential_savings)
]
}senior-data-engineer reference
Troubleshooting
Pipeline Failures
Symptom: Airflow DAG fails with timeout
Task exceeded max execution timeSolution: 1. Check resource allocation 2. Profile slow operations 3. Add incremental processing
# Increase timeout
default_args = {
'execution_timeout': timedelta(hours=2),
}
# Or use incremental loads
WHERE updated_at > '{{ prev_ds }}'---
Symptom: Spark job OOM
java.lang.OutOfMemoryError: Java heap spaceSolution: 1. Increase executor memory 2. Reduce partition size 3. Use disk spill
spark.conf.set("spark.executor.memory", "8g")
spark.conf.set("spark.sql.shuffle.partitions", "200")
spark.conf.set("spark.memory.fraction", "0.8")---
Symptom: Kafka consumer lag increasing
Consumer lag: 1000000 messagesSolution: 1. Increase consumer parallelism 2. Optimize processing logic 3. Scale consumer group
# Add more partitions
kafka-topics.sh --alter \
--bootstrap-server localhost:9092 \
--topic user-events \
--partitions 24---
Data Quality Issues
Symptom: Duplicate records appearing
Expected unique, found 150 duplicatesSolution: 1. Add deduplication logic 2. Use merge/upsert operations
-- dbt incremental with dedup
{{
config(
materialized='incremental',
unique_key='order_id'
)
}}
SELECT * FROM (
SELECT
*,
ROW_NUMBER() OVER (
PARTITION BY order_id
ORDER BY updated_at DESC
) as rn
FROM {{ source('raw', 'orders') }}
) WHERE rn = 1---
Symptom: Stale data in tables
Last update: 3 days agoSolution: 1. Check upstream pipeline status 2. Verify source availability 3. Add freshness monitoring
# dbt freshness check
sources:
- name: "raw"
freshness:
warn_after: {count: 12, period: hour}
error_after: {count: 24, period: hour}
loaded_at_field: _loaded_at---
Symptom: Schema drift detected
Column 'new_field' not in expected schemaSolution: 1. Update data contract 2. Modify transformations 3. Communicate with producers
# Handle schema evolution
df = spark.read.format("delta") \
.option("mergeSchema", "true") \
.load("/data/orders")---
Performance Issues
Symptom: Query takes hours
Query runtime: 4 hours (expected: 30 minutes)Solution: 1. Check query plan 2. Add proper partitioning 3. Optimize joins
-- Before: Full table scan
SELECT * FROM orders WHERE order_date = '2024-01-15';
-- After: Partition pruning
-- Table partitioned by order_date
SELECT * FROM orders WHERE order_date = '2024-01-15';
-- Add clustering for frequent filters
ALTER TABLE orders CLUSTER BY (customer_id);---
Symptom: dbt model takes too long
Model fct_orders completed in 45 minutesSolution: 1. Use incremental materialization 2. Reduce upstream dependencies 3. Pre-aggregate where possible
-- Convert to incremental
{{
config(
materialized='incremental',
unique_key='order_id',
on_schema_change='sync_all_columns'
)
}}
SELECT * FROM {{ ref('stg_orders') }}
{% if is_incremental() %}
WHERE _loaded_at > (SELECT MAX(_loaded_at) FROM {{ this }})
{% endif %}senior-data-engineer reference
Workflows
Workflow 1: Building a Batch ETL Pipeline
Scenario: Extract data from PostgreSQL, transform with dbt, load to Snowflake.
Step 1: Define Source Schema
-- Document source tables
SELECT
table_name,
column_name,
data_type,
is_nullable
FROM information_schema.columns
WHERE table_schema = 'source_schema'
ORDER BY table_name, ordinal_position;Step 2: Generate Extraction Config
python scripts/pipeline_orchestrator.py generate \
--type airflow \
--source postgres \
--tables orders,customers,products \
--mode incremental \
--watermark updated_at \
--output dags/extract_source.pyStep 3: Create dbt Models
-- models/staging/stg_orders.sql
WITH source AS (
SELECT * FROM {{ source('postgres', 'orders') }}
),
renamed AS (
SELECT
order_id,
customer_id,
order_date,
total_amount,
status,
_extracted_at
FROM source
WHERE order_date >= DATEADD(day, -3, CURRENT_DATE)
)
SELECT * FROM renamed-- models/marts/fct_orders.sql
{{
config(
materialized='incremental',
unique_key='order_id',
cluster_by=['order_date']
)
}}
SELECT
o.order_id,
o.customer_id,
c.customer_segment,
o.order_date,
o.total_amount,
o.status
FROM {{ ref('stg_orders') }} o
LEFT JOIN {{ ref('dim_customers') }} c
ON o.customer_id = c.customer_id
{% if is_incremental() %}
WHERE o._extracted_at > (SELECT MAX(_extracted_at) FROM {{ this }})
{% endif %}Step 4: Configure Data Quality Tests
# models/marts/schema.yml
version: 2
models:
- name: "fct-orders"
description: "Order fact table"
columns:
- name: "order-id"
tests:
- unique
- not_null
- name: "total-amount"
tests:
- not_null
- dbt_utils.accepted_range:
min_value: 0
max_value: 1000000
- name: "order-date"
tests:
- not_null
- dbt_utils.recency:
datepart: day
field: order_date
interval: 1Step 5: Create Airflow DAG
# dags/daily_etl.py
from airflow import DAG
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow.operators.bash import BashOperator
from airflow.utils.dates import days_ago
from datetime import timedelta
default_args = {
'owner': 'data-team',
'depends_on_past': False,
'email_on_failure': True,
'email': ['data-alerts@company.com'],
'retries': 2,
'retry_delay': timedelta(minutes=5),
}
with DAG(
'daily_etl_pipeline',
default_args=default_args,
description='Daily ETL from PostgreSQL to Snowflake',
schedule_interval='0 5 * * *',
start_date=days_ago(1),
catchup=False,
tags=['etl', 'daily'],
) as dag:
extract = BashOperator(
task_id='extract_source_data',
bash_command='python /opt/airflow/scripts/extract.py --date {{ ds }}',
)
transform = BashOperator(
task_id='run_dbt_models',
bash_command='cd /opt/airflow/dbt && dbt run --select marts.*',
)
test = BashOperator(
task_id='run_dbt_tests',
bash_command='cd /opt/airflow/dbt && dbt test --select marts.*',
)
notify = BashOperator(
task_id='send_notification',
bash_command='python /opt/airflow/scripts/notify.py --status success',
trigger_rule='all_success',
)
extract >> transform >> test >> notifyStep 6: Validate Pipeline
# Test locally
dbt run --select stg_orders fct_orders
dbt test --select fct_orders
# Validate data quality
python scripts/data_quality_validator.py validate \
--table fct_orders \
--checks all \
--output reports/quality_report.json---
Workflow 2: Implementing Real-Time Streaming
Scenario: Stream events from Kafka, process with Flink/Spark Streaming, sink to data lake.
Step 1: Define Event Schema
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "UserEvent",
"type": "object",
"required": ["event_id", "user_id", "event_type", "timestamp"],
"properties": {
"event_id": {"type": "string", "format": "uuid"},
"user_id": {"type": "string"},
"event_type": {"type": "string", "enum": ["page_view", "click", "purchase"]},
"timestamp": {"type": "string", "format": "date-time"},
"properties": {"type": "object"}
}
}Step 2: Create Kafka Topic
# Create topic with appropriate partitions
kafka-topics.sh --create \
--bootstrap-server localhost:9092 \
--topic user-events \
--partitions 12 \
--replication-factor 3 \
--config retention.ms=604800000 \
--config cleanup.policy=delete
# Verify topic
kafka-topics.sh --describe \
--bootstrap-server localhost:9092 \
--topic user-eventsStep 3: Implement Spark Streaming Job
# streaming/user_events_processor.py
from pyspark.sql import SparkSession
from pyspark.sql.functions import (
from_json, col, window, count, avg,
to_timestamp, current_timestamp
)
from pyspark.sql.types import (
StructType, StructField, StringType,
TimestampType, MapType
)
# Initialize Spark
spark = SparkSession.builder \
.appName("UserEventsProcessor") \
.config("spark.sql.streaming.checkpointLocation", "/checkpoints/user-events") \
.config("spark.sql.shuffle.partitions", "12") \
.getOrCreate()
# Define schema
event_schema = StructType([
StructField("event_id", StringType(), False),
StructField("user_id", StringType(), False),
StructField("event_type", StringType(), False),
StructField("timestamp", StringType(), False),
StructField("properties", MapType(StringType(), StringType()), True)
])
# Read from Kafka
events_df = spark.readStream \
.format("kafka") \
.option("kafka.bootstrap.servers", "localhost:9092") \
.option("subscribe", "user-events") \
.option("startingOffsets", "latest") \
.option("failOnDataLoss", "false") \
.load()
# Parse JSON
parsed_df = events_df \
.select(from_json(col("value").cast("string"), event_schema).alias("data")) \
.select("data.*") \
.withColumn("event_timestamp", to_timestamp(col("timestamp")))
# Windowed aggregation
aggregated_df = parsed_df \
.withWatermark("event_timestamp", "10 minutes") \
.groupBy(
window(col("event_timestamp"), "5 minutes"),
col("event_type")
) \
.agg(
count("*").alias("event_count"),
approx_count_distinct("user_id").alias("unique_users")
)
# Write to Delta Lake
query = aggregated_df.writeStream \
.format("delta") \
.outputMode("append") \
.option("checkpointLocation", "/checkpoints/user-events-aggregated") \
.option("path", "/data/lake/user_events_aggregated") \
.trigger(processingTime="1 minute") \
.start()
query.awaitTermination()Step 4: Handle Late Data and Errors
# Dead letter queue for failed records
from pyspark.sql.functions import current_timestamp, lit
def process_with_error_handling(batch_df, batch_id):
try:
# Attempt processing
valid_df = batch_df.filter(col("event_id").isNotNull())
invalid_df = batch_df.filter(col("event_id").isNull())
# Write valid records
valid_df.write \
.format("delta") \
.mode("append") \
.save("/data/lake/user_events")
# Write invalid to DLQ
if invalid_df.count() > 0:
invalid_df \
.withColumn("error_timestamp", current_timestamp()) \
.withColumn("error_reason", lit("missing_event_id")) \
.write \
.format("delta") \
.mode("append") \
.save("/data/lake/dlq/user_events")
except Exception as e:
# Log error, alert, continue
logger.error(f"Batch {batch_id} failed: {e}")
raise
# Use foreachBatch for custom processing
query = parsed_df.writeStream \
.foreachBatch(process_with_error_handling) \
.option("checkpointLocation", "/checkpoints/user-events") \
.start()Step 5: Monitor Stream Health
# monitoring/stream_metrics.py
from prometheus_client import Gauge, Counter, start_http_server
# Define metrics
RECORDS_PROCESSED = Counter(
'stream_records_processed_total',
'Total records processed',
['stream_name', 'status']
)
PROCESSING_LAG = Gauge(
'stream_processing_lag_seconds',
'Current processing lag',
['stream_name']
)
BATCH_DURATION = Gauge(
'stream_batch_duration_seconds',
'Last batch processing duration',
['stream_name']
)
def emit_metrics(query):
"""Emit Prometheus metrics from streaming query."""
progress = query.lastProgress
if progress:
RECORDS_PROCESSED.labels(
stream_name='user-events',
status='success'
).inc(progress['numInputRows'])
if progress['sources']:
# Calculate lag from latest offset
for source in progress['sources']:
end_offset = source.get('endOffset', {})
# Parse Kafka offsets and calculate lag---
Workflow 3: Data Quality Framework Setup
Scenario: Implement comprehensive data quality monitoring with Great Expectations.
Step 1: Initialize Great Expectations
# Install and initialize
pip install great_expectations
great_expectations init
# Connect to data source
great_expectations datasource newStep 2: Create Expectation Suite
# expectations/orders_suite.py
import great_expectations as gx
context = gx.get_context()
# Create expectation suite
suite = context.add_expectation_suite("orders_quality_suite")
# Add expectations
validator = context.get_validator(
batch_request={
"datasource_name": "warehouse",
"data_asset_name": "orders",
},
expectation_suite_name="orders_quality_suite"
)
# Schema expectations
validator.expect_table_columns_to_match_ordered_list(
column_list=[
"order_id", "customer_id", "order_date",
"total_amount", "status", "created_at"
]
)
# Completeness expectations
validator.expect_column_values_to_not_be_null("order_id")
validator.expect_column_values_to_not_be_null("customer_id")
validator.expect_column_values_to_not_be_null("order_date")
# Uniqueness expectations
validator.expect_column_values_to_be_unique("order_id")
# Range expectations
validator.expect_column_values_to_be_between(
"total_amount",
min_value=0,
max_value=1000000
)
# Categorical expectations
validator.expect_column_values_to_be_in_set(
"status",
["pending", "confirmed", "shipped", "delivered", "cancelled"]
)
# Freshness expectation
validator.expect_column_max_to_be_between(
"order_date",
min_value={"$PARAMETER": "now - timedelta(days=1)"},
max_value={"$PARAMETER": "now"}
)
# Referential integrity
validator.expect_column_values_to_be_in_set(
"customer_id",
value_set={"$PARAMETER": "valid_customer_ids"}
)
validator.save_expectation_suite(discard_failed_expectations=False)Step 3: Create Data Quality Checks with dbt
# models/marts/schema.yml
version: 2
models:
- name: "fct-orders"
description: "Order fact table with data quality checks"
tests:
# Row count check
- dbt_utils.equal_rowcount:
compare_model: ref('stg_orders')
# Freshness check
- dbt_utils.recency:
datepart: hour
field: created_at
interval: 24
columns:
- name: "order-id"
description: "Unique order identifier"
tests:
- unique
- not_null
- relationships:
to: ref('dim_orders')
field: order_id
- name: "total-amount"
tests:
- not_null
- dbt_utils.accepted_range:
min_value: 0
max_value: 1000000
inclusive: true
- dbt_expectations.expect_column_values_to_be_between:
min_value: 0
row_condition: "status != 'cancelled'"
- name: "customer-id"
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
severity: warnStep 4: Implement Data Contracts
# contracts/orders_contract.yaml
contract:
name: "orders-data-contract"
version: "1.0.0"
owner: data-team@company.com
schema:
type: object
properties:
order_id:
type: string
format: uuid
description: "Unique order identifier"
customer_id:
type: string
not_null: true
order_date:
type: date
not_null: true
total_amount:
type: decimal
precision: 10
scale: 2
minimum: 0
status:
type: string
enum: ["pending", "confirmed", "shipped", "delivered", "cancelled"]
sla:
freshness:
max_delay_hours: 1
completeness:
min_percentage: 99.9
accuracy:
duplicate_tolerance: 0.01
consumers:
- name: "analytics-team"
usage: "Daily reporting dashboards"
- name: "ml-team"
usage: "Churn prediction model"Step 5: Set Up Quality Monitoring Dashboard
# monitoring/quality_dashboard.py
from datetime import datetime, timedelta
import pandas as pd
def generate_quality_report(connection, table_name: "str-dict"
"""Generate comprehensive data quality report."""
report = {
"table": table_name,
"timestamp": datetime.now().isoformat(),
"checks": {}
}
# Row count check
row_count = connection.execute(
f"SELECT COUNT(*) FROM {table_name}"
).fetchone()[0]
report["checks"]["row_count"] = {
"value": row_count,
"status": "pass" if row_count > 0 else "fail"
}
# Freshness check
max_date = connection.execute(
f"SELECT MAX(created_at) FROM {table_name}"
).fetchone()[0]
hours_old = (datetime.now() - max_date).total_seconds() / 3600
report["checks"]["freshness"] = {
"max_timestamp": max_date.isoformat(),
"hours_old": round(hours_old, 2),
"status": "pass" if hours_old < 24 else "fail"
}
# Null rate check
null_query = f"""
SELECT
SUM(CASE WHEN order_id IS NULL THEN 1 ELSE 0 END) as null_order_id,
SUM(CASE WHEN customer_id IS NULL THEN 1 ELSE 0 END) as null_customer_id,
COUNT(*) as total
FROM {table_name}
"""
null_result = connection.execute(null_query).fetchone()
report["checks"]["null_rates"] = {
"order_id": null_result[0] / null_result[2] if null_result[2] > 0 else 0,
"customer_id": null_result[1] / null_result[2] if null_result[2] > 0 else 0,
"status": "pass" if null_result[0] == 0 and null_result[1] == 0 else "fail"
}
# Duplicate check
dup_query = f"""
SELECT COUNT(*) - COUNT(DISTINCT order_id) as duplicates
FROM {table_name}
"""
duplicates = connection.execute(dup_query).fetchone()[0]
report["checks"]["duplicates"] = {
"count": duplicates,
"status": "pass" if duplicates == 0 else "fail"
}
# Overall status
all_passed = all(
check["status"] == "pass"
for check in report["checks"].values()
)
report["overall_status"] = "pass" if all_passed else "fail"
return report---
Related skills
How it compares
Pick senior-data-engineer for end-to-end pipeline and infrastructure design rather than single-query SQL or dashboard-only tasks.
FAQ
What stack does senior-data-engineer cover?
senior-data-engineer focuses on Python, SQL, Spark, Airflow, dbt, and Kafka for production pipelines. It also covers data modeling, orchestration, data quality, and DataOps practices across ETL and ELT workflows.
When should I use senior-data-engineer?
senior-data-engineer fits designing data architectures, building new pipelines, optimizing workflows, implementing governance, or debugging data issues. Trigger it when pipeline reliability, scale, or observability are the main concerns.
Is Senior Data Engineer safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.