
Analytics Engineer
- 179 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
Model event schemas, ETL jobs, and metrics layers when shipping product analytics, warehouses, and reporting for SaaS or API products.
About
Skill for analytics engineering tasks including designing event tracking schemas, building ETL pipelines, defining metric layers, and implementing warehouse models for SaaS and API product analytics.
- Event schema design
- ETL and dbt modeling
- Metrics layer definitions
- Warehouse table design
- Data quality validation
Analytics Engineer by the numbers
- 179 all-time installs (skills.sh)
- Ranked #694 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/borghei/claude-skills --skill analytics-engineerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 179 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Model event schemas, ETL jobs, and metrics layers when shipping product analytics, warehouses, and reporting for SaaS or API products.
Files
Analytics Engineer
The agent operates as a senior analytics engineer, building scalable dbt transformation layers, designing dimensional models, writing tested SQL, and managing semantic-layer metric definitions.
Clarify First
Before building the models, confirm these inputs. If any is unknown or vague, ASK — do not assume:
- [ ] Required grain + downstream consumers — the row grain of the target model and who queries it (dashboard, notebook, reverse-ETL) (drives the dimensional model and materialization)
- [ ] Source tables and freshness — which sources exist, their keys, and load cadence (determines staging models and incremental logic)
- [ ] Data volume + refresh SLA — table size and how often it must rebuild (selects view vs. table vs. incremental materialization)
Stop rule: ask only the 2-3 that most change the output. If the user says "just draft it," proceed and list your assumptions at the top of the artifact.
Workflow
1. Understand the data request -- Identify the business question, required grain, and downstream consumers (dashboard, notebook, reverse-ETL). Confirm source tables exist and check freshness. 2. Design the dimensional model -- Choose star or snowflake schema. Map source entities to dimension and fact tables at the correct grain. Document grain, primary keys, and foreign keys. 3. Build staging models -- One stg_ model per source table. Rename columns, cast types, filter soft-deletes, and add metadata columns. Validate: dbt build --select stg_*. 4. Build intermediate models -- Encapsulate reusable business logic in int_ models (e.g., int_orders_enriched). Keep each CTE single-purpose. 5. Build mart models -- Create dim_ and fct_ models for consumption. Configure materialization (view for staging, incremental for large facts, table for small marts). 6. Add tests and documentation -- Every primary key gets unique + not_null. Foreign keys get relationships. Add accepted_values for enums. Write model descriptions in YAML. 7. Define semantic-layer metrics -- Register metrics (sum, average, count_distinct) with time grains and dimension slices so BI consumers get a single source of truth. 8. Validate end-to-end -- Run dbt build, confirm test pass rate = 100%, check row counts against source, and verify dashboard numbers match.
dbt Project Structure
analytics/
dbt_project.yml
models/
staging/ # stg_<source>__<table>.sql (one per source table)
intermediate/ # int_<entity>_<verb>.sql (reusable logic)
marts/
core/ # dim_*.sql, fct_*.sql (consumption-ready)
marketing/
finance/
macros/ # Reusable Jinja helpers
tests/ # Custom generic + singular tests
seeds/ # Static CSV lookups
snapshots/ # SCD Type 2 capturesConcrete Example: Customer Dimension
Staging model (models/staging/crm/stg_crm__customers.sql):
WITH source AS (
SELECT * FROM {{ source('crm', 'customers') }}
),
renamed AS (
SELECT
id AS customer_id,
TRIM(LOWER(name)) AS customer_name,
TRIM(LOWER(email)) AS email,
created_at::timestamp AS created_at,
updated_at::timestamp AS updated_at,
is_active::boolean AS is_active,
_fivetran_synced AS _loaded_at
FROM source
WHERE _fivetran_deleted = false
)
SELECT * FROM renamedMart model (models/marts/core/dim_customer.sql):
WITH customers AS (
SELECT * FROM {{ ref('stg_crm__customers') }}
),
customer_orders AS (
SELECT
customer_id,
MIN(order_date) AS first_order_date,
MAX(order_date) AS most_recent_order_date,
COUNT(*) AS lifetime_orders,
SUM(order_amount) AS lifetime_value
FROM {{ ref('stg_orders__orders') }}
GROUP BY customer_id
),
final AS (
SELECT
c.customer_id,
c.customer_name,
c.email,
c.created_at,
co.first_order_date,
co.most_recent_order_date,
co.lifetime_orders,
co.lifetime_value,
CASE
WHEN co.lifetime_value >= 10000 THEN 'platinum'
WHEN co.lifetime_value >= 5000 THEN 'gold'
WHEN co.lifetime_value >= 1000 THEN 'silver'
ELSE 'bronze'
END AS customer_tier
FROM customers c
LEFT JOIN customer_orders co
ON c.customer_id = co.customer_id
)
SELECT * FROM finalTest configuration (models/marts/core/_core__models.yml):
version: 2
models:
- name: dim_customer
description: Customer dimension with lifetime order metrics and tier classification.
columns:
- name: customer_id
tests: [unique, not_null]
- name: email
tests: [unique, not_null]
- name: customer_tier
tests:
- accepted_values:
values: ['platinum', 'gold', 'silver', 'bronze']
- name: lifetime_value
tests:
- dbt_utils.expression_is_true:
expression: ">= 0"Incremental Fact Table Pattern
-- models/marts/core/fct_orders.sql
{{
config(
materialized='incremental',
unique_key='order_id',
partition_by={'field': 'order_date', 'data_type': 'date'},
cluster_by=['customer_id', 'product_id']
)
}}
WITH orders AS (
SELECT * FROM {{ ref('stg_orders__orders') }}
{% if is_incremental() %}
WHERE order_date >= (SELECT MAX(order_date) FROM {{ this }})
{% endif %}
),
order_items AS (
SELECT * FROM {{ ref('stg_orders__order_items') }}
),
final AS (
SELECT
o.order_id,
o.order_date,
o.customer_id,
oi.product_id,
o.store_id,
oi.quantity,
oi.unit_price,
oi.quantity * oi.unit_price AS line_total,
o.discount_amount,
o.tax_amount,
o.total_amount
FROM orders o
INNER JOIN order_items oi ON o.order_id = oi.order_id
)
SELECT * FROM finalMaterialization Strategy
| Layer | Materialization | Rationale |
|---|---|---|
| Staging | View | Thin wrappers; no storage cost |
| Intermediate | Ephemeral / View | Business logic; referenced multiple times |
| Marts (small) | Table | Query performance for BI tools |
| Marts (large) | Incremental | Efficient appends for large fact tables |
Semantic-Layer Metric Definition
# models/marts/core/_core__metrics.yml
metrics:
- name: revenue
label: Total Revenue
model: ref('fct_orders')
calculation_method: sum
expression: total_amount
timestamp: order_date
time_grains: [day, week, month, quarter, year]
dimensions: [customer_tier, product_category, store_region]
filters:
- field: is_cancelled
operator: '='
value: 'false'
- name: average_order_value
label: Average Order Value
model: ref('fct_orders')
calculation_method: average
expression: total_amount
timestamp: order_date
time_grains: [day, week, month]Useful Macros
-- macros/cents_to_dollars.sql
{% macro cents_to_dollars(column_name) %}
({{ column_name }} / 100.0)::decimal(18,2)
{% endmacro %}
-- macros/get_incremental_filter.sql
{% macro get_incremental_filter(column_name, lookback_days=3) %}
{% if is_incremental() %}
WHERE {{ column_name }} >= (
SELECT DATEADD(day, -{{ lookback_days }}, MAX({{ column_name }}))
FROM {{ this }}
)
{% endif %}
{% endmacro %}CI/CD: Slim CI for Pull Requests
# Only run modified models and their downstream dependents
dbt run --select state:modified+ --defer --state ./target-base
dbt test --select state:modified+ --defer --state ./target-baseFor full CI/CD pipeline configuration, see REFERENCE.md.
Reference Materials
REFERENCE.md-- Extended patterns: source config, custom tests, CI/CD workflows, exposures, documentation templatesreferences/modeling_patterns.md-- Data modeling best practicesreferences/dbt_style_guide.md-- SQL and dbt conventionsreferences/testing_guide.md-- Testing strategiesreferences/optimization.md-- Performance tuning
Scripts
python scripts/impact_analyzer.py --model dim_customer
python scripts/schema_diff.py --source prod --target dev
python scripts/doc_generator.py --format markdown
python scripts/quality_scorer.py --model fct_ordersTool Reference
| Tool | Purpose | Key Flags |
|---|---|---|
impact_analyzer.py | Trace downstream impact of a dbt model via BFS on the manifest DAG | --model <name>, --manifest <path>, --json |
schema_diff.py | Compare two dbt catalog.json files to detect column additions, removals, and type changes | --source <path>, --target <path>, --json |
doc_generator.py | Generate markdown documentation (column dictionary, dependencies, tests) for a dbt model | --model <name>, --manifest <path>, --catalog <path> |
quality_scorer.py | Score a dbt model 0-100 based on documentation, testing, and layer-convention adherence | --model <name>, --manifest <path>, --json |
Troubleshooting
| Problem | Likely Cause | Resolution |
|---|---|---|
dbt build fails with "relation does not exist" | Upstream model was not run or materialization changed | Run dbt build --select +<model> to build the full upstream chain |
| Incremental model produces duplicates | unique_key does not match the actual grain | Verify the unique_key config matches the primary key columns; run a full refresh with --full-refresh |
Test failures on not_null after deployment | Source data introduced unexpected NULLs in a previously clean column | Add a staging-layer COALESCE or adjust the test to warn severity while investigating upstream |
Schema drift detected by schema_diff.py | Upstream source changed column types or removed columns | Coordinate with the data engineering team; update staging model casts and regenerate documentation |
| Semantic-layer metric values differ from dashboard | Dashboard applies its own filters or calculations outside the semantic layer | Move all calculation logic into the semantic layer; audit dashboard-level computed fields |
Slow dbt run on large incremental models | Lookback window is too wide or partition pruning is not engaged | Narrow the incremental filter, verify partition_by config, and check warehouse query plan |
quality_scorer.py reports low score despite good coverage | Staging model contains JOINs or GROUP BY operations triggering layer-violation penalties | Refactor aggregation logic into intermediate or mart models; keep staging models as thin wrappers |
Success Criteria
- All dbt models pass
dbt buildwith a 100% test pass rate before merging to production. - Every model has a YAML description and at least one test per primary key (
unique+not_null). - Incremental models process new data in under 5 minutes for tables up to 100M rows.
- Schema drift between prod and dev environments is detected and reviewed before each release.
quality_scorer.pyreports >= 80/100 for every mart model.- Downstream dashboards refresh within SLA (< 5 s load time) after transformation runs complete.
- Semantic-layer metrics are the single source of truth -- no ad-hoc metric calculations exist in BI tools.
Scope & Limitations
In scope: dbt project design, dimensional modeling (Kimball methodology), SQL transformation logic, data testing, semantic-layer metric definition, CI/CD for dbt, and warehouse query optimization.
Out of scope: Raw data ingestion and extraction (ELT/ETL orchestration tools like Fivetran or Airbyte), data infrastructure provisioning, BI tool configuration beyond semantic-layer integration, and real-time streaming pipelines.
Limitations: The Python tools operate on dbt manifest/catalog JSON artifacts and do not query the warehouse directly. Scoring heuristics in quality_scorer.py use rule-based deductions that may not cover every project convention. All scripts use the Python standard library only -- no external dependencies required.
Integration Points
- Data Engineer (
engineering/senior-data-engineer): Coordinates on source table contracts, ingestion SLAs, and schema change notifications. - Business Intelligence (
data-analytics/business-intelligence): Consumes mart models and semantic-layer metrics; dashboard specs reference model outputs. - Data Analyst (
data-analytics/data-analyst): Writes ad-hoc queries against mart models; reports data quality issues back to the analytics engineer. - MLOps Engineer (
data-analytics/ml-ops-engineer): Feature engineering pipelines may depend on intermediate or mart models as upstream inputs. - CI/CD Workflows (
templates/): Slim CI patterns (state:modified+) integrate into GitHub Actions or similar runners for automated PR validation.
name: 'analytics_engine'
version: '1.0.0'
config-version: 2
# Replace with your profile name from ~/.dbt/profiles.yml
profile: 'your_profile_name'
model-paths: ["models"]
analysis-paths: ["analyses"]
test-paths: ["tests"]
seed-paths: ["seeds"]
macro-paths: ["macros"]
snapshot-paths: ["snapshots"]
clean-targets:
- "target"
- "dbt_packages"
# Configure environments
models:
analytics_engine:
# Staging models are strictly views
staging:
+materialized: view
+schema: staging
# Intermediate models are ephemeral or views
intermediate:
+materialized: ephemeral
# Marts are fully materialized tables
marts:
+materialized: table
+schema: marts
# For ultra-large facts, we configure incremental overrides
core:
fct_events:
+materialized: incremental
+incremental_strategy: merge
+unique_key: event_id
# Global variable configuration
vars:
# Lookback window for incremental models
incremental_lookback_days: 3
# Toggle for limits in dev environment
strict_mode: true
-- 1. Customize Schema Names based on Environments
-- By default, dbt appends custom schemas like `dev_username_custom`.
-- This enforces that Production builds strictly into their raw defined folders.
{% macro generate_schema_name(custom_schema_name, node) -%}
{%- set default_schema = target.schema -%}
{%- if target.name == 'prod' -%}
{%- if custom_schema_name is none -%}
{{ default_schema }}
{%- else -%}
{{ custom_schema_name | trim }}
{%- endif -%}
{%- else -%}
{%- if custom_schema_name is none -%}
{{ default_schema }}
{%- else -%}
{{ default_schema }}_{{ custom_schema_name | trim }}
{%- endif -%}
{%- endif -%}
{%- endmacro %}
-- 2. Incremental Lookback Filter
-- NOTE: This macro only emits SQL inside incremental models (uses is_incremental() guard).
-- Always call within an incremental model; in non-incremental context it returns empty string.
{% macro get_incremental_filter(column_name, default_lookback=3) %}
{% if is_incremental() %}
-- Grab from global variables or fallback to default
{% set lookback = var('incremental_lookback_days', default_lookback) %}
WHERE {{ column_name }} >= (
SELECT DATEADD(day, -{{ lookback }}, MAX({{ column_name }}))
FROM {{ this }}
)
{% endif %}
{% endmacro %}
-- 3. Dynamic Field Casting
{% macro cents_to_dollars(column_name, decimal_places=2) %}
ROUND( CAST({{ column_name }} AS FLOAT) / 100.0, {{ decimal_places }} )
{% endmacro %}
version: 2
models:
- name: fct_orders
description: "{{ doc('fct_orders_doc') }}" # Points to corresponding .md file
config:
materialized: incremental
unique_key: order_id
cluster_by: ['order_date']
columns:
- name: order_id
description: "Surrogate PK for the order."
tests:
- unique
- not_null
- name: customer_id
description: "FK routing back to dim_customers."
tests:
- not_null
- relationships:
to: ref('dim_customers')
field: customer_id
- name: order_status
description: "Current progression state."
tests:
- accepted_values:
values: ['pending', 'shipped', 'cancelled', 'returned']
- name: total_amount
description: "Gross revenue of the order in USD."
tests:
- not_null
# Requires: dbt-utils in packages.yml
- dbt_utils.expression_is_true:
expression: ">= 0"
# Sample Semantic Layer Configuration
metrics:
- name: gross_revenue
label: Gross Revenue
model: ref('fct_orders')
description: "Total gross amount driven by closed-won sales."
calculation_method: sum
expression: total_amount
timestamp: order_date
time_grains: [day, week, month, quarter]
dimensions:
- order_status
version: 2
sources:
- name: internal_ecommerce
description: "The primary transactional database cluster."
database: raw_zone
schema: transactional
# Global freshness rule for all tables in this source
freshness:
warn_after: {count: 12, period: hour}
error_after: {count: 24, period: hour}
loaded_at_field: _fivetran_synced
tables:
- name: users
description: "Customer accounts."
columns:
- name: id
description: "Primary key"
tests:
- unique
- not_null
- name: email
- name: orders
description: "Order headers."
# Override global freshness rule for highly-volatile tables
freshness:
warn_after: {count: 1, period: hour}
error_after: {count: 2, period: hour}
columns:
- name: id
tests:
- unique
- not_null
- name: user_id
tests:
- not_null
Analytics Engineer -- Extended Reference
Source Configuration
# models/staging/crm/_crm__sources.yml
version: 2
sources:
- name: crm
description: Customer relationship management system
database: raw
schema: crm
loader: fivetran
loaded_at_field: _fivetran_synced
freshness:
warn_after: {count: 12, period: hour}
error_after: {count: 24, period: hour}
tables:
- name: customers
description: Customer master data
columns:
- name: id
description: Primary key
tests: [unique, not_null]
- name: email
tests: [unique]Custom Generic Tests
-- tests/assert_positive_amount.sql
{% test positive_amount(model, column_name) %}
SELECT {{ column_name }}
FROM {{ model }}
WHERE {{ column_name }} < 0
{% endtest %}
-- tests/generic/assert_row_count_equal.sql
{% test row_count_equal(model, compare_model) %}
WITH source_count AS (
SELECT COUNT(*) AS cnt FROM {{ model }}
),
compare_count AS (
SELECT COUNT(*) AS cnt FROM {{ ref(compare_model) }}
)
SELECT *
FROM source_count
CROSS JOIN compare_count
WHERE source_count.cnt != compare_count.cnt
{% endtest %}Additional Macros
-- macros/generate_schema_name.sql
{% macro generate_schema_name(custom_schema_name, node) -%}
{%- set default_schema = target.schema -%}
{%- if custom_schema_name is none -%}
{{ default_schema }}
{%- else -%}
{{ default_schema }}_{{ custom_schema_name | trim }}
{%- endif -%}
{%- endmacro %}
-- macros/pivot_values.sql
{% macro pivot_values(column_name, values, alias_prefix='') %}
{% for value in values %}
SUM(CASE WHEN {{ column_name }} = '{{ value }}' THEN 1 ELSE 0 END)
AS {{ alias_prefix }}{{ value | lower | replace(' ', '_') }}
{% if not loop.last %},{% endif %}
{% endfor %}
{% endmacro %}Exposures
# models/exposures.yml
version: 2
exposures:
- name: executive_dashboard
type: dashboard
maturity: high
url: https://tableau.company.com/views/executive
description: Executive KPI dashboard
depends_on:
- ref('fct_orders')
- ref('dim_customer')
- ref('dim_product')
owner:
name: Analytics Team
email: analytics@company.comGitHub Actions CI/CD Pipeline
# .github/workflows/dbt.yml
name: dbt CI/CD
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- run: pip install dbt-snowflake
- run: dbt deps
- run: dbt compile --target ci
- run: dbt test --target ci
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: dbt run --target prod
- run: dbt test --target prodQuery Optimization: Pre-aggregate Pattern
-- Before: expensive window function on full table
SELECT order_id, customer_id, order_date,
SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS running_total
FROM orders;
-- After: pre-aggregate then join
WITH daily_totals AS (
SELECT customer_id, order_date, SUM(amount) AS daily_amount
FROM orders
GROUP BY customer_id, order_date
),
running_totals AS (
SELECT customer_id, order_date,
SUM(daily_amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS running_total
FROM daily_totals
)
SELECT o.order_id, o.customer_id, o.order_date, rt.running_total
FROM orders o
JOIN running_totals rt
ON o.customer_id = rt.customer_id AND o.order_date = rt.order_date;Model Documentation Template
models:
- name: fct_orders
description: |
Order fact table containing one row per order line item.
## Business Logic
- Orders with status 'cancelled' are excluded
- Amounts are in USD
- Tax is calculated at time of order
## UsageSELECT * FROM {{ ref('fct_orders') }} WHERE order_date >= '2024-01-01'
## Dependencies
- stg_orders__orders
- stg_orders__order_itemsdbt Style Guide & Conventions
Consistent project structure and SQL style are critical for maintaining large-scale dbt architectures.
1. Project Layering
The dbt project must follow a strict three-layer architecture:
Staging (models/staging/)
- Forms a strict 1:1 relationship with source tables.
- Purpose: Renaming columns, casting data types, standardizing booleans, and applying
trim/lowerlogic. - Constraints:
- Strictly NO JOINs or aggregations allowed!
- Must use the
source('schema', 'table')macro. - Materialization:
view - Naming Prefix:
stg_<source_name>__<table_name>.sql
Intermediate (models/intermediate/)
- Encapsulates complex transformational logic.
- Purpose: Consolidating multi-source data, joining lookup tables, and executing window functions.
- Materialization:
ephemeral(compiled inside downstream CTEs) orviewif referenced multiple times. - Naming Prefix:
int_<entity_name>_<verb>.sql(e.g.,int_users_enriched.sql)
Marts (models/marts/)
- The business-facing dimensional and fact models.
- Purpose: Serving clean, optimized data to BI tools.
- Materialization:
table(orincrementalfor massive fact tables). - Naming Prefix:
dim_<entity>.sqlorfct_<event>.sql
---
2. SQL Formatting and Syntax
No Implicit Columns
- NEVER use
SELECT *against source or ref tables in Production models (outside of simple staging passthroughs). - The terminal
SELECT * FROM finalCTE pattern is acceptable as it references an explicitly projected CTE. - Why: Implicit lists break dbt data contracts during schema drift and inflate network/memory payloads. Always project columns explicitly in CTEs.
CTE Structure
- All models must use Common Table Expressions (CTEs), ending with a final
SELECT * FROM finalblock. - Why: Separating logic into modular CTE blocks allows for isolation and step-by-step debugging.
Example Standard Model:
WITH customers AS (
SELECT * FROM {{ ref('stg_salesforce__customers') }}
),
orders AS (
SELECT * FROM {{ ref('stg_stripe__orders') }}
),
customer_orders AS (
SELECT
customer_id,
MIN(order_date) AS first_order_date,
SUM(amount) AS lifetime_value
FROM orders
GROUP BY customer_id
),
final AS (
SELECT
c.customer_id,
c.customer_name,
co.first_order_date,
co.lifetime_value
FROM customers AS c
LEFT JOIN customer_orders AS co
ON c.customer_id = co.customer_id
)
SELECT * FROM final;Jinja and Macros
- Store reusable logic (e.g., converting cents to dollars, generating schema names) inside the
macros/folder. - Use
target.nameconditionals to limit data queried indevenvironments to save operational costs.
Data Modeling Patterns
This guide formalizes the dimensional data modeling conventions expected within the analytics engineering workflow.
1. Dimensional Architectures
The Star Schema
The Star Schema is the universally preferred architecture for analytics. It relies on a single Fact Table surrounded by highly denormalized Dimension Tables.
- Performance: Minimizes complex multi-table joins.
- Understandability: Business users can natively grok the "nouns" (dimensions) and "verbs" (facts) of the business.
- Rule: Never use a Snowflake schema unless explicitly required by a BI tool limitation. A dimension table should contain all attributes required to slice the fact table, regardless of normalization redundancy.
One Big Table (OBT)
- Warning: Do not use OBT approaches. While columnar databases handle them well, they force disparate business processes into monolithic structures, resulting in extreme matrix sparsity and complex workarounds.
---
2. Dimension Tables
Dimensions (the "who, what, where, when") provide the text attributes used to filter and group fact data.
Surrogate Keys
- Rule: Every dimension table MUST have a meaningless, auto-incrementing integer (or hashed integer) as its primary key.
- Why: Protects the warehouse from upstream source system changes (e.g., if a natural key is recycled) and enables Slowly Changing Dimension (SCD) capabilities.
- Implementation: Use
dbt_utils.generate_surrogate_key(['natural_key']).
Hierarchies
- Do not normalize hierarchies. The
Dim_Producttable should containCategory,Sub_Category,Brand, andSKUdirectly on the single row.
Slowly Changing Dimensions (SCDs)
- SCD Type 1 (Overwrite): Use when history is irrelevant. Edits overwrite the old data.
- SCD Type 2 (Versioning): Required for compliance and historical accuracy. Create a new row when an attribute changes. Requires
is_activeboolean,effective_date, andexpiration_datecolumns. - For implementing SCD Type 2 in dbt, utilize the `snapshots` feature rather than manual logic.
---
3. Fact Tables
Fact tables capture the measurable, quantitative metrics of a business event.
Target Atomic Grain
- Define the lowest possible granularity. The fact table should represent the most atomic level of an event (e.g.,
one row per order line item, notone row per order).
Additive Facts Only
- Rule: Store fully additive measures (e.g.,
dollars_sold,quantity_ordered). - Rule: Non-additive measures (e.g.,
gross_margin_percentage,conversion_rate) must NOT be stored in the database. Instead, store the additive components (revenue,cost) and perform the ratio division dynamically in the BI tool.
Degenerate Dimensions
- Rule: For extremely high-cardinality values with no descriptive attributes (e.g.,
transaction_id,invoice_number), store them directly on the fact table. Do not create a dimension table just to hold an ID.
No NULL Foreign Keys
- Rule: A fact table must never contain a
NULLforeign key. Inner joins drop records withNULLkeys, silently obliterating revenue metrics. - Fix: Direct
NULLforeign keys to an established placeholder dimension row (e.g., ID-1mapped to "Unknown" or "N/A").
SQL Query Optimization Patterns
This guide outlines antipatterns that cripple data warehouse performance and details the strategies required to remediate them.
1. Eliminate Spaghetti Queries
- The Antipattern: Writing a monolithic, multi-thousand-line
SELECTstatement attempting to aggregate, filter, and join a dozen tables at once to fulfill an entire reporting requirement. - The Issue: Massive queries confuse the warehouse optimizer. The engine often allocates incorrect memory logic, resulting in unintentional Cartesian products (cross-joins) that spin compute resources into infinity.
- The Fix: Adopt a Divide and Conquer methodology. Break complex operations down into distinct sequential dbt Intermediate models (
int_layer) or distinct, linearly progressive Common Table Expressions (CTEs) representing granular steps.
2. Eliminate EAV and Jaywalking Joins
- Jaywalking: Storing comma-separated delimited IDs inside a single column and using
JOIN ON a LIKE '%' || b || '%'to connect them. - EAV (Entity-Attribute-Value): Storing schema-less data in three vertical columns (
entity_id,attribute_name,value). - The Issue: Both patterns completely bypass the relational database indexing structures, forcing horizontal table scans that are mathematically exponential.
- The Fix: Abstract many-to-many elements into an explicit Intersection Table natively supporting primary-to-foreign key bridging.
3. Ambiguous Aggregations
- The Antipattern: Selecting raw columns alongside aggregate measures without explicitly grouping them, or adding extraneous descriptive fields to a
GROUP BYclause just to pass syntax. - The Issue: It results in pseudo-random value selection for the non-grouped rows or inadvertently alters the grain of the calculation, resulting in duplicated or dropped financial measures.
- The Fix: Follow the single-value rule. Only group on the absolute core dimension keys. For descriptive descriptors, wrap them in deterministic functions like
MAX(), or calculate the core aggregates in a nested CTE andJOINthe descriptors afterward.
4. Unoptimized Randomization
- The Antipattern:
SELECT * FROM massive_table ORDER BY RAND() LIMIT 10 - The Issue: The database must assign a random floating-point integer to every single row in the millions-row facts table, execute a global sort on the entire table in memory, select the first 10, and discard the rest.
- The Fix: Avoid
RAND(). Instead use windowing sequencing operations:
-- More optimal approach for sampling:
WITH CTE AS (
SELECT *, ROW_NUMBER() OVER(ORDER BY event_timestamp) AS rn
FROM massive_table
)
SELECT * FROM CTE WHERE rn % 100 = 0; -- takes 1% roughly5. Incremental Processing
For facts exceeding ten million records, daily rebuilds consume massive resources. Configure fact models as materialized='incremental' inside the configuration block.
- Read only the last 1-3 days of data utilizing the
{% if is_incremental() %}Jinja macro condition. - Update matching records utilizing
unique_key.
Testing & CI/CD Guide
Ensuring data pipeline integrity requires rigorous coverage at all execution vectors.
1. Generic Tests
Generic tests are globally scalable validations declared within .yml schema files.
- Unique: Apply to all primary keys to ensure the grain of the model is respected.
- Not_null: Apply to all primary keys and critical business fields (e.g.
amount). - Accepted_values: Use to validate enum-type fields (e.g., ensuring
order_statuscan only equalpending,shipped,cancelled). - Relationships: Apply to all foreign keys to validate referential integrity against parent dimension tables.
Implementation Example:
models:
- name: fct_orders
columns:
- name: order_id
tests:
- unique
- not_null
- name: customer_id
tests:
- relationships:
to: ref('dim_customers')
field: customer_id
- name: status
tests:
- accepted_values:
values: ['placed', 'shipped', 'completed']2. Testing Sources First
Do not wait until the transformation is complete to discover bad data. Apply tests directly to the raw sources inside the _sources.yml configurations. Isolating upstream drops reduces debug turnaround.
3. Singular Tests
When business logic requires complex checks that generic .yml blocks can't accommodate, write explicit Singular Tests.
- Store these as
.sqlfiles within thetests/directory. - The test file should
SELECTthe failing condition. If the query returns0rows, the test passes.
Example: tests/assert_positive_amount.sql
-- Ensure that the revenue amount is never negative
SELECT
order_id,
revenue_amount
FROM {{ ref('fct_orders') }}
WHERE revenue_amount < 04. Continuous Integration (CI/CD)
Pull Request Workflows
Never merge directly into main. The Git workflow should mandate PRs, and the CI environment must automatically run building and testing.
Slim CI
Running a full dbt build against thousands of models on every PR commits is too slow and expensive.
- Rule: Utilize Slim CI workflows comparing state against production.
- Command:
dbt build --select state:modified+ - Result: Instructs the environment to only build and test models whose code changed in the PR branch, along with any downstream models that depend on them.
#!/usr/bin/env python3
import argparse
import json
import os
import sys
def parse_args():
parser = argparse.ArgumentParser(description="Generate markdown documentation from dbt manifest/catalog.")
parser.add_argument("--model", required=True, help="Model name to generate documentation for")
parser.add_argument("--manifest", default="target/manifest.json", help="Path to manifest.json")
parser.add_argument("--catalog", default="target/catalog.json", help="Path to catalog.json (optional)")
return parser.parse_args()
def load_json(path):
if not os.path.exists(path):
return {}
with open(path, 'r') as f:
return json.load(f)
def generate_markdown(manifest, catalog, model_name):
# Find model in manifest
model_node = None
for node_id, data in manifest.get("nodes", {}).items():
if data.get("name") == model_name and data.get("resource_type") == "model":
model_node = data
break
if not model_node:
print(f"Error: Model {model_name} not found.", file=sys.stderr)
sys.exit(1)
description = model_node.get("description", "No description provided.")
columns = model_node.get("columns", {})
depends_on = model_node.get("depends_on", {}).get("nodes", [])
# Try to overlay types from catalog
cat_node = None
for cat_id, data in catalog.get("nodes", {}).items():
if data.get("metadata", {}).get("name") == model_name:
cat_node = data
break
md = [f"# Model: `{model_name}`\n"]
md.append(f"**Description:** {description}\n")
md.append("## Dependencies")
if depends_on:
for dep in depends_on:
dep_name = dep.split('.')[-1]
md.append(f"- `{dep_name}`")
else:
md.append("*No dependencies*")
md.append("\n")
md.append("## Column Dictionary")
md.append("| Column Name | Type | Description | Tests |")
md.append("|---|---|---|---|")
for c_name, c_data in columns.items():
desc = c_data.get("description", "")
# Get tests
tests = []
for child_id in manifest.get("child_map", {}).get(model_node["unique_id"], []):
if child_id.startswith("test."):
child_data = manifest.get("nodes", {}).get(child_id, {})
if child_data.get("column_name") == c_name:
test_type = child_data.get("test_metadata", {}).get("name", child_id.split(".")[-1])
tests.append(test_type)
test_str = ", ".join(tests) if tests else "none"
c_type = "UNKNOWN"
if cat_node and c_name in cat_node.get("columns", {}):
c_type = cat_node["columns"][c_name].get("type", "UNKNOWN")
md.append(f"| `{c_name}` | `{c_type}` | {desc} | {test_str} |")
return "\n".join(md)
def main():
args = parse_args()
manifest = load_json(args.manifest)
catalog = load_json(args.catalog)
if not manifest:
print(f"Error: Manifest not found at {args.manifest}", file=sys.stderr)
sys.exit(1)
md = generate_markdown(manifest, catalog, args.model)
print(md)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
import argparse
from collections import deque
import json
import os
import sys
def parse_args():
parser = argparse.ArgumentParser(description="Analyze downstream impact of a dbt model.")
parser.add_argument("--model", required=True, help="Name of the dbt model (e.g., fct_orders)")
parser.add_argument("--manifest", default="target/manifest.json", help="Path to dbt manifest.json")
parser.add_argument("--json", action="store_true", help="Output in JSON format")
return parser.parse_args()
def load_manifest(manifest_path):
if not os.path.exists(manifest_path):
print(f"Error: Manifest file not found at {manifest_path}. Have you run 'dbt compile'?", file=sys.stderr)
sys.exit(1)
with open(manifest_path, 'r') as f:
return json.load(f)
def build_parent_map(manifest):
"""Returns a map of node -> children (downstream)"""
return manifest.get("child_map", {})
def get_node_id(manifest, model_name):
# Search for model node
nodes = manifest.get("nodes", {})
for node_id, node_data in nodes.items():
if node_data.get("name") == model_name and node_data.get("resource_type") == "model":
return node_id
return None
def analyze_impact(manifest, model_name):
node_id = get_node_id(manifest, model_name)
if not node_id:
print(f"Error: Model '{model_name}' not found in manifest.", file=sys.stderr)
sys.exit(1)
child_map = build_parent_map(manifest)
# BFS to find all downstream
queue = deque([node_id])
visited = set()
exposures = []
downstream_models = []
tests = []
nodes = manifest.get("nodes", {})
manifest_exposures = manifest.get("exposures", {})
while queue:
current = queue.popleft()
if current in visited:
continue
visited.add(current)
children = child_map.get(current, [])
for child in children:
if child not in visited:
queue.append(child)
child_type = child.split('.')[0]
if child_type == "model":
downstream_models.append(nodes.get(child, {}).get("name", child))
elif child_type == "test":
tests.append(child)
elif child_type == "exposure":
exposures.append(manifest_exposures.get(child, {}).get("name", child))
return {
"model": model_name,
"direct_and_indirect_children_count": len(visited) - 1,
"downstream_models": list(set(downstream_models)),
"downstream_exposures": list(set(exposures)),
"downstream_tests": len(set(tests))
}
def main():
args = parse_args()
manifest = load_manifest(args.manifest)
impact = analyze_impact(manifest, args.model)
if args.json:
print(json.dumps(impact, indent=2))
else:
print(f"Impact Analysis for: {impact['model']}")
print("=" * 40)
print(f"Total Downstream Nodes : {impact['direct_and_indirect_children_count']}")
print(f"Downstream Tests : {impact['downstream_tests']}")
print(f"\nDownstream Models ({len(impact['downstream_models'])}):")
for m in sorted(impact['downstream_models']):
print(f" - {m}")
print(f"\n[WARN] Downstream Exposures ({len(impact['downstream_exposures'])}):")
if not impact['downstream_exposures']:
print(" None")
for e in sorted(impact['downstream_exposures']):
print(f" - Dashboard/App: {e}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
import argparse
import json
import os
import sys
def parse_args():
parser = argparse.ArgumentParser(description="Generate a quality score for a dbt model.")
parser.add_argument("--model", required=True, help="Model to score")
parser.add_argument("--manifest", default="target/manifest.json", help="Path to manifest")
parser.add_argument("--json", action="store_true", help="Output in JSON format")
return parser.parse_args()
def load_json(path):
if not os.path.exists(path):
print(f"Error: Could not find {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r') as f:
return json.load(f)
def run_checks(manifest, model_name):
# Find node
node = None
for n_id, data in manifest.get("nodes", {}).items():
if data.get("name") == model_name and data.get("resource_type") == "model":
node = data
break
if not node:
print(f"Error: Model {model_name} not found.", file=sys.stderr)
sys.exit(1)
score = 100
deductions = []
# Check 1: Model has a description
if not node.get("description"):
score -= 20
deductions.append("-20: Model is missing a description in .yml")
# Check 2: Column descriptions
columns = node.get("columns", {})
if not columns:
score -= 20
deductions.append("-20: No columns documented in .yml")
else:
undocumented = [c for c, d in columns.items() if not d.get("description")]
if undocumented:
penalty = min(20, len(undocumented) * 5)
score -= penalty
deductions.append(f"-{penalty}: {len(undocumented)} columns missing descriptions")
# Check 3: Layer violations
raw_sql = node.get("raw_code", "").upper()
if model_name.startswith("stg_"):
if " JOIN " in raw_sql:
score -= 30
deductions.append("-30: Staging model contains JOIN operations (Layer violation)")
if "GROUP BY" in raw_sql:
score -= 30
deductions.append("-30: Staging model contains GROUP BY aggregations (Layer violation)")
elif model_name.startswith("dim_") or model_name.startswith("fct_"):
# Marts should probably have dependent intermediate or staging models
parents = node.get("depends_on", {}).get("nodes", [])
raw_sources = [p for p in parents if p.startswith("source.")]
if raw_sources:
score -= 20
deductions.append("-20: Mart directly selects from source instead of staging (Layer violation)")
# Check 4: Testing
# Look at tests bound to this node
tests = []
for child_id in manifest.get("child_map", {}).get(node["unique_id"], []):
if child_id.startswith("test."):
tests.append(child_id)
if not tests:
score -= 30
deductions.append("-30: Model has zero tests configured")
return {
"model": model_name,
"score": max(0, score),
"deductions": deductions
}
def main():
args = parse_args()
manifest = load_json(args.manifest)
result = run_checks(manifest, args.model)
if args.json:
print(json.dumps(result, indent=2))
return
print(f"Quality Score for: {result['model']}")
print("=" * 40)
score_display = ""
if result['score'] >= 90: score_display = f"[PASS] {result['score']}/100 (Excellent)"
elif result['score'] >= 70: score_display = f"[WARN] {result['score']}/100 (Needs Work)"
else: score_display = f"[FAIL] {result['score']}/100 (Critical Action Required)"
print(score_display)
if result['deductions']:
print("\nDeductions:")
for r in result['deductions']:
print(f" {r}")
else:
print("\nPerfect Score! Model follows all enforced conventions.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
import argparse
import json
import os
import sys
def parse_args():
parser = argparse.ArgumentParser(description="Compare two dbt catalog.json files to detect schema drift.")
parser.add_argument("--source", required=True, help="Path to source catalog.json (e.g. prod)")
parser.add_argument("--target", required=True, help="Path to target catalog.json (e.g. dev)")
parser.add_argument("--json", action="store_true", help="Output in JSON format")
return parser.parse_args()
def load_catalog(path):
if not os.path.exists(path):
print(f"Error: Catalog not found at {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r') as f:
return json.load(f)
def build_schema_map(catalog):
nodes = catalog.get("nodes", {})
schema_map = {}
for node_id, data in nodes.items():
if node_id.startswith("model."):
model_name = data.get("metadata", {}).get("name") or node_id.split(".")[-1]
columns = data.get("columns", {})
schema_map[model_name] = {
c_name.lower(): c_data.get("type", "UNKNOWN").upper()
for c_name, c_data in columns.items()
}
return schema_map
def compare_schemas(source_map, target_map):
results = {
"new_models": [],
"dropped_models": [],
"modified_models": {}
}
for model, s_cols in source_map.items():
if model not in target_map:
results["dropped_models"].append(model)
else:
t_cols = target_map[model]
model_diff = {"new_columns": [], "dropped_columns": [], "type_changes": []}
for c_name, c_type in s_cols.items():
if c_name not in t_cols:
model_diff["dropped_columns"].append(c_name)
elif t_cols[c_name] != c_type:
model_diff["type_changes"].append({
"column": c_name,
"old_type": c_type,
"new_type": t_cols[c_name]
})
for c_name in t_cols.keys():
if c_name not in s_cols:
model_diff["new_columns"].append(c_name)
if any(model_diff.values()):
results["modified_models"][model] = model_diff
for model in target_map.keys():
if model not in source_map:
results["new_models"].append(model)
return results
def main():
args = parse_args()
source_cat = load_catalog(args.source)
target_cat = load_catalog(args.target)
source_map = build_schema_map(source_cat)
target_map = build_schema_map(target_cat)
diff = compare_schemas(source_map, target_map)
if args.json:
print(json.dumps(diff, indent=2))
return
print("Schema Diff Analysis")
print("=" * 40)
print(f"New Models Added : {len(diff['new_models'])}")
print(f"Models Dropped : {len(diff['dropped_models'])}")
print(f"Models Modified : {len(diff['modified_models'])}")
if diff["dropped_models"]:
print("\n[WARN] Models Dropped:")
for m in diff["dropped_models"]:
print(f" - {m}")
if diff["modified_models"]:
print("\nModel Modifications:")
for model, changes in diff["modified_models"].items():
if changes["dropped_columns"]:
print(f"\n [!] {model} - DROPPED COLUMNS (Breaking Change!):")
for c in changes["dropped_columns"]:
print(f" - {c}")
if changes["type_changes"]:
print(f"\n [~] {model} - TYPE CHANGES:")
for tc in changes["type_changes"]:
print(f" - {tc['column']}: {tc['old_type']} -> {tc['new_type']}")
if changes["new_columns"]:
print(f"\n [+] {model} - NEW COLUMNS:")
for c in changes["new_columns"]:
print(f" - {c}")
if __name__ == "__main__":
main()