A production-style data engineering pipeline that ingests live unemployment data from the World Bank API, validates structure and value bounds, transforms raw observations into an analytical dataset with year-over-year change calculations, and loads the result into a local DuckDB warehouse. The pipeline is orchestrated with Prefect, containerized with Docker for reproducible runs, and validated on every push via GitHub Actions.
Data source: World Bank Open Data API — Indicator SL.UEM.TOTL.ZS (unemployment, total % of labor force)
Geographic scope: East African Community (EAC) member states: Kenya, Uganda, Tanzania, Rwanda, Burundi, South Sudan, DR Congo
The analysis workflow relied on a static CSV file manually exported from the World Bank at a single point in time. This approach introduced several engineering limitations:
- Data becomes outdated: Analysis is frozen at the moment the CSV was downloaded; refreshing requires manual re-downloads
- Manual data acquisition: No automated, repeatable process for acquiring fresh data
- Weak reproducibility: Different analysts may use different CSV snapshots, leading to inconsistent results
- Lack of automated validation: No structural checks on the raw data before transformation or analysis
- Difficulty refreshing analysis: Updating the analysis requires repeating the entire manual workflow
- External data reliability: No handling for API changes, pagination, or missing observations across countries and years
- Missing observations: Not every country reports unemployment data for every year — the pipeline must distinguish between structurally invalid data and expected gaps in reporting
Moving to live API ingestion improves the pipeline by:
- Current data: Each run retrieves the latest available observations from the World Bank
- Automated acquisition: No manual downloads required
- Reproducibility: The same code produces consistent results from the same API state
- Explicit validation: Structural and value checks before transformation
- Clear missing data strategy: Null unemployment values are allowed during validation where the source did not report data; rows without values are excluded during transformation
Important distinction: Live API ingestion does not guarantee complete reporting coverage. Source freshness and source completeness are separate concerns — the pipeline handles both explicitly.
Endpoint: https://api.worldbank.org/v2/country/{country_codes}/indicator/SL.UEM.TOTL.ZS
Indicator: SL.UEM.TOTL.ZS — Unemployment, total (% of total labor force) (modeled ILO estimate)
API characteristics:
- Public API — no API key required
- Returns paginated JSON responses
- Each observation includes: country code, country name, year, indicator value, unit of measurement
- Not every country has observations for every year — missing data is expected and handled explicitly
The pipeline fetches data for all East African Community (EAC) member states:
| Country | ISO Code |
|---|---|
| Kenya | KE |
| Uganda | UG |
| Tanzania | TZ |
| Rwanda | RW |
| Burundi | BI |
| South Sudan | SS |
| DR Congo | CD |
- Unit of measurement: Percentage of total labor force
- Grain: One row per country per year (where data is reported)
- Time coverage: All available years from the World Bank API (varies by country)
- Missing observations: Some country-year combinations have no reported unemployment value — this is expected and handled explicitly
┌─────────────────────┐
│ World Bank API │
│ (external source) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Ingestion │
│ (requests) │
│ → raw JSON │
│ → manifest │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Validation │
│ (pandera) │
│ → schema gate │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Transformation │
│ (pandas) │
│ → Parquet │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Storage │
│ (DuckDB) │
│ → warehouse │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Orchestration │
│ (Prefect) │
│ → DAG flow │
└──────────┬──────────┘
│
├──────────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Containerization │ │ CI/CD │
│ (Docker) │ │ (GitHub Actions) │
│ → reproducible run │ │ → pytest │
└─────────────────────┘ └─────────────────────┘
| Stage | Responsibility | Technology | Output |
|---|---|---|---|
| Ingestion | Fetches unemployment data from World Bank API with pagination, writes raw JSON and manifest | requests |
Raw JSON files + manifest JSON |
| Validation | Validates structure, country codes, year range, value bounds; allows nulls where countries didn't report | pandera |
Validation gate (pass/fail) |
| Transformation | Drops unreported rows, computes year-over-year change per country, writes typed columnar output | pandas, pyarrow |
Parquet files |
| Storage | Loads processed data into local analytical warehouse | DuckDB |
DuckDB database with typed tables |
| Orchestration | Chains ingestion → validation → transformation → load into single flow with retries and hard validation gate | Prefect |
Orchestrated DAG execution |
| Containerization | Packages full pipeline so it runs identically on any machine | Docker, docker compose |
Reproducible container runtime |
| CI | Runs unit test suite and schema sanity check on every push/PR | GitHub Actions, pytest |
Automated code validation |
The pipeline is decomposed into independently runnable, independently tested modules. Each stage has a clear input, processing logic, validation, output, and failure behavior.
Input: Country codes (EAC member states), indicator code (SL.UEM.TOTL.ZS)
Processing:
- Constructs World Bank API URL with country codes and indicator
- Handles pagination automatically (World Bank API returns paginated responses)
- Fetches all pages until complete dataset is retrieved
- Parses JSON response and extracts observations
- Writes raw JSON to
data/raw/with timestamped filename - Generates manifest JSON containing:
- Ingestion timestamp
- Indicator code
- Countries fetched
- Page count
- Total record count
- Source URL
Output: Raw JSON files + manifest JSON in data/raw/
Failure behavior: Pipeline halts if API request fails or pagination handling encounters an error. No downstream stages execute without valid raw data and manifest.
Why raw JSON is preserved: The raw layer provides traceability — the exact API response is captured and can be audited or reprocessed if needed.
Input: Raw JSON observations from ingestion stage
Processing:
- Loads observations into a Pandas DataFrame
- Applies a Pandera schema that enforces:
- Required columns:
country_code,country_name,year,value - Data types: All columns typed explicitly (string, integer, float)
- Country codes: Must match expected EAC member state codes (KE, UG, TZ, RW, BI, SS, CD)
- Country names: Must be non-empty strings
- Year range: Must be within reasonable bounds (e.g., 1960–2026)
- Value bounds: Unemployment percentage must be between 0 and 100 (inclusive) if present
- Nullability:
valuecolumn is explicitly nullable — not every country reports every year - Duplicate records: No duplicate (country_code, year) combinations
- Required columns:
Output: Validation result (pass/fail)
Failure behavior: If validation fails, the pipeline halts immediately. No transformation or storage occurs.
Important distinction: A missing unemployment value from the World Bank is not automatically invalid source data. The schema allows nulls in the value column where the source did not report data. This distinguishes between:
- Structurally invalid data: Wrong country code, year out of range, value outside 0–100 — fails validation
- Missing reported observations: Valid record structure but no value reported — passes validation, handled during transformation
Input: Validated DataFrame with raw observations
Processing:
- Drop unreported rows: Removes rows where
valueis null (countries that didn't report for that year) - Sort: Orders by country code and year for consistent processing
- Deduplication: Removes any duplicate (country_code, year) combinations (should not exist after validation, but defensive check)
- Year-over-year calculation: For each country, computes the change in unemployment rate from the previous year:
- Formula:
yoy_change = current_year_value - previous_year_value - First year for each country has
yoy_change = null(no prior year to compare)
- Formula:
- Data type conversions: Ensures all columns are explicitly typed
- Output column selection: Standardizes column order for downstream consistency
Output: Parquet file in data/processed/
Output fields:
| Field | Transformation | Purpose |
|---|---|---|
country_code |
Direct from API | ISO country identifier |
country_name |
Direct from API | Human-readable country name |
year |
Direct from API | Observation year |
unemployment_rate |
Direct from API (value) |
Unemployment % of labor force |
yoy_change |
current_year_value - previous_year_value |
Year-over-year change in percentage points |
Why missing observations are handled after validation: Structural validation ensures the data is well-formed before any filtering. Dropping unreported rows is a transformation decision, not a validation failure.
Input: Processed Parquet file
Processing:
- Creates or recreates DuckDB database at
data/warehouse/unemployment.duckdb - Loads Parquet into a table named
unemployment_rates - Schema mirrors Parquet columns with explicit types
- Table is fully replaced on each run (not incremental)
Output: DuckDB database with unemployment_rates table
Load method: Full reload — appropriate for this dataset size and single-node architecture.
Why DuckDB:
- Local analytical workload: No server to manage; file-based database
- Native Parquet support: Reads Parquet directly without ETL
- Typed analytical storage: Enforces schema on load
- Reproducibility: Database file is portable and versionable (though gitignored here)
Input: None (orchestrates all stages)
Flow structure:
- Task 1:
ingest_data()— fetches from World Bank API - Task 2:
validate_data()— applies Pandera schema (hard gate) - Task 3:
transform_data()— feature engineering and Parquet output - Task 4:
load_to_warehouse()— DuckDB load
Dependencies: Sequential (ingest → validate → transform → load)
Retries: Configured with 2 retries for ingestion and storage tasks (transient network or file I/O errors)
Validation gate: If validate_data() fails, downstream tasks are skipped entirely
Failure behavior: Prefect marks the flow as failed, logs the error, and does not proceed to transformation or load.
This pipeline makes an explicit distinction between two types of "missing" data:
- Wrong country code (not in EAC scope)
- Year outside reasonable range
- Unemployment value outside 0–100 range
- Duplicate (country, year) combinations
- Missing required fields (country code, name, year)
Handling: Validation fails — pipeline halts, no downstream processing.
- Valid country code, year, and structure
- Unemployment value is null (source did not report)
Handling:
- Validation: Passes — null values are explicitly allowed in the schema
- Transformation: Rows with null values are dropped before analytical storage
- Rationale: These are not data quality failures — the World Bank simply doesn't have data for that country-year combination
Why this matters: Treating all nulls as validation failures would incorrectly flag expected gaps in reporting as errors. The pipeline distinguishes between "bad data" and "data not reported."
Source: World Bank Open Data API — Indicator SL.UEM.TOTL.ZS
Geographic scope: East African Community (EAC) member states (7 countries)
Time coverage: All available years from the World Bank API (varies by country)
Unit of measurement: Percentage of total labor force
Grain: One row per country per year (where data is reported)
Output format: Parquet file + DuckDB table
Row count: Varies by run — depends on how many country-year combinations have reported data
- Unemployment rate: Modeled ILO estimate, total % of labor force
- Year-over-year change: Absolute percentage-point change from prior year (null for first year per country)
- Missing data: Some country-year combinations have no reported value — excluded from analytical output
The pipeline enforces data quality at multiple stages:
| Check | Stage | Enforcement |
|---|---|---|
| API response validation | Ingestion | HTTP status, pagination handling, JSON parsing |
| Schema validation | Validation | Pandera schema enforces column names, types, nullability |
| Country scope validation | Validation | Country codes must match EAC member states |
| Year validation | Validation | Years must be within reasonable bounds |
| Value bounds | Validation | Unemployment rate must be 0–100 if present |
| Nullable observations | Validation | Null values allowed where source didn't report |
| Duplicate detection | Validation | No duplicate (country_code, year) combinations |
| Transformation checks | Transformation | Drops unreported rows, defensive deduplication |
| Storage checks | Storage | Row count verified after DuckDB load |
| Pipeline failure | Orchestration | Failed tasks halt downstream execution; retries configured |
Invalid data stops the pipeline: The validation gate ensures structurally invalid data never reaches transformation or storage.
Expected missing observations follow defined policy: Null values are allowed during validation but excluded during transformation — this is a deliberate design choice, not an oversight.
.
├── src/
│ ├── ingestion/ # World Bank API pull (paginated), raw JSON + manifest
│ ├── validation/ # Pandera schema definition and validation gate
│ ├── transformation/ # Cleaning, YoY feature, Parquet output
│ ├── storage/ # DuckDB load
│ └── pipeline/ # Prefect flow tying all stages together
├── tests/
│ ├── test_ingestion.py # API response handling (mocked)
│ ├── test_validation.py # Pandera schema tests
│ ├── test_transformation.py # Feature engineering tests
│ ├── test_storage.py # DuckDB load tests
│ └── test_pipeline.py # End-to-end flow tests (synthetic data)
├── data/ # Gitignored: raw/, processed/, warehouse/ (generated at runtime)
├── data-science/ # Original notebook, static CSV, and slide deck this project started from
├── Dockerfile # Container image definition
├── docker-compose.yml # Service orchestration
├── requirements.txt # Python dependencies
└── .github/
└── workflows/
└── ci.yml # GitHub Actions CI workflow
The data-science/ directory contains the original analytical project from which this data engineering pipeline evolved:
- Static CSV snapshot of World Bank data
- Jupyter notebook with unemployment analysis
- Slide deck presenting findings
This directory is preserved for reference but is not part of the live pipeline. The engineering project demonstrates the transformation from a static, manual workflow into a reproducible, automated pipeline.
- Python 3.10+
- Docker Desktop (optional, for containerized runs)
- Internet access (required for World Bank API calls)
No API key required — the World Bank API is public.
git clone <repository-url>
cd unemployment-ea-pipeline# Create virtual environment
python -m venv .venv
# Activate (Linux/macOS)
source .venv/bin/activate
# Activate (Windows)
.venv\Scripts\activatepip install -r requirements.txtpython -m src.pipeline.flowThe flow runs: ingestion → validation → transformation → load. If validation fails, the pipeline halts before transformation or storage.
pytest tests/ -vUnit tests use synthetic data and do not require live API access.
docker compose up --buildDocker Compose builds the image and runs the pipeline in a container with the same environment configuration.
The test suite is designed for speed, isolation, and reproducibility.
| Test Module | Coverage |
|---|---|
test_ingestion.py |
API response handling with mocked responses |
test_validation.py |
Pandera schema validation (pass/fail cases) |
test_transformation.py |
Feature engineering, YoY calculations, missing value handling |
test_storage.py |
DuckDB load and row count verification |
test_pipeline.py |
End-to-end flow with synthetic data |
Key properties:
- Synthetic data: Tests do not depend on live API access or real data
- Isolation: Each stage is tested independently
- Speed: Full test suite runs in seconds
- External dependency avoidance: CI can run tests without internet access or API availability
The Prefect flow orchestrates the pipeline rather than performing the transformations itself.
src/pipeline/flow.py — defines the unemployment_data_pipeline() flow
| Task | Function | Purpose |
|---|---|---|
| Ingest | ingest_data() |
Fetches data from World Bank API |
| Validate | validate_data() |
Applies Pandera schema (hard gate) |
| Transform | transform_data() |
Feature engineering and Parquet output |
| Load | load_to_warehouse() |
DuckDB storage |
Sequential: ingest_data → validate_data → transform_data → load_to_warehouse
- Ingestion and storage tasks: 2 retries with exponential backoff (transient errors)
- Validation and transformation: No retries (failures indicate data issues, not transient errors)
- If
validate_data()fails, downstream tasks are skipped - Prefect marks the flow as failed and logs the error
- No partial data is written to storage
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/
COPY data/ data/
CMD ["python", "-m", "src.pipeline.flow"]services:
pipeline:
build: .
volumes:
- ./data:/app/data- Reproducibility: Same Python version, dependencies, and environment on any machine
- Isolation: No conflicts with host system packages
- Portability: Runs identically on local machines, CI, or cloud infrastructure
- Simplified onboarding: New engineers can run the pipeline with a single
docker compose upcommand
Triggers: Push to main, pull requests
Python version: 3.11
Steps:
- Checkout repository
- Install Python dependencies
- Run
pytest tests/ -v - Schema sanity check (validates Pandera schema loads without errors)
What CI does NOT do:
- Does not run the live pipeline against the World Bank API
- Does not fetch live data
- Does not execute the full end-to-end flow
Rationale:
- Repeatability: CI should not depend on external API availability
- Test speed: Unit tests run in seconds; live API calls add latency
- Reliability: CI should not fail due to temporary API outages
- Independence: Code correctness is separate from data availability
Full pipeline runs against live data are a separate, deliberate concern requiring internet access and manual or scheduled execution.
Decision: Fetch data from the World Bank API on each run rather than using a static CSV snapshot.
Reasons:
- Current data: Each run retrieves the latest available observations
- Repeatability: No manual downloads required
- Reduced manual work: Automated acquisition eliminates human error
- Source refreshes: Pipeline stays current as World Bank updates data
Trade-off: Requires internet access; API availability is an external dependency.
Important acknowledgment: Live ingestion does not guarantee complete reporting coverage — some country-year combinations will still have missing data.
Decision: Allow null unemployment values during validation where the source did not report data.
Reasons:
- Realistic data modeling: Not every country reports every year — this is expected
- Distinguish failure modes: Structural invalidity vs. missing reporting are different problems
- Explicit handling: Transformation drops unreported rows deliberately, not as a validation failure
Trade-off: Validation is more permissive, but transformation enforces the "must have value" rule before storage.
Decision: Use DuckDB rather than PostgreSQL, MySQL, or a cloud warehouse.
Reasons:
- Local analytical workload: No server to manage; file-based database
- Native Parquet support: Reads Parquet directly without ETL
- Single-node architecture: Appropriate for this dataset size
- Reproducibility: Database file is portable
Trade-off: Not suitable for multi-user concurrent writes or cloud-scale workloads.
Decision: Use Prefect rather than Airflow or manual scripting.
Reasons:
- Cross-platform execution: Runs on Windows, macOS, Linux without WSL or Docker
- Task orchestration: Explicit DAG structure with dependencies
- Retries: Built-in retry logic for transient errors
- Failure visibility: Clear logging and task status
- Lightweight setup: No database backend or web server required
Trade-off: Less mature ecosystem than Airflow for enterprise-scale deployments.
Decision: GitHub Actions runs unit tests only, not the full pipeline against live data.
Reasons:
- Repeatability: CI should not depend on external API availability
- Test speed: Unit tests run in seconds
- Reliability: CI should not fail due to temporary API outages
- Independence: Code correctness is separate from data availability
Trade-off: CI does not validate end-to-end data flow — that requires manual or scheduled runs.
To reproduce a pipeline run:
- Python version: 3.10+ (tested on 3.11)
- Dependencies: Install from
requirements.txt - Internet access: Required for World Bank API calls
- Docker (optional):
docker compose up --buildensures identical environment
No API key required — the World Bank API is public.
| Artifact | Location | Git-tracked |
|---|---|---|
| Raw JSON | data/raw/ |
No |
| Manifest JSON | data/raw/ |
No |
| Processed Parquet | data/processed/ |
No |
| DuckDB database | data/warehouse/ |
No |
src/— pipeline codetests/— test suiterequirements.txt— dependenciesDockerfile,docker-compose.yml— containerization.github/workflows/ci.yml— CI configurationdata-science/— original analytical project (reference only)
Each run retrieves current available data from the World Bank API — results may vary slightly as the source is updated.
World Bank API
↓
Raw JSON (data/raw/)
↓
Pandera Validation
↓
Transformation (drop nulls, YoY calculation)
↓
Processed Parquet (data/processed/)
↓
DuckDB (data/warehouse/unemployment.duckdb)
↓
Analysis and downstream use
The resulting warehouse enables:
- Unemployment trend analysis: Track unemployment rates over time by country
- Country comparisons: Compare unemployment levels across EAC member states
- Year-over-year analysis: Analyze changes in unemployment rates
- Economic research: Study labor market dynamics in East Africa
- Data science modelling: Export data for forecasting or classification models
The pipeline prepares the data; downstream consumers (analysts, researchers, models) use the warehouse as a trusted source.
- External API dependency: Pipeline requires World Bank API access and availability
- No historical snapshots: Raw and processed files are overwritten on each run (no versioned snapshots)
- No automated scheduling: Pipeline runs manually or via Docker Compose, not deployed to a production scheduler
- Local DuckDB storage: Not suitable for multi-user concurrent access or cloud-scale workloads
- No data observability platform: No monitoring, alerting, or drift detection beyond validation gate
- No incremental loading: Full reload on each run (acceptable for this dataset size)
- CI focused on unit tests: Does not validate end-to-end flow against live data
- Historical versioning: Use DVC or similar to version raw and processed datasets
- Scheduled orchestration: Deploy Prefect flow to Prefect Cloud or Airflow for automated periodic runs
- Cloud warehouse: Migrate DuckDB to Snowflake, BigQuery, or Redshift for multi-user access
- Data observability: Integrate with data quality monitoring tools (e.g., Great Expectations, Monte Carlo)
- Incremental loading: Implement watermark-based incremental updates if historical data becomes important
- API caching: Cache raw responses to reduce API calls and enable offline reprocessing
| Layer | Technology | Purpose |
|---|---|---|
| Language | Python 3.11 | Pipeline implementation |
| Data ingestion | requests |
Fetch data from World Bank API |
| Validation | pandera |
Schema enforcement and data quality gate |
| Transformation | pandas, pyarrow |
Feature engineering and Parquet output |
| Storage | DuckDB |
Local analytical warehouse |
| Orchestration | Prefect |
DAG-based workflow orchestration |
| Containerization | Docker, docker compose |
Reproducible runtime environment |
| Testing | pytest |
Unit and integration tests |
| CI | GitHub Actions |
Automated code validation on push/PR |
Data source: World Bank Open Data API — Indicator SL.UEM.TOTL.ZS (Unemployment, total % of total labor force) (modeled ILO estimate).
This project uses the World Bank data for educational and portfolio purposes. The pipeline implementation is original; the underlying data remains the property of the World Bank.
This project is licensed under the MIT License — see the LICENSE file for details.