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.
- 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
| Purpose | Why it matters |
|---|---|
| Preserves raw fidelity | Keeps an untouched copy of source data for audit and replay. |
| Decouples ingestion and modelling | Ingestion and modelling teams can work independently. |
| Leverages warehouse compute | Pushes transformations into the scalable warehouse engine. |
| Enables reproducibility | Any model can be rebuilt from raw data at any time. |
| Supports multiple consumers | One raw dataset can power many downstream models. |
| Faster, flexible iteration | Transformation 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 = 1Conform 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_idBuild 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_idTest, 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
- uniqueThe 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.