From raw files to this screen
The data pipeline: Extract, Transform, Load, Check
For every step: the methods we used, how much each one caught, and why. Every count is measured by re-running the cleaning code on the raw files.
Extract
Get the data. Everything is public. Raw files are kept unedited, each logged with its link, date and fingerprint.
144 downloads logged
DOSM Domestic Tourism Survey
File download from OpenDOSM
20 spreadsheets
WhyVisitors, spending and where tourists come from, by state.
data.gov.my
File download
7 tables
WhyPopulation, income, poverty, basic amenities, GDP.
Tourism Malaysia
The public dashboard's own data API
7 tables
WhyHotels, rooms and how full they are.
OpenStreetMap
Overpass API, one query per kind of place
22,872 map points
WhyAttractions, airports, rail stations, hotels, eateries.
Travel web pages
Exa search API, 295 searches
2,950 pages
WhyWhat travellers write, in Malay, English and Chinese.
Video comments
YouTube Data API
9,229 comments
WhyShort first-hand reactions to each state.
Wikivoyage and rainfall
MediaWiki API and Open-Meteo API
83 pages, 37 towns
WhyReal places and ten years of rain for the trip planner.
Transform
Clean the data. Each row is one method: what it does, how much it caught, and why.
18,420 texts → 7,561 real trips
Official tables
Find rows by their label
Search for the row that says "Johor" instead of trusting cell B12.
3,671 cells read
WhyThe sheets are laid out for printing, so positions shift from file to file.
Dashes stay empty
"-" and "n.a." become an empty cell, never 0.
26 cells
WhyA dash means "not published". A zero would mean "nobody came".
One code per state
Every spelling is mapped to one 3-letter code.
21 → 16 spellings
Why"Penang" and "Pulau Pinang" must be the same state, or tables from different agencies cannot be matched.
Remove repeated state-years
Two DOSM editions cover the same years. One copy is kept.
112 of 256 removed
WhyCounting a state twice in one year would double its visitors.
Keep only the rows we need
Drop the rows split by sex, age group or sub-total; keep the "everyone" row.
1,063 of 276,015 kept
WhyEach state and year must appear exactly once, or the join would multiply rows.
Map points inside a state
Each point is placed in the state whose border it falls inside; repeats are dropped.
129 of 22,872 removed
WhyA point out at sea or listed twice cannot be counted for a state.
Travel posts
Skip review sites
Pages from TripAdvisor, Google Maps, Facebook, Instagram and TikTok are not read.
19 of 2,950 pages
WhyTheir terms do not allow reviews to be copied.
Drop junk paragraphs
Menus, cookie notices, "subscribe", headings and one-liners under 40 characters.
89,146 of 150,175
WhyMost of a web page is not travel writing.
Drop very short comments
Comments under 60 characters.
5,146 of 9,229
Why"Nice video!" says nothing about a place.
Remove personal details
Links 1,394, e-mails 317, usernames 877, phone numbers 832.
3,420 removed
WhyPrivacy. We need what was said, not who said it.
Cap long pages
At most 6 passages are taken from one page.
5,259 passages left out
WhyOne very long blog must not drown out a whole state.
Remove exact duplicates
Same text after ignoring capitals, spaces and punctuation.
1,914 of 18,420
WhyA repeated post would count one opinion many times.
Remove near-duplicates
Texts sharing at least 70% of their 5-word runs with an earlier text.
31 more removed
WhyThe same article copied to a second site with a word changed.
AI filter: is this a real trip?
Gemini 2.5 Flash Lite reads each text and answers from fixed lists.
2,956 not a trip
WhyAdverts, news and "I want to go one day" are not experiences.
Ask a second time
The same yes/no question again, more strictly. Kept only on two yeses.
5,861 more removed
WhyOne pass let too much chatter through. Fewer posts, but cleaner.
Wrong-state check
If the place the AI found names a different state, the post is not counted.
64 removed
WhyA search for Sabah that returns a post about Kuala Lumpur must not score Sabah.
Steady small samples
A topic with few mentions is pulled towards the national average.
176 state-topic scores
WhyTwelve posts should not decide whether a state is called safe.
Load
Store the clean tables, join them into one row per state per year, work out the scores, and hand small files to the website.
80 rows × 53 columns
Save as clean tables
Each cleaned source is stored as a typed Parquet table, with a data dictionary.
21 tables, 45,198 rows
WhyFixed column types, so a number can never quietly turn into text.
Match by state code and year
Every clean table is looked up for each of the 16 states. No state is ever dropped.
80 × 53 rows × columns
WhyOne table means every score is worked out from the same numbers.
Carry the latest year forward
If a year is not published, use the most recent earlier year and record which year it was.
272 of 3,840 cells
WhyDOSM's state spending stops at 2023. We say so on screen instead of guessing.
Never fill a gap
A value that was never published stays empty.
98 cells left empty
WhyAn invented number looks as real as a true one. Almost all are 2021 detail DOSM did not publish.
Work out the scores
Gini, Opportunity, Bottleneck Diagnoser, Room to grow, Simulator.
5 scores
WhyAnyone can check by hand why a state ranks where it does. Steps are on the Methodology page.
Ship small files
The final table and scores are exported as JSON with the website. No database.
12 files
WhyNothing to log in to, nothing to go down.
Check
Prove nothing went wrong. If any check fails, the pipeline stops and nothing is published.
110 automatic tests
Range rules
Percentages must be 0-100, counts cannot be negative, nights stayed must be believable.
0 problems in 2,426 values
WhyA value outside its range means we read the file wrongly. The pipeline stops if one appears.
Every state, every year
Each year must hold exactly the 16 states, once each.
0 missing of 224
WhyA missing or doubled state would bend every share and every ranking.
Totals must match the publisher
Our 16 states added up = DOSM's national total; tourists + day-trippers = visitors; state rooms = Tourism Malaysia's total.
0 failed of 46 checks
WhyIt proves nothing was lost or double-counted. 2025: 290,064.7 = 290,064.7 thousand visitors.
Compare the two editions
Where both editions give the same state and year, do the numbers agree?
0 of 112 differ
WhyThey agree everywhere, so dropping the repeated copy loses nothing.
Outlier scan
Flag any yearly jump in visitors far from the usual change (robust z-score above 3.5).
14 of 128 flagged
WhyAll are 2022: travel bouncing back after the pandemic. Real, so kept unchanged. A flag in a normal year would point to an error.
Automatic tests
53 on the data and the maths, 57 on the website.
110 must pass
WhyIf one fails, nothing is published.
Two calculators must agree
The website recalculates every score live; its answers are compared with Python's.
5 decimal places
WhySo the screen can never drift from the pipeline.
Shake the weights
Re-rank the states 1,000 times with random weights.
0.94 of 1 agreement
WhyThe ranking must not depend on the weights we happened to choose.
Counted from the pipeline's files on 2026-09-20.