Back to Guides
Data Engineering

dbt for Healthcare Data Engineering

The complete guide to building healthcare data pipelines with dbt — project structure, staging models for claims and clinical data, HEDIS measure logic, PHI handling, ISO-11179 naming conventions, and production deployment patterns for Snowflake and BigQuery.

Why dbt for Healthcare?

dbt has become the standard transformation layer for modern healthcare data platforms, adopted by health plans, health systems, and health tech companies building on Snowflake, BigQuery, and Databricks. Its combination of SQL-based transformations, built-in testing, documentation generation, and Git-based version control addresses healthcare data engineering challenges that traditional ETL tools handle poorly.

Healthcare data environments have specific requirements that make dbt particularly valuable: HIPAA audit trails are satisfied by Git commit history of every transformation change, annual HEDIS measure specification updates require versioned logic that dbt variables and tags handle cleanly, multi-source integration across EHR, claims, pharmacy, and eligibility systems benefits from dbt's dependency graph and modular model structure, and large analyst teams need guardrails that dbt's testing framework provides.

This guide covers everything a healthcare data engineer needs to build a production-grade dbt project — from project structure and PHI handling to HEDIS measure implementation and CI/CD deployment patterns.

Healthcare dbt Project Structure

Organize your healthcare dbt project into clear layers with separate schemas, access controls, and materialization strategies for each stage of the data lifecycle.

healthcare_dbt/
├── dbt_project.yml
├── profiles.yml
├── packages.yml
│
├── models/
│   ├── sources/
│   │   ├── claims_sources.yml      # raw claim tables
│   │   ├── clinical_sources.yml    # EHR/Clarity tables
│   │   ├── member_sources.yml      # eligibility tables
│   │   └── pharmacy_sources.yml    # PBM/dispensing tables
│   │
│   ├── staging/                    # 1:1 with source tables
│   │   ├── claims/
│   │   │   ├── stg_claims__header.sql
│   │   │   ├── stg_claims__service_line.sql
│   │   │   └── stg_claims__header.yml
│   │   ├── clinical/
│   │   │   ├── stg_clarity__encounters.sql
│   │   │   └── stg_clarity__diagnoses.sql
│   │   ├── member/
│   │   │   └── stg_member__eligibility.sql
│   │   └── pharmacy/
│   │       └── stg_pharmacy__claims.sql
│   │
│   ├── intermediate/               # cross-domain joins
│   │   ├── int_claims__with_member.sql
│   │   ├── int_claims__with_provider.sql
│   │   └── int_encounters__with_diagnoses.sql
│   │
│   ├── marts/                      # business-level models
│   │   ├── claims/
│   │   │   ├── fact_claim_header.sql
│   │   │   └── fact_claim_service_line.sql
│   │   ├── member/
│   │   │   ├── dim_member.sql      # SCD Type 2
│   │   │   └── fct_member_months.sql
│   │   ├── provider/
│   │   │   └── dim_provider.sql
│   │   └── quality/
│   │       ├── fct_hedis_cbp.sql   # Controlling Blood Pressure
│   │       ├── fct_hedis_cdc.sql   # Comprehensive Diabetes Care
│   │       └── fct_hedis_amd.sql   # Annual Monitoring for D-E Meds
│   │
│   └── reference/                  # static lookups
│       ├── ref_icd10_codes.sql
│       └── ref_npi_registry.sql
│
├── seeds/
│   ├── icd10_codes_2026.csv        # annual ICD-10 reference
│   ├── carc_codes.csv              # CARC denial reasons
│   ├── rarc_codes.csv              # RARC remark codes
│   ├── hedis_measure_specs.csv     # measure specifications
│   └── zip_county_crosswalk.csv    # geography mapping
│
├── tests/
│   └── generic/
│       ├── test_npi_format.sql     # custom NPI validation
│       └── test_icd10_format.sql   # ICD-10 format check
│
└── macros/
    ├── phi_masking.sql             # PHI masking macros
    ├── generate_surrogate_key.sql  # SK generation
    └── hedis_date_range.sql        # measure date helpers

dbt_project.yml — Healthcare Configuration

name: healthcare_dbt
version: '1.0.0'
config-version: 2

vars:
  measurement_year: 2025
  hedis_start_date: '2025-01-01'
  hedis_end_date: '2025-12-31'
  phi_masking_enabled: true

models:
  healthcare_dbt:
    staging:
      +materialized: view
      +schema: staging
      +tags: ['staging']
      +grants:
        select: ['data_engineer', 'phi_analyst']

    intermediate:
      +materialized: ephemeral
      +tags: ['intermediate']

    marts:
      claims:
        +materialized: incremental
        +schema: claims
        +cluster_by: ['svc_dt_key', 'payer_key']
        +tags: ['claims', 'phi']
        +grants:
          select: ['data_engineer', 'claims_analyst', 'phi_analyst']
      member:
        +materialized: incremental
        +schema: member
        +tags: ['member', 'phi', 'scd2']
        +grants:
          select: ['data_engineer', 'phi_analyst']
      quality:
        +materialized: table
        +schema: quality
        +tags: ['quality', 'hedis']
        +grants:
          select: ['data_engineer', 'quality_analyst', 'phi_analyst']
      reference:
        +materialized: table
        +schema: reference
        +tags: ['reference', 'no_phi']
        +grants:
          select: ['data_engineer', 'claims_analyst', 'quality_analyst']

seeds:
  healthcare_dbt:
    +schema: reference
    icd10_codes_2026:
      +column_types:
        icd10_cd: varchar(7)
        icd10_desc: varchar(500)
    carc_codes:
      +column_types:
        carc_cd: varchar(5)

Staging Models — Claims and Member

models/staging/claims/stg_claims__header.sql

{{ config(materialized='view', schema='staging', tags=['staging','claims','phi']) }}

with source as (
    select * from {{ source('claims_raw', 'claim_header') }}
),

staged as (
    select
        -- Identifiers — ISO-11179 naming
        clm_nbr::varchar(20)            as clm_nbr,
        mbr_id::varchar(20)             as mbr_id,
        billng_prvdr_npi::varchar(10)   as billng_prvdr_npi,
        rndrng_prvdr_npi::varchar(10)   as rndrng_prvdr_npi,
        payer_id::varchar(20)           as payer_id,

        -- Dates
        svc_dt_from::date               as svc_dt_from,
        svc_dt_to::date                 as svc_dt_to,
        paid_dt::date                   as paid_dt,
        submsn_dt::date                 as submsn_dt,

        -- Diagnosis codes — always VARCHAR(7), never INTEGER
        prim_diag_cd::varchar(7)        as prim_diag_cd,
        sec_diag_cd::varchar(7)         as sec_diag_cd,
        icd_vrsn::varchar(5)            as icd_vrsn,

        -- Financial measures
        bld_amt::decimal(12,2)          as bld_amt,
        alwd_amt::decimal(12,2)         as alwd_amt,
        paid_amt::decimal(12,2)         as paid_amt,
        ded_amt::decimal(10,2)          as ded_amt,
        copay_amt::decimal(10,2)        as copay_amt,
        coins_amt::decimal(10,2)        as coins_amt,

        -- Status codes
        clm_sts_cd::varchar(20)         as clm_sts_cd,
        clm_type_cd::varchar(20)        as clm_type_cd,

        -- PHI fields — tagged for masking policy
        -- meta: {phi: true}
        mbr_first_nm::varchar(100)      as mbr_first_nm,
        mbr_last_nm::varchar(100)       as mbr_last_nm,
        mbr_dob::date                   as mbr_dob,

        -- Metadata
        rec_creat_ts::timestamp         as rec_creat_ts,
        rec_updt_ts::timestamp          as rec_updt_ts

    from source
    where clm_nbr is not null
)

select * from staged

models/staging/claims/stg_claims__header.yml

version: 2
models:
  - name: stg_claims__header
    description: "Standardized claims header — ISO-11179 naming, typed columns, PHI tagged"
    columns:
      - name: clm_nbr
        description: "Unique claim identifier"
        tests: [not_null, unique]
      - name: billng_prvdr_npi
        description: "Billing provider NPI"
        tests:
          - not_null
          - dbt_utils.expression_is_true:
              expression: "length(billng_prvdr_npi) = 10"
      - name: prim_diag_cd
        description: "Primary ICD-10-CM diagnosis code"
        meta:
          phi: true
        tests:
          - dbt_utils.expression_is_true:
              expression: "length(prim_diag_cd) between 3 and 7"
      - name: icd_vrsn
        tests:
          - accepted_values:
              values: ['ICD9', 'ICD10']
      - name: paid_amt
        tests:
          - dbt_utils.expression_is_true:
              expression: "paid_amt >= 0"
      - name: mbr_first_nm
        meta:
          phi: true
          phi_type: direct
      - name: mbr_last_nm
        meta:
          phi: true
          phi_type: direct
      - name: mbr_dob
        meta:
          phi: true
          phi_type: direct

HEDIS Measure Logic in dbt

HEDIS measures follow a consistent denominator-numerator-exclusion pattern that maps naturally to dbt's layered model structure. Here is the Controlling Blood Pressure measure as a complete example.

models/marts/quality/fct_hedis_cbp.sql — Controlling Blood Pressure

{{ config(
    materialized='table',
    schema='quality',
    tags=['quality', 'hedis', 'cbp']
) }}

-- HEDIS Controlling High Blood Pressure (CBP)
-- Denominator: Members 18-85 with hypertension diagnosis
-- Numerator: Most recent BP reading < 140/90

with measurement_period as (
    select
        '{{ var("hedis_start_date") }}'::date   as start_dt,
        '{{ var("hedis_end_date") }}'::date     as end_dt,
        {{ var("measurement_year") }}            as msrmt_yr
),

-- Denominator: active members 18-85 with HTN
denominator as (
    select distinct
        m.mbr_id,
        m.mbr_key,
        m.plan_cd,
        m.mbr_dob,
        datediff('year', m.mbr_dob, p.end_dt) as age_at_end
    from {{ ref('dim_member') }} m
    cross join measurement_period p
    -- Age 18-85 at end of measurement year
    where datediff('year', m.mbr_dob, p.end_dt) between 18 and 85
    -- Active enrollment during measurement year
      and m.eff_dt <= p.end_dt
      and coalesce(m.exp_dt, '9999-12-31') >= p.start_dt
      and m.curr_rec_flg = true
    -- Hypertension diagnosis in measurement or prior year
      and exists (
          select 1
          from {{ ref('fact_claim_header') }} c
          join {{ ref('dim_icd10') }} d
              on d.icd10_key = c.prim_diag_key
          where c.mbr_key = m.mbr_key
            and d.icd10_cd like 'I1%'    -- hypertension codes
            and c.svc_dt_from between
                dateadd('year', -1, p.start_dt) and p.end_dt
      )
),

-- Numerator: most recent BP < 140/90
numerator as (
    select
        d.mbr_id,
        max(r.result_dt)            as last_bp_dt,
        max(r.systlc_bp_val)
            keep (dense_rank last
                  order by r.result_dt) as last_systolic,
        max(r.diastlc_bp_val)
            keep (dense_rank last
                  order by r.result_dt) as last_diastolic
    from denominator d
    join {{ ref('fct_lab_result') }} r
        on r.mbr_id = d.mbr_id
    cross join measurement_period p
    where r.result_dt between p.start_dt and p.end_dt
      and r.loinc_cd in ('8480-6', '8462-4')   -- BP LOINC codes
    group by d.mbr_id
),

final as (
    select
        p.msrmt_yr,
        d.mbr_id,
        d.mbr_key,
        d.plan_cd,
        d.age_at_end,
        true                                    as in_denominator,
        n.last_bp_dt,
        n.last_systolic,
        n.last_diastolic,
        case
            when n.last_systolic < 140
             and n.last_diastolic < 90
            then true
            else false
        end                                     as in_numerator,
        case
            when n.mbr_id is null then 'NO_BP_READING'
            when n.last_systolic >= 140 then 'HIGH_SYSTOLIC'
            when n.last_diastolic >= 90 then 'HIGH_DIASTOLIC'
            else 'CONTROLLED'
        end                                     as bp_status_desc,
        current_timestamp                       as rec_creat_ts
    from denominator d
    cross join measurement_period p
    left join numerator n on n.mbr_id = d.mbr_id
)

select * from final

Browse related quality measure terms in the Quality Data Glossary →

Custom dbt Tests for Healthcare Data Quality

tests/generic/test_npi_format.sql

{% test npi_format(model, column_name) %}
-- Validates NPI is exactly 10 numeric digits
-- Usage: - dbt_utils.expression_is_true OR this custom test
select count(*) as failures
from {{ model }}
where {{ column_name }} is not null
  and (
    length({{ column_name }}) != 10
    or {{ column_name }} ~ '[^0-9]'
  )
{% endtest %}

macros/phi_masking.sql

{% macro apply_phi_masking(table_ref, phi_columns) %}
-- Apply Snowflake dynamic data masking to PHI columns
-- Call in post-hook after model creation
{% for col in phi_columns %}
    alter table {{ table_ref }}
    modify column {{ col }}
    set masking policy phi_name_mask;
{% endfor %}
{% endmacro %}

-- Usage in model config:
-- +post-hook: "{{ apply_phi_masking(this, ['mbr_first_nm','mbr_last_nm']) }}"

{% macro hedis_measurement_period(year_offset=0) %}
-- Returns standard HEDIS measurement period dates
{% set yr = var('measurement_year') + year_offset %}
{
    'start_dt': "'" ~ yr ~ "-01-01'::date",
    'end_dt':   "'" ~ yr ~ "-12-31'::date"
}
{% endmacro %}

Incremental Models for Large Claims Tables

{{ config(
    materialized='incremental',
    schema='claims',
    unique_key='clm_key',
    incremental_strategy='merge',
    cluster_by=['svc_dt_key', 'payer_key'],
    on_schema_change='sync_all_columns',
    tags=['claims', 'phi', 'incremental']
) }}

with staged as (
    select * from {{ ref('stg_claims__header') }}

    -- Incremental filter: only new/changed records
    {% if is_incremental() %}
    where rec_updt_ts > (
        select max(rec_updt_ts)
        from {{ this }}
    )
    {% endif %}
),

with_keys as (
    select
        {{ dbt_utils.generate_surrogate_key(['clm_nbr']) }}
                                        as clm_key,
        -- Dimension foreign keys (looked up from dims)
        mbr.mbr_key,
        rndr.prvdr_key                  as rndrng_prvdr_key,
        bill.prvdr_key                  as billng_prvdr_key,
        dt.dt_key                       as svc_dt_key,
        pay.payer_key,
        diag.icd10_key                  as prim_diag_key,
        -- Degenerate dimensions
        s.clm_nbr,
        s.clm_type_cd,
        s.clm_sts_cd,
        -- Measures
        s.bld_amt,
        s.alwd_amt,
        s.paid_amt,
        s.ded_amt,
        s.copay_amt,
        s.coins_amt,
        -- Metadata
        s.rec_creat_ts,
        s.rec_updt_ts
    from staged s
    -- Point-in-time member join (SCD Type 2)
    left join {{ ref('dim_member') }} mbr
        on mbr.mbr_id = s.mbr_id
        and s.svc_dt_from between mbr.eff_dt
            and coalesce(mbr.exp_dt, '9999-12-31')
    left join {{ ref('dim_provider') }} rndr
        on rndr.npi_nbr = s.rndrng_prvdr_npi
        and rndr.curr_rec_flg = true
    left join {{ ref('dim_provider') }} bill
        on bill.npi_nbr = s.billng_prvdr_npi
        and bill.curr_rec_flg = true
    left join {{ ref('dim_date') }} dt
        on dt.full_dt = s.svc_dt_from
    left join {{ ref('dim_payer') }} pay
        on pay.payer_id = s.payer_id
    left join {{ ref('dim_icd10') }} diag
        on diag.icd10_cd = s.prim_diag_cd
)

select * from with_keys

Convert DDL between Snowflake, BigQuery, and Databricks using the free DDL Converter →

dbt Healthcare Best Practices

  • Use double underscore naming for staging models: Name staging models stg_[source]__[entity].sql with double underscores separating source from entity — stg_claims__header, stg_clarity__encounters. This makes the source system immediately clear and prevents name collisions when multiple sources provide similar entities.
  • Apply ISO-11179 naming in staging, not marts: Rename source columns to ISO-11179 standards in your staging layer so every downstream model inherits consistent names. Never let Epic Clarity column names like PAT_ENC_CSN_ID or raw claim field names like MEM_ID reach your marts layer.
  • Tag PHI columns in every schema YAML: Add meta: {phi: true} to every PHI column in every model YAML file. This enables automated data catalog classification, drives masking policy application, and provides an auditable record of PHI field identification across your entire dbt project.
  • Use dbt variables for all measurement year logic: Never hardcode 2025 or specific date ranges in HEDIS measure models. Use dbt variables (var("measurement_year")) so the same models run correctly for any reporting year without code changes — critical for HEDIS restatements and prior year comparisons.
  • Run dbt test in CI before every production merge: Configure your CI/CD pipeline (GitHub Actions, dbt Cloud, or Airflow) to run dbt test on every pull request before merging to main. A failed NPI format test or paid_amt non-negative test in CI prevents broken data from reaching production dashboards.
  • Version seed files annually for reference data: Create year-specific seed files for annually-updated reference data like icd10_codes_2026.csv and use dbt variables to select the correct version. Never overwrite prior year reference data — HEDIS audits and restatements require access to the exact code sets used in the original measurement.

Frequently Asked Questions

Why use dbt for healthcare data transformation?

dbt (data build tool) is particularly well-suited for healthcare data transformation because it brings software engineering practices — version control, testing, documentation, and CI/CD — to SQL-based data pipelines. Healthcare data environments have unique challenges: strict HIPAA compliance requirements that benefit from auditable transformation history, complex multi-source data integration from EHR, claims, and pharmacy systems, annual measure specification changes for HEDIS and Star Ratings that require versioned logic, and large teams of analysts and engineers who need to collaborate on shared transformation logic without overwriting each other's work.

How should I structure a dbt project for healthcare data?

Organize your dbt project into four layers: sources (raw Snowflake or BigQuery tables replicated from EHR, claims, and eligibility systems), staging models (one-to-one with source tables applying type casting, column renaming to ISO-11179 standards, and basic cleaning), intermediate models (joining and enriching staged data across domains such as claims joined to member and provider dimensions), and marts (business-level dimensional models including fact_claim_header, dim_member with SCD Type 2, and quality measure fact tables). Use separate dbt schemas for each layer and apply schema-level access grants to enforce PHI access controls at the model layer.

How do I handle PHI in dbt models?

Apply a three-part PHI strategy in dbt: first, tag all PHI columns in your schema YAML files with meta: {phi: true, phi_type: "direct"} to enable data catalog classification and automated policy discovery. Second, use Snowflake dynamic data masking policies or BigQuery column-level security applied through dbt post-hooks to mask PHI for unauthorized roles at query time. Third, configure dbt grants in your dbt_project.yml to restrict which database roles can SELECT from PHI-containing models, ensuring that staging models with unmasked member names and dates of birth are only accessible to authorized engineers and analysts.

How do I implement HEDIS measure logic in dbt?

Build HEDIS measures as a three-layer pattern in dbt: a denominator model identifying the eligible population meeting age, enrollment, and diagnosis criteria for each measure, a numerator model identifying members in the denominator who completed the required service or outcome within the measurement period, and a measure summary model joining denominator and numerator with exclusion logic to calculate the final compliance rate. Use dbt variables for measurement year parameters so the same models can be run for different reporting years without code changes. Store measure specifications as dbt seeds for reference in model logic.

What dbt tests are most important for healthcare data?

Healthcare data quality tests should cover: not_null and unique tests on all surrogate and natural keys; expression_is_true tests validating NPI is exactly 10 digits and ICD-10 codes are 3-7 characters; accepted_values tests for controlled vocabulary fields like claim status codes and encounter types; relationships tests verifying foreign key integrity between fact and dimension tables; and custom singular tests checking business rules such as paid_amt being non-negative, service dates not being in the future, and member enrollment periods not overlapping. Run these tests in CI/CD before every production deployment.

How do I use dbt seeds for healthcare reference data?

dbt seeds are ideal for static healthcare reference data that changes infrequently — ICD-10 code sets, CPT code descriptions, CARC and RARC denial reason codes, HEDIS measure specifications, ZIP code to county crosswalks, and provider taxonomy descriptions. Store these as CSV files in your seeds/ directory, version-controlled in Git alongside your transformation logic. Run dbt seed as part of your pipeline initialization to load these into your warehouse as queryable tables. For annually-updated reference data like ICD-10 codes that change each October, maintain year-specific seed files and use dbt variables to select the appropriate version.

Should I use dbt for Epic Clarity transformations?

Yes — dbt is an excellent fit for Epic Clarity transformations. After replicating Clarity tables to Snowflake or BigQuery, use dbt staging models to apply ISO-11179 column naming (PAT_ENC_CSN_ID becomes enctr_id, CONTACT_DATE becomes enctr_dt), decode ZC_ category codes by joining to lookup tables within the staging layer, and enforce data quality tests for NPI format, diagnosis code length, and encounter date validity. The resulting standardized staging layer insulates your downstream dimensional models from Epic-specific naming conventions and makes the data accessible to analysts unfamiliar with Clarity structure.

How do I manage dbt model performance for large healthcare datasets?

For large healthcare datasets with hundreds of millions of claim records, use incremental materialization with a reliable watermark strategy based on rec_updt_ts or a claims processing date. Configure Snowflake clustering keys on your incremental models matching your most common filter patterns — typically service date and payer. Use dbt's on_schema_change configuration to handle source schema changes gracefully. For very large historical loads, use the full_refresh flag selectively rather than globally to avoid reprocessing all historical data on every pipeline run. Monitor model execution time using dbt Cloud or the dbt artifacts to identify slow models requiring optimization.

Related Resources