Skip to Content
The HIC Learning Exchange begins July 13, 2026. View the agenda
LectureItem 8 of 22 · 30 min

Introduction to Data Modelling

What is data modelling

Data modelling is the process of organising data into a structure that is easy to store, understand, query, and analyse. It means transforming raw ingested data into structured, reliable, business-ready datasets that can be used for reporting, analytics, dashboards, machine learning, and applications.

In an ELT pipeline, raw data lands first — modelling is the transform step that happens after ingestion. Extract and load land the data untouched; transform models it into something usable. That transform step is the focus of this lecture.

What you'll learn
  • Explain why raw data lands untouched before any modelling happens
  • List the five activities that take raw data to business-ready models
  • Profile a raw table for nulls, duplicates, and type issues before modelling it
  • Build a staging model that casts types, renames fields, and dedupes rows
  • Combine staging models into a conformed intermediate model with shared business logic
  • Build fact and dimension marts ready for BI consumption

Why load raw first, transform after

PurposeWhy it matters
Preserves raw fidelityKeeps an untouched copy of source data for audit and replay.
Decouples ingestion and modellingIngestion and modelling teams can work independently.
Leverages warehouse computePushes transformations into the scalable warehouse engine.
Enables reproducibilityAny model can be rebuilt from raw data at any time.
Supports multiple consumersOne raw dataset can power many downstream models.
Faster, flexible iterationTransformation logic can change without re-extracting data.

The five activities of data modelling

Modelling takes you from raw, landed data to tested, business-ready models in five stages. The worked example below follows a single table — raw.vaccine_shipments, 50,000 rows landed from the source system — through all five, using dbt.

Validate and profile

Scan the raw data for schema issues, nulls, and duplicates before building anything on top of it. Profiling raw.vaccine_shipments turns up 120 rows with a null facility_id, roughly 300 duplicate shipment_id values, and a quantity field stored as text — '500 doses' instead of 500.

select count(*) filter (where facility_id is null) as null_facility_ids, count(*) - count(distinct shipment_id) as duplicate_shipment_ids, count(*) filter (where quantity !~ '^[0-9]+$') as non_numeric_quantities from raw.vaccine_shipments;

Clean and standardize into staging

Cast types, rename fields, and dedupe into a staging model — one clean, typed row per shipment. stg_vaccine__shipments casts quantity to an integer (stripping the ' doses' suffix), renames facility_id to health_facility_id, and dedupes on shipment_id, keeping the latest received_at.

-- models/staging/stg_vaccine__shipments.sql with ranked as ( select shipment_id, facility_id as health_facility_id, cast(replace(quantity, ' doses', '') as integer) as quantity_doses, received_at, row_number() over ( partition by shipment_id order by received_at desc ) as row_num from {{ source('raw', 'vaccine_shipments') }} ) select shipment_id, health_facility_id, quantity_doses, received_at from ranked where row_num = 1

Conform and integrate

Join sources and apply shared business logic once, in one place. int_vaccine_stock_ledger combines shipments (issues and receipts), doses administered, and facility metadata, and applies a single stock rule everywhere — instead of every report recalculating the balance differently.

-- models/intermediate/int_vaccine_stock_ledger.sql with shipments as ( select * from {{ ref('stg_vaccine__shipments') }} ), doses as ( select * from {{ ref('stg_immunization__doses') }} ), facilities as ( select * from {{ ref('stg_facility__master') }} ), stock as ( select * from {{ ref('stg_facility__stock_counts') }} ) select s.health_facility_id, f.district, s.received_at::date as ledger_date, s.quantity_doses as received, d.doses_administered, d.doses_wasted, -- one shared rule, applied once k.opening_balance + s.quantity_doses - d.doses_administered - d.doses_wasted as closing_balance from shipments s join doses d on d.health_facility_id = s.health_facility_id and d.dose_date = s.received_at::date join stock k on k.health_facility_id = s.health_facility_id and k.count_date = s.received_at::date join facilities f on f.health_facility_id = s.health_facility_id

Build marts

Create the dimensional models consumers actually query: fct_vaccine_stock_movements (one row per transaction, ready for BI) and dim_health_facility (district, province, facility type). An analyst can now answer “average stockout days by province last quarter” directly, with no joins or business logic to reinvent.

-- models/marts/fct_vaccine_stock_movements.sql with ledger as ( select * from {{ ref('int_vaccine_stock_ledger') }} ), -- unpivot the daily ledger into one row per stock transaction movements as ( select health_facility_id, ledger_date, 'receipt' as transaction_type, received as quantity_doses from ledger union all select health_facility_id, ledger_date, 'administered', doses_administered from ledger union all select health_facility_id, ledger_date, 'adjustment', doses_wasted from ledger ) select movements.health_facility_id, facility.province, facility.district, facility.facility_type, movements.ledger_date, movements.transaction_type, movements.quantity_doses from movements join {{ ref('dim_health_facility') }} as facility on facility.health_facility_id = movements.health_facility_id

Test, document, and schedule

Add data tests and field descriptions in schema.yml, then schedule the pipeline so models stay fresh — for example dbt run --select vaccine_stock+ nightly, so dashboards are up to date each morning.

# models/marts/schema.yml models: - name: fct_vaccine_stock_movements columns: - name: health_facility_id description: Facility receiving or issuing the stock movement. tests: - not_null - relationships: to: ref('dim_health_facility') field: health_facility_id - name: transaction_type description: Kind of stock movement recorded. tests: - accepted_values: values: [issue, receipt, administered, adjustment] - name: dim_health_facility columns: - name: health_facility_id description: Unique identifier for the health facility. tests: - not_null - unique

The live demo for this lecture runs as Lab 3: Setting Up Medallion Infrastructure — Bronze, Silver, Gold during the Exchange. The lab materials are published in this sub-module: start with the dbt training guide, then work through Lab 3: Medallion dbt Modeling — DHIS2 Case.