Rebuilding GeoThai: From Reformatted Data to Checked Sources
My Thai address dataset was someone else's data in four new formats. I rebuilt it from official sources, checked every postal code against independent sources, and kept a reason for each decision. The postal codes were the hard part.
view .mdopen in claudeopen in chatgpt
GeoThai gives you the provinces, districts, subdistricts and postal codes of Thailand as JSON, an npm package and a Python package. The npm package had more than 9,000 downloads in a year.
In October I looked at where the data came from. The answer was uncomfortable. The data repository held four versions of one dataset, and every version came from another open-source repository. I changed the format. I never checked the values.
This post covers the rebuild: what the official sources gave me, where they disagreed, and the small system that now decides each disagreement.
One Dataset Carries Its Own Errors
A single dataset can be wrong in ways that nobody sees. Nothing looks broken. Each row has a code, a name and a postal code. The error shows up only when a second source says something different.
So the plan had one rule: every value comes from a named source, and other sources check it. The build has five steps:
fetch download each source, keep the raw file and its SHA-256
parse one adapter per source
reconcile join on the official code, apply corrections with a reason
check structural rules: one code per area, child codes start with the parent code
diff compare with the last snapshot, write what changed and why
The output is one JSON Lines file per level, a manifest.json with each source URL, hash and license, and a CHANGES.md.
The Base: DOPA
The Department of Provincial Administration (DOPA, กรมการปกครอง) owns the codes. It publishes one spreadsheet, ccaatt.xlsx, with every code, the Thai and English names, and the date each abolished area stopped.
The first problem was not the data. DOPA’s DNS fails outside Thailand, so GitHub Actions did not find the host. The build now fetches DOPA and Thailand Post through a proxy with an exit in Thailand.
The first comparison was a relief. All 77 provinces, 928 districts and 7,436 subdistricts had the same codes as the old data. The names were different:
- The 50 Bangkok districts start with “เขต” in the official file.
- DOPA’s English names differ from the old data in 2 provinces, 58 districts and 636 subdistricts.
- One old name had a typo: “ธงชัยหนือ” for “ธงชัยเหนือ”.
The official file also gave me something new: 80 abolished districts and 1,503 abolished subdistricts, each with a date.
Postal Codes Are the Hard Part
DOPA does not publish postal codes. Thailand Post owns them, and it publishes them in a PDF booklet from 2015. No official machine-readable list exists.
The old data had one postal code per subdistrict. That is wrong for many places. A post office zone does not follow subdistrict lines. One subdistrict can use the code of its district, plus a second code for a few villages near another post office.
I tried a simple rule first: take three sources, and two that agree win. That rule failed in Pak Phanang. The base source and a second open dataset gave 80140 for seven subdistricts. The booklet, a third dataset, Wikipedia and an open post office all gave 80370. Two of three was the wrong answer, because the two sources that agreed probably came from the same origin. Agreement between copies is not evidence.
So the build uses more sources, each with a role:
| Source | Role |
|---|---|
| thailand-geography-json | Base postal codes, one per subdistrict |
| Thailand Post booklet (2015) | District codes and 619 exceptions for parts of a subdistrict |
| Thai Wikipedia postal code list | District codes, exceptions, and codes that Thailand Post merged |
| Thailand Post office lists | Which codes still have an open office |
| DOPA population file, every year | Subdistrict codes and names, as a check |
A check against Wikipedia and the office list found the main error type of the base source. It put a village-level code on the whole subdistrict, and it did not know about merged codes. For example, 50210 is now 50290, and 24180 is now 24130.
Wikipedia is not always right either. For Si Somdet district it gives 45260. No post office uses 45260, and the four licensed offices in that district use 45000. The build keeps 45000. When sources disagree, a physical office is the strongest evidence I have.
Today 160 subdistricts have more than one postal code.
A PDF Parser That Gave Two Answers
I read the booklet with pdftotext. On my machine it found 581 exceptions. On the Ubuntu runner it found 540, because the two machines had different versions of poppler.
The fix was to stop parsing the PDF in the build. A separate extract command writes the parsed text and the result to the repository, pinned to the SHA-256 of the PDF. The build reads that result. If Thailand Post publishes a new booklet, the hash changes and I run extract again by hand.
A Ledger, Not Overrides
A normal override file says “use this value”. That has a silent failure: if the source fixes its error, or changes the value again, the override hides it.
Each decision in GeoThai is an entry in corrections.json, with a why:
| Key | Meaning |
|---|---|
drop |
Remove a row from the source |
fix |
Replace a value. was pins the source value. If the source changes, the build stops |
accept |
Keep our value. It pins both values. If either changes, the item opens again |
A disagreement between sources does not fail the build. It goes into review.md. CI fails while that queue has open items. Today the ledger has 1 drop, 195 fixes and 290 accepts, and the queue is empty.
The one drop is a row in the DOPA file with a code for province 97, which does not exist.
Only Real Changes
The build writes canonical/ only when the output changes. A source that publishes the same data with a new timestamp produces no commit. When the output does change, CHANGES.md names the cause in a full sentence: the source changed, a correction changed, or the code changed.
A scheduled workflow runs the build every Monday and opens a pull request with CHANGES.md as the body. After a merge, a publish workflow writes the public files to GeoThai/data, tags a release with the date (v2026.10.23), and tells the npm package, the Python package and the website to update. One day holds one release.
Successors for Abolished Areas
An old address can carry a code that no longer exists. So each abolished area can now list the area that took its place. 1,182 of the 1,583 abolished areas have one, from two kinds of evidence:
- 111 come from Royal Gazette (ราชกิจจานุเบกษา) notices that I read by hand. Each entry keeps the notice and its date in force.
- 1,071 are codes from DOPA’s 2006 cleanup, matched by Thai name to the one active subdistrict with that name in the same province.
The date in force needs care. A notice that takes effect “N days after publication” does not count the day of publication, under section 193/3 of the Civil and Commercial Code.
401 abolished areas have no successor yet.
Maps on the Same Codes
Clean codes made one more thing possible: maps. GeoThai/boundaries puts the boundary lines from OCHA COD-AB (from the Royal Thai Survey Department, กรมแผนที่ทหาร) on DOPA codes. The build links each shape to the active subdistricts it covers. A shape with an old code goes to its successor from a notice. Any other gap stops the build.
On top of that, @geothai/maps draws Thailand as SVG, with adapters for React, Vue and Svelte:
import { provinces, render, toSvg } from "@geothai/maps";
const svg = toSvg(render(provinces, { fills: { 10: "#ffcf3f" } }));
The website uses it on every province, district and subdistrict page. The maps get their own post.
What I Learned
- Reformatting data is not owning it. I owned the format and nothing else.
- Agreement means little if the sources share an origin. Count independent sources, not sources.
- Pin the value you correct. Otherwise a correction hides the next upstream change.
- A parser that depends on a system tool is not reproducible. Commit its output and pin it to the input hash.
- A real-world fact, like an open post office, beats any dataset.
The data is on GeoThai/data, the packages are @geothai/core and @geothai/maps on npm and geothai on PyPI, and you can browse every area on geothai.fasu.dev.