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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
-- 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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.processed_data/ into STG
tables via ADF Copy Data. Pre-copy TRUNCATE TABLE STG.* ensures deterministic
re-runs.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.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 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.
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.
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.
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.
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.
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.
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.
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.
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.
I'm currently looking for Data Engineer / Analytics Engineer roles. UK-based, open to remote or hybrid.