Project story
Each step below covers the decisions made at that stage, from the source files to the live API.
by Christofer Lindholm · Data Engineering
8 years · 8 Excel files · 9,983 records → PostgreSQL + FastAPI. Swedish vocational education (YH) application rounds, harmonized from inconsistent source data into one clean, queryable dataset.
8 yearly Excel files from 2018-2025. Sheet names, header rows, and columns differ across years.
One column map turns the yearly files into one canonical applications schema with traceable source columns.
Normalize decisions, fix Landsting to Region, coerce numeric values, and derive distance/multi-municipality flags.
Notebook and API checks verify row counts, year coverage, required values, and normalized decision values.
9,983 rows loaded into one typed applications table with indexes for common filters and stats.
17 endpoints expose records, stats, CSV export, refresh, prediction, and SCB municipality enrichment.
Random Forest model estimates approval probability from historical MYH application patterns.
Separate enrichment notebook joins population, employment, and unemployment context from Statistics Sweden.
React, TanStack Query, and Recharts present the API, notebooks, data explorer, ML model, and enrichment layer.
Each step below covers the decisions made at that stage, from the source files to the live API.
Full notebooks including all code and cell outputs. Click any tab to switch notebook.
Excel ingestion, harmonization, cleaning, enrichment, validation, and CSV export.
All charts hit the FastAPI directly, no mock data, no caching layer. If the API is cold-starting on Render's free tier, the data may take up to 60 seconds to appear. Charts will populate automatically.
Volume trend with highlighted peak and latest year
Approved applications divided by total
Approved, rejected, and withdrawn by year
Top counties by application volume
Volume and approval rate by subject area
On-site, distance, and other study forms
Largest providers by application count
Query GET /applications with combinable filters. 50 rows per page, server-side pagination.
All filters except Area query the full dataset server-side. Area filters only the rows on the current page.
| Year | Program | Area | Points | Provider | City | Decision | Form | Exam | Details |
|---|---|---|---|---|---|---|---|---|---|
MYH application activity joined with SCB population and BAS labour-market statistics. The charts use municipality/kommun, not informal city labels.
All MYH years 2018–2025 are aggregated. Each municipality is joined with its most recent SCB snapshot (population stops at 2024, labour-market data at 2024).
Raw totals favor large municipalities. Per-capita values make smaller municipalities comparable.
Employment and unemployment describe the local labour market. They do not prove why MYH approved an application.
| Method | Path | Description |
|---|---|---|
| GET | /pipeline/status | Live data quality checks against the applications table. |
| GET | /health | Database connectivity and row-count check. |
| GET | /applications | List applications with year, decision, region, municipality, provider, and study-form filters. |
| GET | /applications/{diarienummer} | All rows for one MYH case number. |
| GET | /providers | Distinct education provider names. |
| GET | /providers/{name}/applications | Provider search using case-insensitive contains matching. |
| GET | /stats/by-year | Yearly totals and approval rates. |
| GET | /stats/by-region | County statistics; excludes Flera kommuner by default. |
| GET | /stats/by-education-area | Volume and approval rate grouped by education area. |
| GET | /stats/by-study-form | Comparison by Bunden, Distans, and other study forms. |
| GET | /stats/by-provider | Top providers by application count. |
| GET | /stats/multi-municipality | Separate summary for rows spanning several municipalities. |
| GET | /stats/municipality-enrichment | MYH municipality summaries joined with SCB population and labour-market context. |
| GET | /export/applications | Filtered applications as CSV. |
| GET | /export/stats/by-year | Yearly stats as CSV. |
| POST | /predict | ML model: approval probability estimate for a new application. |
| POST | /refresh | Reload the database table from the curated CSV. |
| Tool | Layer | Role |
|---|---|---|
| Python 3.11 | Runtime | Core language for the data pipeline and API |
| pandas | Data | Excel ingestion, harmonization, cleaning, and enrichment |
| openpyxl | Data | Engine for reading MYH .xlsx files |
| SCB PxWeb API | External data | Population and BAS labour-market enrichment snapshot |
| Jupyter | Notebook | Documents the raw-data journey and transformation decisions |
| PostgreSQL 16 | Storage | Stores the curated applications table |
| psycopg2-binary | Database | PostgreSQL adapter — direct connection pooling and parameterized SQL queries |
| FastAPI | API | Read API with filters, statistics, CSV export, and Swagger docs |
| Pydantic | API | Response models for application rows and yearly stats |
| Uvicorn | API | ASGI server for running the FastAPI app |
| python-dotenv | Config | Loads local PG_* settings from .env |
| Docker | Local/Deploy | Containerized API image based on python:3.11-slim |
| Docker Compose | Local | Starts PostgreSQL and API together with seeded data |
| Neon.tech | Cloud DB | Hosted PostgreSQL for the deployed API |
| Render.com | Hosting | Hosts the deployed FastAPI service |
| Ruff | Quality | Python formatter and linter configured in the backend repo |
| Lovable | Frontend | Created and deploys this React dashboard project |
| React 19 | Frontend | UI framework for the dashboard and explorer |
| TanStack Query | Frontend | Live API fetching, retry, and loading state |
| Recharts | Frontend | Gradient trend charts and comparison charts |
| Tailwind CSS v4 | Frontend | Visual styling and responsive layout |
| Claude Code | Assistant | Code assistant and project assistant during development |
| Codex | Assistant | Reviewer of the project plus API/frontend polish and release prep |
27 columns (26 data + id). ? = nullable.
| Column | Type | Notes |
|---|---|---|
| id | int | Surrogate primary key (SERIAL) |
| source_year | int | MYH application round year |
| source_file | text | Original .xlsx filename |
| source_sheet | text | Sheet within the workbook |
| diarienummer | text | MYH case number (not unique pre-2019) |
| utbildningsnamn | text | Program name |
| utbildningsomrade | text? | Education area |
| beslut | text? | Raw Swedish decision value |
| beslut_normalized | text? | approved / rejected / withdrawn |
| lan | text? | County (län) |
| kommun | text? | Municipality |
| yh_poang | int? | YH credits |
| studieform | text? | Form of study |
| studietakt_procent | int? | Pace of study (%) |
| typ_av_examen | text? | Exam type, null for 2018 and 2020 |
| utbildningsanordnare | text? | Education provider |
| huvudmannatyp | text? | Provider type (Privat/Kommun/Region/Statlig) |
| flera_kommuner | text? | Multi-municipality raw value, null for 2018 |
| antal_kommuner | int? | Municipality count, null for 2018 |
| sokta_omgangar | int? | Rounds applied for, null for 2018-2019 |
| beviljade_omgangar | int? | Rounds granted, null for 2018-2019 |
| sun5_inriktning | text? | SUN5 subject code, 2023+ only |
| sun5_inriktning_namn | text? | SUN5 subject name, 2023+ only |
| seqf_niva | text? | SeQF qualification level, 2023+ only |
| smalt_yrkesomrade | text? | Narrow occupational area, 2023+ only |
| is_distance | bool? | Derived: studieform contains 'Distans' |
| has_multiple_municipalities | bool? | Derived: more than one kommun |
Fill in the details of a YH application. The model estimates the probability it would be approved based on 8 years of MYH decisions (2018–2025).
Fill in the details and click Predict.
How it works: A Random Forest model trained on 9,983 MYH applications (2018–2025) predicts based on region, education area, study form, operator type, program size, and year.
Limitation: The model reflects historical patterns only. MYH's actual decisions also weigh program quality, employer demand, and regional need, none of which are in the dataset. Historical average approval rate: ~36%.
Live checks against the applications table, checking row count, year coverage, null constraints, and value integrity. Run against the production database on every page load. The four notebooks also include their own end logs or output summaries for the recorded walkthrough.