Back to portfolio
~14 min read · Updated August 2026
Case Study · Featured · Azure + ELT + Healthcare

National Hospital: Azure ELT data warehouse
for a healthcare analytics scenario

A cloud-native ELT pipeline I built on Microsoft Azure for a healthcare analytics scenario. Azure Data Factory orchestrates a 13-activity pipeline with parallel fan-out and a quality gate. Databricks runs the PySpark transformations. Raw CSVs land in ADLS Gen2, are cleaned and quarantined through a visibility-first transformation layer, then loaded into Azure SQL Database in a two-schema (STG + EDW) warehouse. A two-layer data quality suite runs at both Databricks and SQL layers, catching 14 records with illogical dates and quarantining them for source-system review. Real deployment: 2m 20s end-to-end runtime, daily schedule, 4-check safety net, honest engineering tradeoffs.

Featured case study Azure Data Factory V2 Azure Databricks Azure SQL Database ADLS Gen2 Two-schema warehouse
13
orchestrated activities
6
source CSVs, 4 departments
7,506
rows loaded to EDW
2m 20s
end-to-end pipeline runtime
14
rows quarantined for bad dates
99.3%
source-to-warehouse retention
01 The Problem

A hospital's data lives in six spreadsheets across four departments

National Hospital is a healthcare institution focused on continuously improving patient care through data-driven insights. Their existing reporting process meant analysts spent significant time manually consolidating CSV files from four separate departments (Registration, Consultation and Medical, Lab and Scan, Research and Development) into spreadsheets before any analysis could begin. Six CSVs from six systems, no shared identifiers enforced, no single source of truth for a patient's history across the hospital.

The brief was direct. The hospital wanted a cloud-native ELT platform that could automatically ingest CSV extracts from each department, land them in Azure blob storage, apply cleaning and dimensional modeling transformations, and materialize analytics-ready tables in a data warehouse that supports disease prediction research, trial recruitment analytics, and better patient care planning. The platform had to be scheduled, quality-checked, and demonstrably production-shaped on real Azure infrastructure, not local emulation.

The architectural choice

For this Azure deployment, I chose the classical enterprise data warehousing pattern of STG (staging) and EDW (Enterprise Data Warehouse) schemas inside a single SQL database, rather than the lakehouse Medallion pattern (Bronze in object storage, Silver and Gold in the warehouse) I used for World Football Insights on GCP. Medallion suits data-lake-first architectures where transformations happen in Spark and analysts read Parquet directly; STG + EDW suits warehouse-first architectures where SQL analysts and Power BI users query the warehouse directly. Both patterns are correct in their contexts. The National Hospital use case is warehouse-first: BI analysts querying via Power BI, not data scientists reading Parquet.

02 Architecture

A 13-activity ADF pipeline with a two-layer DQ suite

The whole pipeline runs on managed Azure services. Azure Data Factory hosts the pipeline definition. Azure Databricks runs the PySpark transformations. Azure Data Lake Storage Gen2 (accessed via WASB protocol) holds raw CSVs, processed Parquet, and a quarantine folder. Azure SQL Database stores the STG and EDW schemas. Azure Key Vault stores the storage account key so the Databricks notebook can access blob storage securely.

The pipeline shape is a fan-out / converge / validate pattern. One Databricks transformation runs first, producing six cleaned datasets in ADLS. Six Copy Data activities then fan out in parallel to load STG tables. Five EDW load scripts fan back out to load the warehouse. Finally, a single Script activity runs four data quality checks against the loaded EDW.

TRANSFORM
run_transformationDatabricks Notebook · PySpark · cleaning + quarantine
↓ fans out to 6 parallel loads
LOAD STG
cp_patients_stgCopy Data · wildcard read
cp_participants_stgCopy Data · wildcard read
cp_med_records_stgCopy Data · wildcard read
cp_imaging_stgCopy Data · wildcard read
cp_labs_stgCopy Data · wildcard read
cp_trails_stgCopy Data · wildcard read
↓ converge and fan out to 5 parallel EDW loads
LOAD EDW
load_patients_edwScript · DELETE + INSERT
load_participants_edwScript · DELETE + INSERT
load_med_record_edwScript · DELETE + INSERT
load_trials_edwScript · DELETE + INSERT
load_labs_imaging_edwScript · combined lab + imaging
↓ safety net
DATA QUALITY
run_dq_checksScript · 4-check safety net · fails pipeline on hard violations

The pattern that matters here is separation of concerns. Databricks handles the messy work of reading CSVs with mixed quoting, filtering nulls, quarantining suspicious rows, and writing clean data with audit timestamps. ADF handles orchestration, dependency management, retries, scheduling, and monitoring. Azure SQL Database is the queryable analytics surface. Each service does what it does best.

The honest bucket count

The deployed Azure resource group holds seven resources: nahospDF (Data Factory), nathosp (Key Vault), nathospdatalake (Storage Account), nathospdb (SQL server), nathospDB (SQL database), project_workspace (Databricks Service), and the resource group itself. Data Factory and Databricks run in East US; the SQL server runs in Central US. The region split introduces small inter-region egress costs and adds a few seconds of latency per operation. At this data volume (~12,500 rows), the cost is measured in pennies and the latency in seconds. At production scale, resources should be consolidated to a single region. Worth naming honestly rather than pretending it is a design choice.

03 Schema and storage

Two schemas, five entities, one honest layered warehouse

The nathospdatalake storage account holds four top-level folders visible in the Azure Storage browser: raw/, processed_data/, quarantine/, and Databricks-managed scratch space. Raw CSVs land in raw/ directly from the source departments. The Databricks notebook reads from raw/, applies cleaning and validation, writes clean data to processed_data/, and writes any rejected rows to quarantine/ for analyst review.

ADLS Gen2 layout
wasbs://nationalhospital@nathospdatalake.blob.core.windows.net/
├── raw/
│   ├── patients_data.csv                    (1,000 rows)
│   ├── trial_participants_data.csv          (500 rows)
│   ├── medical_records_data.csv             (2,000 rows)
│   ├── imaging_results_data.csv             (1,500 rows)
│   ├── lab_results_data.csv                 (2,500 rows)
│   └── clinical_trials_data.csv             (20 rows)
├── processed_data/                          (Databricks output, wildcard-read by ADF)
│   ├── patients_data_processed/*.csv
│   ├── participants_data_processed/*.csv
│   ├── med_records_data_processed/*.csv
│   ├── imaging_data_processed/*.csv
│   ├── lab_data_processed/*.csv
│   └── trials_data_processed/*.csv
└── quarantine/                              (rows rejected by DQ, for analyst review)
    └── medical_records_bad_dates/*.csv      (14 rows: discharge < admission)

Azure SQL Database nathospDB holds two schemas: STG (staging, tables mirror the cleaned CSV structure) and EDW (Enterprise Data Warehouse, tables have primary keys, load audit timestamps, and analytics-ready structure). The pipeline's final Script activities do a DELETE FROM EDW.table WHERE patient_id IN (SELECT patient_id FROM STG.table) followed by an INSERT INTO EDW SELECT * FROM STG, which produces deterministic, idempotent loads.

Azure SQL Database tables in nathospDB (EDW schema)
nathospDB · STG + EDW schemas · 5 entity streams
Every entity is duplicated across STG and EDW schemas: STG mirrors the CSV cleaning output, EDW holds the analytics-ready copy with primary keys and load audit columns. Idempotent DELETE + INSERT pattern means re-runs produce identical warehouse state.
dim_patient
1,000 rows · dim · patient_id PK · demographic fields (name, DOB, gender, address)
dim_participants
500 rows · bridge · trial-patient enrollment with status
fact_med_record
1,986 rows · fact · patient admissions and discharges with diagnosis (14 rows quarantined for bad dates)
fact_labs
4,000 rows · fact · combined lab and imaging results (labs + imaging merged in load script)
fact_trials
20 rows · dim-like · clinical trial metadata (name, investigator, dates, description)
dim_date
4,018 rows · calendar · 2020-01-01 through 2030-12-31 with year, quarter, month, week, day_of_week
04 Engineering

A visibility-first cleaning layer and a four-check SQL safety net

The Databricks transformation notebook uses a helper function called clean_with_visibility() that replaces the common anti-pattern of silent dropDuplicates + na.drop(). Instead of quietly removing rows, it reports raw count, deduplication count, missing-critical-field count, and final count for every entity. It only drops rows missing genuinely critical fields (patient_id, record_id) rather than any null in any column, so a patient record with a missing address is preserved rather than silently discarded.

databricks · clean_with_visibility() helper
def clean_with_visibility(df, entity_name, critical_cols, quarantine=True,
                          original_raw_count=None, pre_filtered_count=0,
                          pre_filtered_reason=None):
    """Clean a DataFrame with full DQ visibility. Reports source-to-warehouse
    retention rate, quarantines dropped rows, and only drops on critical field
    nulls rather than any-null."""
    raw_count = df.count()
    true_raw = original_raw_count if original_raw_count is not None else raw_count

    deduped_df = df.dropDuplicates()
    duplicates_removed = raw_count - deduped_df.count()

    null_filter = F.lit(False)
    for col in critical_cols:
        null_filter = null_filter | F.col(col).isNull()

    dropped_df = deduped_df.filter(null_filter)
    cleaned_df = deduped_df.filter(~null_filter).withColumn("loaded_at", F.current_timestamp())

    if quarantine and dropped_df.count() > 0:
        dropped_df.write.mode("overwrite").csv(f"{storage_path}/quarantine/{entity_name}", header=True)

    print(f"[{entity_name}] Retention: {(cleaned_df.count() / true_raw * 100):.1f}% (source to warehouse)")
    return cleaned_df

Before this helper runs on medical_records, a date sanity filter identifies rows where discharge_date < admission_date and writes them to a quarantine folder. The filter caught 14 rows, all from a January 2024 cluster (dates like admission 2024-01-14 with discharge 2024-01-07). The pattern is too tight to be random. In a real hospital, this would trigger investigation with the source-system team about a specific window of data entry.

databricks · date sanity quarantine for medical_records
bad_dates_df = med_records_df.filter(
    F.col("discharge_date").isNotNull()
    & F.col("admission_date").isNotNull()
    & (F.col("discharge_date") < F.col("admission_date"))
)

if bad_dates_df.count() > 0:
    print(f"[medical_records] Date sanity: {bad_dates_df.count()} records with illogical dates")
    bad_dates_df.write.mode("overwrite").csv(
        f"{storage_path}/quarantine/medical_records_bad_dates", header=True
    )

med_records_df = med_records_df.filter(
    F.col("discharge_date").isNull()
    | F.col("admission_date").isNull()
    | (F.col("discharge_date") >= F.col("admission_date"))
)

The SQL side runs a four-check data quality safety net after all EDW loads succeed: row count parity between STG and EDW, primary key uniqueness on every table, foreign key orphan detection (facts referencing missing patients), and date sanity as a downstream check that upstream Databricks quarantine actually worked. Hard failures use THROW to fail the pipeline; soft warnings pass with alerts.

azure sql · foreign key orphan check (excerpt)
-- Catches the classic pipeline bug: Databricks dropped a patient,
-- but the medical records for that patient survived. Orphan facts
-- silently corrupt downstream analytics.
SELECT 'fact_med_record',
       (SELECT COUNT(*) FROM EDW.fact_med_record f
        LEFT JOIN EDW.dim_patient p ON f.patient_id = p.patient_id
        WHERE p.patient_id IS NULL)

IF @orphan_total > 0
BEGIN
    SET @error_count = @error_count + 1
    SET @error_message = 'ORPHAN FACTS DETECTED: ' +
        CAST(@orphan_total AS VARCHAR) +
        ' fact rows reference patients that do not exist in EDW.dim_patient.'
END
05 Decisions

Nine decisions I made, with honest tradeoffs on each

01

STG + EDW schemas over medallion Bronze/Silver/Gold

The brief allowed either pattern. I picked the classical enterprise warehousing layout (STG for landing, EDW for analytics) over medallion because National Hospital's use case is warehouse-first (SQL analysts querying via Power BI), not lake-first (data scientists reading Parquet). Both patterns are correct in their contexts. Tradeoff: no explicit "Silver" curation layer, so cleaning logic lives in Databricks rather than as a queryable intermediate table. Gain: simpler mental model for BI users, cleaner separation of staging vs analytics tables.

02

Databricks over stored procedures for transformations

Azure allows either. I picked Databricks because PySpark's expressiveness (window functions, complex string handling, quarantine writes to blob) is genuinely better than T-SQL stored procedures for this class of work. Tradeoff: cold-start cost per pipeline run (~1 minute for Databricks cluster start). Gain: transformation code is version-controlled Python, testable in isolation, and matches the toolchain of Nova Retail and WFI for consistency across my portfolio.

03

Visibility-first cleaning over silent na.drop()

The obvious pattern is df.dropDuplicates().na.drop(). It works, but drops any row with any null in any column silently. I replaced it with a clean_with_visibility() helper that only drops rows missing genuinely critical fields (patient_id, record_id), reports what was dropped and why, and quarantines rejected rows to a folder analysts can review. Tradeoff: more code and slightly slower (extra count operations). Gain: production-grade observability into what gets dropped, and no silent data loss.

04

Two-layer data quality suite (Databricks + SQL)

DQ could live at one layer only. Instead I split it: Databricks catches upstream problems (bad source data, missing critical fields, illogical dates), SQL catches downstream problems (row count parity, PK uniqueness, FK orphans). Tradeoff: two places to maintain DQ logic. Gain: each layer catches failure modes the other misses, and the SQL layer serves as a safety net that surfaces bugs in the upstream Databricks quarantine logic.

05

Quarantine over auto-correct for illogical dates

The 14 medical records with discharge_date < admission_date could have been silently dropped, auto-corrected by swapping the dates, or loaded with a suspicious flag. I chose quarantine: write them to quarantine/medical_records_bad_dates/, remove them from the loaded data, and document the pattern. Tradeoff: 14 rows do not reach the warehouse (0.7% loss). Gain: the warehouse stays clean, the bad data remains inspectable for source-system investigation, and no silent auto-correction hides the underlying data quality issue.

06

DELETE + INSERT for EDW loads over MERGE

Both patterns produce idempotent loads. I chose DELETE FROM EDW WHERE patient_id IN (SELECT FROM STG); INSERT INTO EDW SELECT * FROM STG because it is explicit about intent and easy to reason about at 2am. Tradeoff: no fine-grained update logic; every row is replaced wholesale. Gain: deterministic warehouse state on every run, and re-runs produce identical results. MERGE would be the natural next step for a v2 that needs SCD Type 2 history tracking.

07

Wildcard file paths over dataset-per-file

ADF Copy Data activities read from processed_data/patients_data_processed/*.csv. Databricks writes CSV as a folder of part files rather than a single file, so wildcard paths handle both single-partition and multi-partition outputs without change. Tradeoff: dataset definitions are less strict about what they will match. Gain: pipeline handles Databricks partitioning changes without requiring dataset republishing.

08

Storage account key via Databricks secret scope

The obvious anti-pattern is hardcoding the storage account key in the Databricks notebook. Instead, the key lives in an Azure Key Vault-backed Databricks secret scope, referenced as dbutils.secrets.get(scope="datalakekey", key="datalakekey"). Tradeoff: one-time setup complexity to create the secret scope. Gain: no credentials in source control, secure rotation possible, and the notebook is safe to share for open code review.

09

Daily schedule over event-driven

Real hospitals batch overnight rather than event-stream in real-time. The ADF trigger runs the pipeline daily at 10:00 AM UK time (auto-adjusts for daylight savings). Tradeoff: up to 24 hours of latency between source data change and warehouse refresh. Gain: matches actual hospital reporting cadence, avoids the complexity of event-driven triggers that would be genuinely overkill for this data volume and analytical use case.

06 Impact and runtime

Real pipeline runs, real numbers, real analytics output

The pipeline runs end-to-end in about 2 minutes and 20 seconds on real Azure infrastructure. Every activity is logged in Data Factory's monitoring UI with per-activity start times, durations, and success or failure status. Below is a captured run from August 23, 2026.

Databricks transformation (run_transformation)
1m 8s
Reads 6 CSVs from raw/, cleans through clean_with_visibility(), quarantines 14 medical_records with bad dates, writes cleaned data to processed_data/. Cold-start cost for Databricks cluster is included in this duration.
Six parallel STG copies (Copy Data)
16-23s each
Wildcard reads from processed_data/ into STG tables via ADF Copy Data. Pre-copy TRUNCATE TABLE STG.* ensures deterministic re-runs.
Five parallel EDW loads (Script)
9-27s each
DELETE FROM EDW WHERE patient_id IN (SELECT FROM STG); INSERT INTO EDW SELECT * FROM STG. The 27-second outlier is load_labs_imaging_edw which combines two source datasets into one target table.
Data quality checks (run_dq_checks)
10s
Four-check safety net: row count parity, PK uniqueness, FK orphans, date sanity. Fails pipeline on hard violations, warns on soft violations, passes silently when clean. Screenshot-ready output in ADF activity logs.
End-to-end pipeline runtime
2m 20s
From run_transformation start (09:53:43) to run_dq_checks completion (09:56:13). Consistent across multiple runs. Well within the daily-schedule window.

The January 2024 anomaly

The monthly admission trend query surfaced 49 months of data (January 2020 through January 2024). Baseline admission volume is roughly 40 per month with modest seasonal variation. January 2024 stood out with only 28 admissions, meaningfully below the baseline. Interestingly, this is the same month where the DQ suite flagged 14 medical records with illogical discharge_date < admission_date values.

Two independent data quality signals converging on the same time period suggest a source-system issue during January 2024 rather than random noise. In a real hospital deployment, this pattern would trigger investigation with the source IT team about that specific window (potentially a data-entry system bug, a shift change with training gaps, or a data migration during that month). Neither the low admission count nor the bad dates would be visible without the pipeline surfacing them. This is the difference between a data pipeline that moves data and a data platform that produces insight.

On honest cost

Real Azure infrastructure costs real money. Each end-to-end pipeline run consumes roughly £0.30 to £0.60 in Databricks compute plus Azure SQL Database ambient overhead. Cloud Composer for WFI cost £1 to £2 per run; this pipeline is cheaper because Databricks Serverless clusters spin up and down more efficiently than Cloud Composer's persistent environment. Total spend during development stayed well within the free Azure trial credit.

07 Tech stack

The tools, and what each one does here.

Azure Data Factory V2 Orchestration · 13-activity pipeline · daily trigger
Azure Databricks PySpark transformations · visibility-first cleaning
Azure SQL Database Warehouse · STG + EDW schemas · analytics surface
Azure Data Lake Gen2 Storage · raw + processed + quarantine folders
Azure Key Vault Secret storage · datalake key for Databricks
PySpark 3.x Distributed transform · Databricks-bundled runtime
Python 3.11 Databricks notebooks · Cleaning + validation logic
T-SQL EDW load scripts · 4-check DQ safety net
Copy Data Activity Blob to STG · wildcard file path · TRUNCATE precopy
Script Activity EDW loads · DELETE + INSERT idempotency · DQ checks
Databricks Notebook Activity Triggers PySpark cleaning · returns to ADF on success
Schedule Trigger Daily 10:00 UK time · auto-adjusts for daylight savings
Databricks Secret Scope Key Vault-backed · no credentials in notebook code
NVARCHAR Warehouse text columns · migrated from deprecated TEXT
WASB Protocol Databricks to blob storage · account key auth
10Alytics Dataset Synthetic hospital data · 6 CSVs across 4 departments

If I were scaling this up

For production against a full hospital's actual admissions data (millions of rows, not thousands), the natural evolutions would be: consolidate resources to a single Azure region to eliminate inter-region egress; SCD Type 2 dimensions on dim_patient so address and contact changes retain history; MERGE statements replacing DELETE + INSERT for finer-grained update semantics; incremental loading with watermarks or CDC rather than full-refresh; HIPAA-aware transformations including PHI hashing, audit logs on sensitive queries, and column-level encryption; dbt models on top of EDW for analyst-authored curated marts; Power BI dashboards for the Registration, Medical, Lab, and R&D departments; pytest coverage on the Databricks transformations to catch schema drift in CI rather than at runtime; and Azure Monitor alerts wired to the DQ check output for on-call notification when the pipeline finds real problems.

08 Honest limits

What this project does not do (yet)

No HIPAA compliance

The pipeline processes synthetic patient data with no encryption at rest for sensitive fields, no PHI hashing, no audit logs on sensitive queries, no column-level access control. A production healthcare pipeline would need all of these. Worth naming as scope, not as oversight.

No incremental loads

Every run does a full-refresh DELETE + INSERT. Correct for a bounded synthetic dataset; wrong for a growing hospital dataset. A production pipeline would need watermarks or Change Data Capture (CDC) with merge-on-key logic.

No unit tests

Neither the Databricks notebook nor the SQL DQ suite have pytest or tSQLt coverage. Data quality is enforced at runtime through the two-layer suite, but there is no CI-time check that the transformation logic or schema declarations behave correctly against fixtures. Next honest improvement.

No SCD Type 2 on dim_patient

If a patient's address or contact number changes, the previous value is overwritten. Real hospital analytics often need history (address on the date of admission, not current address). Adding SCD Type 2 would require effective-from and effective-to columns plus updated load logic.

Labs and imaging merged into one fact

fact_labs contains both lab results and imaging results in a single table with columns like image_url that are null for lab rows. Cleaner Kimball design would split into fact_lab_result and fact_imaging_result because their grain differs. Preserved as-is in this iteration; would refactor in v2.

No Power BI dashboard yet

The brief architecture diagram shows Power BI as the consumer. The EDW is queryable via SQL and Power BI Desktop can connect natively, but no dashboards exist yet. Adding disease frequency, monthly admission trend, and trial enrollment funnel visuals would complete the end-to-end analytics story.

Let's talk

Need a data engineer who ships production Azure pipelines?

I'm currently looking for Data Engineer / Analytics Engineer roles. UK-based, open to remote or hybrid.