A production-grade, end-to-end data engineering pipeline built entirely in Python. Collects hourly weather data for 5 major Kenyan cities, stores it in a Medallion-architecture PostgreSQL warehouse, runs daily SQL transformation jobs, and serves insights through an interactive Plotly Dash dashboard and a Flask REST API.
Pipeline Overview · Quick Start · Dashboard · API · Skills Learned · Roadmap
- Overview
- Pipeline Overview
- Medallion Architecture
- Data Collected
- Tech Stack
- Project Structure
- Prerequisites
- Quick Start
- Usage
- Dashboard
- API Reference
- Airflow DAGs
- Skills Learned
- Roadmap
- Contributing
- License
The Weather Data Warehouse is a full-lifecycle data engineering project that demonstrates every stage of a production pipeline applied to a single real-world domain: East African weather.
It tracks temperature, rainfall, wind, humidity, pressure, and visibility for Nairobi, Mombasa, Kisumu, Nakuru, and Eldoret — every hour, around the clock — using the Open-Meteo API (completely free, no API key needed).
The entire stack is pure Python and open-source. No paid services, no proprietary tools.
| Output | Detail |
|---|---|
| Raw ingestion | 120 rows/hour · 5 cities · 12 weather variables · UPSERT-safe |
| Daily summaries | Avg/min/max temp, total rainfall, wind, visibility per city per day |
| Monthly rollups | Monthly aggregates, rainy-day counts, trend direction |
| Anomaly detection | Hourly readings flagged where temp deviates > 2σ from 30-day baseline |
| Live dashboard | 5-tab Plotly Dash app, auto-refreshes hourly |
| REST API | 8 Flask endpoints + CSV export |
| Weekly email | HTML digest with 7-day city summaries |
┌──────────────┬───────────────┬───────────────┬───────────────┬─────────────────┐
│ STAGE 1 │ STAGE 2 │ STAGE 3 │ STAGE 4 │ STAGE 5 │
│ Generation │ Storage │ Ingestion │Transformation │ Serving │
├──────────────┼───────────────┼───────────────┼───────────────┼─────────────────┤
│ │ │ │ │ │
│ Open-Meteo │ PostgreSQL │ Apache │ SQL + Python │ Plotly Dash │
│ REST API │ Medallion │ Airflow │ │ (port 8050) │
│ │ Architecture │ │ daily_summary│ │
│ 5 cities │ │ Hourly DAG │ monthly_sum │ Flask API │
│ 12 variables │ Bronze │ Backfill DAG │ anomaly_det │ (port 5000) │
│ hourly │ Silver │ Daily DAG │ │ │
│ │ Gold │ Weekly DAG │ │ Email Reports │
└──────────────┴───────────────┴───────────────┴───────────────┴─────────────────┘
Open-Meteo API (free, no key)
│
▼ Stage 1 — Python, every hour via Airflow
bronze.weather_raw ← raw JSON payload, never modified
silver.weather_readings ← parsed columns, 120 rows/run, UPSERT-safe
│
▼ Stage 4 — SQL transforms, daily at 01:00 EAT
gold.daily_summary ──────────────────────────────┐
gold.monthly_summary ── Plotly Dash Dashboard ◄───┤
gold.temperature_anomalies ── Flask REST API ◄───┤
gold.rainfall_trends (view) ── Weekly Email ◄───┘
graph LR
A[Open-Meteo API] -->|Raw JSON| B[(Bronze: weather_raw)]
B -->|Parsed, typed| C[(Silver: weather_readings)]
C -->|Aggregated| D[(Gold: daily_summary, monthly_summary, anomalies)]
D -->|Read by| E[Dashboard & API]
This project implements the industry-standard Medallion (Bronze → Silver → Gold) pattern.
BRONZE SILVER GOLD
────────────── ────────────── ────────────────
Raw data exactly Validated, typed, Pre-computed,
as it arrived deduplicated business-ready
from the API hourly readings aggregates
bronze.weather_raw ──► silver.weather_readings ──► gold.daily_summary
gold.monthly_summary
gold.temperature_anomalies
gold.rainfall_trends (view)
| Layer | Schema | Purpose | Who writes | Who reads |
|---|---|---|---|---|
| Bronze | bronze.* |
Immutable raw JSON — audit trail and replay source | Stage 1 | Nobody directly |
| Silver | silver.* |
Clean, typed, one column per variable | Stage 1 | Stage 4 transforms |
| Gold | gold.* |
Pre-computed answers — fast queries | Stage 4 | Dashboard + API |
Why three layers? If parsing logic changes, replay from Bronze without re-hitting the API. Silver gives transforms a consistent input. Gold makes dashboards fast — they query aggregates, not raw hourly rows.
| City | Latitude | Longitude | Altitude | Notes |
|---|---|---|---|---|
| Nairobi | -1.2833° | 36.8167° | ~1700 m | Capital · pressure baseline ~840 hPa |
| Mombasa | -4.0547° | 39.6636° | ~17 m | Coastal · highest temperatures |
| Kisumu | -0.0861° | 34.7289° | ~1131 m | Lake Victoria region |
| Nakuru | -0.3072° | 36.0722° | ~1850 m | Rift Valley |
| Eldoret | 0.5204° | 35.2699° | ~2100 m | Highest altitude · coolest |
| Column | Unit | Description |
|---|---|---|
temp_celsius |
°C | 2 m air temperature |
apparent_temp |
°C | Feels-like temperature |
humidity_pct |
% | Relative humidity |
precipitation_mm |
mm | Total precipitation |
rain_mm |
mm | Rain-only precipitation |
weather_code |
WMO | 0=clear · 61=rain · 95=thunderstorm |
pressure_hpa |
hPa | Surface pressure |
cloud_cover_pct |
% | Cloud cover |
wind_speed_mps |
m/s | 10 m wind speed |
wind_direction_deg |
° | Wind direction |
wind_gusts_mps |
m/s | Wind gusts |
visibility_m |
m | Visibility in metres |
| Layer | Tool | Version | Why this? |
|---|---|---|---|
| Language | Python | 3.11+ | Readable, huge community, runs everywhere |
| Database | PostgreSQL | 15 | Free, reliable, excellent SQL support |
| Orchestration | Apache Airflow | 2.8 | Industry-standard scheduler — free, Python-native |
| Dashboard | Plotly Dash | 2.17 | Interactive web charts in pure Python — no JavaScript |
| API | Flask | 3.0 | Simplest Python web framework |
| Data Source | Open-Meteo | v1 | Completely free, no API key needed |
| Data Wrangling | pandas | 2.2 | The standard Python data tool |
| DB Adapter | psycopg2 | 2.9 | Native PostgreSQL connector |
| ORM | SQLAlchemy | 2.0 | Works seamlessly with pandas |
| Containerisation | Docker Compose | v2 | One command to start everything |
| Config | python-dotenv | 1.0 | Keeps passwords out of code |
weather-data-warehouse/
│
├── .env ← Your secret config (passwords, DB URL)
├── .env.example ← Template showing what goes in .env
├── requirements.txt ← Python packages to install
├── docker-compose.yml ← Starts PostgreSQL + Airflow
│
├── scripts/
│ ├── init_db.sql ← Creates all schemas and tables (run once)
│ └── setup_airflow.sh ← Configures Airflow connections (run once)
│
├── db/
│ └── connection.py ← Single place to get a DB connection
│
├── generation/ ── STAGE 1
│ └── weather_ingest.py ← Fetches data from Open-Meteo → PostgreSQL
│
├── transformation/ ── STAGE 4
│ ├── transform_runner.py ← Runs all SQL transforms from Python
│ ├── daily_summary.sql ← Silver → Gold: daily aggregation
│ ├── monthly_summary.sql ← Silver → Gold: monthly rollup
│ └── anomaly_detection.sql ← Gold: temperature anomaly flags
│
├── dags/ ── STAGE 3 (Airflow)
│ ├── hourly_ingest.py ← Runs weather_ingest.py every hour
│ ├── backfill.py ← Loads 31 days of history (manual)
│ ├── daily_transforms.py ← Runs transforms at 01:00 EAT daily
│ └── weekly_digest.py ← Sends summary email every Monday
│
├── serving/ ── STAGE 5
│ ├── dashboard.py ← Plotly Dash web dashboard (port 8050)
│ ├── api.py ← Flask REST API (port 5000)
│ └── reports.py ← Generates CSV / HTML weekly reports
│
└── logs/ ← Airflow writes logs here
| Tool | Download |
|---|---|
| Python 3.11+ | python.org |
| Docker Desktop | docker.com |
| Git | git-scm.com |
No database installation needed — PostgreSQL runs in Docker. No API keys needed — Open-Meteo is completely free.
git clone https://github.com/yourhandle/weather-data-warehouse.git
cd weather-data-warehousecp .env.example .envEdit .env with a text editor:
# Required — pick any strong password
POSTGRES_PASSWORD=MyWeatherPass123!
DATABASE_URL=postgresql+psycopg2://weather_admin:MyWeatherPass123!@localhost:5432/weather_db
# Airflow Fernet key (run this in Python, copy the output):
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
AIRFLOW_FERNET_KEY=paste_output_here
# Airflow secret key (run this in Python, copy the output):
# python -c "import secrets; print(secrets.token_hex(32))"
AIRFLOW_SECRET_KEY=paste_output_here
AIRFLOW_ADMIN_PASSWORD=admin123python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txtdocker compose up -dPostgreSQL starts, and scripts/init_db.sql runs automatically — creating all Bronze, Silver, and Gold schemas, tables, and indexes. No manual SQL needed.
docker compose ps # all containers should show "healthy" or "Up"bash scripts/setup_airflow.sh# Load 31 days of history (recommended)
python generation/weather_ingest.py --mode backfill
# Or just pull today
python generation/weather_ingest.py2026-06-13 20:31:06 INFO ===== Weather Ingest START [mode=backfill] =====
2026-06-13 20:31:07 INFO [Nairobi] 744 rows parsed
2026-06-13 20:31:08 INFO [Mombasa] 744 rows parsed
...
2026-06-13 20:31:10 INFO Total: 3720 rows | cities: 5
2026-06-13 20:31:10 INFO Wrote 3720 rows to silver.weather_readings
2026-06-13 20:31:10 INFO ===== Weather Ingest DONE =====
python -m transformation.transform_runner --transform allpython serving/dashboard.py→ Open http://localhost:8050
| Service | URL | Credentials |
|---|---|---|
| Dashboard | http://localhost:8050 | None |
| REST API | http://localhost:5000 | None |
| Airflow UI | http://localhost:8080 | admin / your AIRFLOW_ADMIN_PASSWORD |
- Open Airflow at http://localhost:8080
- Find
hourly_weather_ingestand toggle it ON - Find
daily_weather_transformsand toggle it ON - Data now collects automatically — no manual intervention needed
docker compose exec postgres psql -U weather_admin -d weather_db -c "
SELECT 'silver.weather_readings' AS layer, COUNT(*) FROM silver.weather_readings
UNION ALL
SELECT 'gold.daily_summary', COUNT(*) FROM gold.daily_summary
UNION ALL
SELECT 'gold.temperature_anomalies', COUNT(*) FROM gold.temperature_anomalies;"python -m transformation.transform_runner --transform daily --date 2026-06-12
python -m transformation.transform_runner --transform anomalies
python -m transformation.transform_runner --transform allFive interactive tabs, built with Plotly Dash — pure Python, no JavaScript, no external services.
python serving/dashboard.py → http://localhost:8050
| Tab | Charts | Data Source |
|---|---|---|
| Overview | City stat cards: temp, rain, wind, condition | gold.daily_summary |
| Temperature | 30-day trend line + min/max shaded range band | gold.daily_summary |
| Rainfall | Daily grouped bar chart + 7-day rolling average line | gold.rainfall_trends |
| Anomalies | Scatter plot vs baseline + sortable anomaly table | gold.temperature_anomalies |
| Monthly | Monthly avg temp + total rainfall grouped bar charts | gold.monthly_summary |
Features: auto-refresh every hour · city filter on temperature tab · consistent city colour palette · anomaly rows highlighted red/blue · all queries hit pre-computed Gold layer
python serving/api.py → http://localhost:5000
| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
Liveness check + last Gold layer update timestamp |
GET |
/api/cities |
List all monitored cities |
GET |
/api/weather/current/<city> |
Latest daily summary for one city |
GET |
/api/weather/daily/<city> |
Date-range daily summaries (?from_date=&to_date=) |
GET |
/api/weather/monthly/<city> |
Monthly aggregates (last 12 months) |
GET |
/api/weather/anomalies |
Flagged anomalies (?hours=48&city=Nairobi) |
GET |
/api/weather/compare |
All-city snapshot for one date (?date=2026-06-12) |
GET |
/api/export/csv |
Download Gold data as CSV (?city=Nairobi&from_date=) |
Example:
curl http://localhost:5000/api/weather/current/Nairobi{
"city": "Nairobi",
"summary_date": "2026-06-12",
"avg_temp": 18.2,
"min_temp": 15.1,
"max_temp": 21.4,
"avg_humidity": 82.5,
"avg_pressure": 840.3,
"total_rain": 0.4,
"avg_wind_speed": 4.8,
"avg_wind_gusts": 9.2,
"avg_visibility": 9400.0,
"dominant_code": 51
}Access at http://localhost:8080 (admin / your password)
| DAG | Schedule | Trigger | What it does |
|---|---|---|---|
hourly_weather_ingest |
Every hour | Automatic | Pulls API → Bronze + Silver |
backfill_weather |
None | Manual ▶ | Loads 31 days of history |
daily_weather_transforms |
Daily 01:00 EAT | Automatic | Silver → Gold aggregates |
weekly_weather_digest |
Monday 07:00 EAT | Automatic | Sends HTML email report |
Data Engineering Fundamentals
- Full pipeline thinking — each stage has one job and hands a clean contract to the next
- Medallion architecture — why three layers beat one flat table in every production system
- Idempotent design — every write uses
ON CONFLICT DO NOTHING/UPDATE, making every stage safe to retry without data corruption
Batch Pipeline Design
- Airflow patterns —
ShortCircuitOperatorfor conditional monthly runs,max_active_runs=1for concurrency safety, XCom for inter-task reporting - Per-task error isolation — wrapping each city in
try/exceptso one failure never kills the others - Retry logic — exponential backoff for transient API failures
Time-Series Data & SQL
- Timezone correctness — why
DATE(recorded_at)gives wrong dates withoutAT TIME ZONE 'Africa/Nairobi'for EAT data stored as UTC - SQL window functions —
STDDEV() OVER,AVG() OVER (ROWS BETWEEN N PRECEDING)for rolling baselines MODE() WITHIN GROUP— ordered-set aggregate for dominant WMO weather code per day- Upsert patterns —
ON CONFLICT DO UPDATEfor safe incremental loads
Python Data Stack
- Plotly Dash — multi-tab interactive dashboards with callbacks, dropdown filters, DataTable, and auto-refresh
- Flask — REST endpoints with query params, JSON serialisation, and streaming CSV responses
- pandas + SQLAlchemy —
pd.read_sql()for dashboard queries,df.to_sql()for bulk inserts - openmeteo-requests — batched multi-city API calls with caching and retry middleware
Infrastructure & DevOps
- Docker Compose — multi-service orchestration with
healthcheck,depends_on: condition: service_healthy, and named volumes - Airflow connection management — externalising database credentials out of DAG code for portability
- Environment management —
.envfiles andpython-dotenv, never committing secrets
East African Context
timezone: autoin Open-Meteo — all 5 Kenyan cities auto-resolve toAfrica/Nairobi(EAT = UTC+3) from coordinates- High-altitude pressure — Nairobi's ~840 hPa is correct at 1700 m above sea level, not a data error
- Multi-city pipeline design — a single codebase handles N cities with zero code duplication
- Great Expectations for dataset-level validation suites with DAG-level quality gates
- Alembic schema migrations for version-controlled database evolution
- dbt to replace raw
.sqlfiles with tested, versioned, lineage-tracked models - GitHub Actions CI/CD —
pyteston every push, DAG linting before merge - Apache Kafka for real-time anomaly detection (seconds, not hourly)
- Pan-Africa expansion — Kampala, Dar es Salaam, Kigali, Addis Ababa, Lagos
- Open-Meteo Historical API — weather data back to 1940 for long-term climate analysis
- USSD interface via Africa's Talking for feature-phone users (
*384#) - WhatsApp Business API alerts for rainfall thresholds and anomalies
- Auto-generated weekly Jupyter notebook reports via Papermill
- Embeddable JavaScript weather widget for media and government portals
- Kubernetes + Helm for cloud-scale deployment
- HashiCorp Vault or GCP Secret Manager replacing
.envin production - Prometheus metrics + dedicated pipeline health dashboard
| Error | Cause | Fix |
|---|---|---|
DATABASE_URL is not set |
.env not configured |
cp .env.example .env and fill in values |
Connection refused on port 5432 |
PostgreSQL not running | docker compose up -d postgres |
relation silver.weather_readings does not exist |
init_db.sql didn't auto-run |
Run: docker compose exec postgres psql -U weather_admin -d weather_db -f /docker-entrypoint-initdb.d/init_db.sql |
| DAG not appearing in Airflow | Mount not configured | Check dags/ volume in docker-compose.yml; restart scheduler |
No module named 'openmeteo_requests' |
venv not active | source .venv/bin/activate && pip install -r requirements.txt |
ImportError on generation module |
PYTHONPATH missing | export PYTHONPATH="$PWD" |
| Dashboard shows "No data yet" | Transforms not run | python -m transformation.transform_runner --transform all |
| Airflow Fernet key error | Key not generated | python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" |
- Fork the repository
- Create a branch:
git checkout -b feature/add-kisii - Make changes — adding a city only requires one line in
CITIESlist - Test:
python generation/weather_ingest.py --dry-run - Pull request with a clear description
MIT License — Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies,
without restriction.
Built in Nairobi, Kenya 🇰🇪
Python · PostgreSQL · Apache Airflow · Plotly Dash · Flask · Open-Meteo
Implements the complete data engineering lifecycle: Generation → Storage → Ingestion → Transformation → Serving
⭐ Star this repo if it helped you learn data engineering!