Every time you upload a photo to Immich, the system automatically tags where it was taken, say “Xinyi District, Taipei” or “Shibuya, Tokyo”. That is not the work of a cloud API. It is a reverse geocoding system that runs entirely offline.
Because it runs offline, there is room for a project like immich-geodata-zh-tw to exist (for the actual installation steps, see the illustrated setup guide in the first post of this series). Immich reads place names from a handful of plain text files, so replacing those files changes the place names it displays.
This is the first technical post in the series, and it lays the groundwork: what actually happens when Immich resolves a place name, which files it reads, and what room that mechanism leaves for us to work with. Everything in the later posts, the per-country strategies, the translation work, the validation, builds on this.
How Immich’s Reverse Geocoding Works#
To understand what this project can do, you first need to see the shape of Immich’s mechanism. The whole system depends entirely on a local offline database and never calls a cloud API.
The process splits into two stages that happen at different times:
- At container startup: a few plain text geodata files get imported into PostgreSQL and a spatial index is built. This step only reruns when the data content changes.
- At photo upload: the photo’s GPS coordinates are used to find the nearest point in the database, and its field values are read out and assembled into an address string.
There is no boundary matching, no external service lookup, and no validation step. It finds the closest point and copies its fields.

The Query: From Coordinates to a Place Name#
When you upload a photo carrying GPS coordinates (25.033, 121.565, for example), Immich uses a nearest neighbor query to pick out “Xinyi District, Taipei” from 200,000 records. In short, Immich will:
- Convert the photo’s latitude and longitude into 3D spherical coordinates
- Find the closest location in the
geodata_placestable - Pull that location’s Country, Admin1, and City fields
- Assemble the final address string
Here is Immich’s actual query logic, simplified:
// simplified query logic
this.db
.selectFrom('geodata_places')
.selectAll()
.where(
sql`earth_box(ll_to_earth_public(${point.latitude}, ${point.longitude}), ${reverseGeocodeMaxDistance})`,
'@>',
sql`ll_to_earth_public(latitude, longitude)`,
)
.orderBy(
sql`(earth_distance(ll_to_earth_public(${point.latitude}, ${point.longitude}), ll_to_earth_public(latitude, longitude)))`,
)
.limit(1)This logic lives in map.repository.ts in the official Immich repository. This article is based on the main branch as of late 2025; the implementation may shift as Immich evolves, and the link deliberately omits line numbers so it does not go stale.
The key functions:
ll_to_earth_public(lat, lng): converts latitude and longitude into 3D spherical coordinates (based on an earth ellipsoid model)earth_box(point, radius): builds a search window centered on that point with the given radiusearth_distance(): computes the actual spherical distance between two pointsreverseGeocodeMaxDistance: the upper bound on the search radius. If no location exists within that distance, the query comes back empty and the photo gets no location info. This is exactly why “increasing data density” matters
The query first narrows the search window with earth_box, then sorts precisely with earth_distance, and finally returns the single closest record.
Once You Understand the Query, You Can Game It#
Reading through Immich’s query logic surfaces one crucial point: the entire reverse geocoding system depends purely on the contents of cities500.txt and its companion files. Immich never validates whether a place name is correct, and it never goes online to check. It simply finds the nearest point and reads the field values.
That means we can change the contents of these files to:
- Increase data density: add finer-grained locations to
cities500.txt(villages in Taiwan, municipalities in Japan, and so on) - Improve place name quality: replace the
namefield with accurate Traditional Chinese instead of English - Refine translation logic: handle Simplified-to-Traditional conversion, variant character normalization, and similar issues
That is the core strategy of immich-geodata-zh-tw: rebuild these files from each country’s official geodata, then swap out Immich’s defaults.
Which Files Immich Reads#
As mentioned above, geodata gets imported at startup. There are four files in total, mostly from GeoNames, an open geographical database (its maintainers claim over 11 million geographical points).
| File | Purpose | How this project handles it |
|---|---|---|
cities500.txt | Primary data of location coordinates and names | Merges in points generated from each country’s official geodata and rewrites the names |
admin1CodesASCII.txt | Maps first-level administrative division codes to names | Replaced with localized names to stay in sync |
i18n-iso-countries/langs/en.json (before v3.3.0)countryInfo.txt (v3.3.0 onwards) | Maps country codes to country names | Country names swapped for Traditional Chinese (see below) |
geodata-date.txt | Decides whether to reimport | Content updated with every release |
Here is each one in turn.
admin1CodesASCII.txt: First-Level Administrative Division Names#
Format: country code.division code TAB name TAB ASCII name TAB geoname_id
For example:
TW.03 Taiwan Province Taiwan Province 1668284This file is used to turn the admin1_code in cities500.txt into an actual administrative division name.
cities500.txt: The Core Geographical Coordinate Database#
This is the heart of Immich’s reverse geocoding, and it is the GeoNames cities500 dataset: settlements and administrative centers with a population of 500 or more, roughly 200,000 records (the count shifts as GeoNames updates). The file is tab-separated (TSV), with each line representing one geographical point across 19 fields:
geoname_id name asciiname alternatenames latitude longitude feature_class feature_code country_code cc2 admin1_code admin2_code admin3_code admin4_code population elevation dem timezone modification_dateImmich imports this data into the PostgreSQL geodata_places table. At query time the system relies on latitude and longitude to locate the nearest place, then assembles a full address from the name, country_code, admin1_code, and related fields.
Country Name Mapping#
Immich uses this mapping to convert a country code such as TW into a country name, and it reads only one fixed file, so the language of country names is unaffected by the interface language.
immich-geodata-zh-tw takes advantage of this by replacing the country names in that file with Traditional Chinese, so Immich displays 臺灣 rather than “Taiwan”. The details are covered later in the section “Country Names: Working Around an Immich Limitation”.
geodata-date.txt: Deciding Whether to Reimport#
This is a single-line text file containing nothing but a timestamp. Immich compares the file’s contents against the reverse-geocoding-state record in the database, and only reimports the data when the two differ.
Note that the comparison is on content equality, not on the file’s modification time and not on which date comes first. So to force Immich to reimport, changing the content to any different value works, including an earlier date.
admin2Codes.txtadmin2Codes.txt holds second-level administrative division data. This project does nothing to it and keeps the original file only to preserve the same file structure. That is sufficient in practice, because the place name fields Immich displays come from cities500.txt itself and never touch admin2Codes.txt.

Country Names: Working Around an Immich Limitation#
That covers where place names (cities and administrative divisions) come from. What remains is country names (TW → 臺灣). They do not go through the cities500.txt processing flow; Immich looks them up in a separate file. So the approach differs too: we leave the points alone and simply swap the file used for that lookup.
The Immich Limitation#
As noted in the file list above, the country name mapping comes from a single fixed source, even when the user interface language is set to Traditional Chinese. That is a design decision inside Immich, and we cannot change the behavior from the outside.
In theory that means country names will forever display in English: Taiwan, Japan, South Korea.
That fixed source changed once, in v3.3.0. Before that, Immich read the i18n-iso-countries package, whose ISO official names make Taiwan display as “Taiwan, Province of China”. Upstream switched to GeoNames’ countryInfo.txt in immich-app/immich#30199, and the same country became the more neutral “Taiwan”.
Changing the source did not change the limitation, though: Immich still displays only what that file says.
The Workaround: Swap the Country Names in the File#
Since Immich displays whatever it reads, we can simply replace the country names in the file with Traditional Chinese. Before v3.3.0 the file to change is en.json; from v3.3.0 onwards it is countryInfo.txt. The two approaches differ slightly.
Rewriting en.json#
This approach has to fool Immich: replace the contents of en.json with Traditional Chinese.
{
"locale": "en", // ← locale stays "en", which is what makes Immich read this file
"countries": {
"TW": "臺灣", // ← but the value is already Traditional Chinese
"CN": "中國",
"JP": "日本",
"KR": "南韓",
"US": "美國",
"GB": "英國"
// ... roughly 250 countries and regions, all in Traditional Chinese
}
}With that in place, Immich reads en.json and gets Traditional Chinese country names.
Rewriting the country name column of countryInfo.txt#
This approach does not need the disguise of a locale marked “en” with Chinese content. countryInfo.txt is GeoNames’ country table, one country per line, and the country name is just one column, so we replace it directly. Immich reads only three columns: the alpha-2 country code in column 1, the alpha-3 country code in column 2, and the country name in column 5.
# Original
TW TWN 158 TW Taiwan Taipei 35980 ...
# Rewritten
TW TWN 158 TW 臺灣 Taipei 35980 ...The project replaces only column 5 and preserves every other column and the header comments byte for byte. That way, even if upstream starts reading another column later, our edits will not break anything.
Whichever file Immich reads, the location line users see in their library becomes 臺灣 · 臺北市 · 信義區 instead of “Taiwan · Taipei City · Xinyi District”. The project’s release ships both files, so both versions of Immich work.
Authoritative Translation Sources#
To keep the translated names correct, these Traditional Chinese renderings follow data published by the Taiwanese government:
- Country and region names published by the Ministry of Foreign Affairs of the Republic of China
- Country and region name mappings from the International Trade Administration, Ministry of Economic Affairs
The full source list and license notices are in the project’s NOTICE.md.
They are then fine-tuned to match the usage Taiwanese readers expect. For example:
- 臺灣 rather than 台灣 (following official government orthography)
- 南韓 rather than 韓國 (the wording used by Taiwanese media and in everyday speech)
- 阿拉伯聯合大公國 rather than 阿聯酋 (the official International Trade Administration name)
We still keep zh-tw.json as the Traditional Chinese reference baseline for country names.
Where These Files Come From#
At this point the mechanism is fully covered: Immich reads those files, finds place names with a nearest neighbor query, and assembles the fields into an address. Only one question remains: where the contents of those files come from.
They are produced by a Rust CLI whose processing splits into two tracks:
extract: converts one country’s official geodata (Shapefile, GeoJSON, or an official API response) into an intermediate CSV containing administrative division names and computed representative coordinates. It runs once per country that has dedicated processing logic.release: merges intermediate CSVs back into the original GeoNames files, handles ID allocation, translation, and point pruning, and finally packages everything intorelease.tar.gz. This track consists of seven stages:cleanup,prepare,enhance,locationiq,translate,prune, andpack.
The next post takes both tracks apart in full: what each of the seven stages does, why every one of them has to be runnable on its own, how IDs are allocated so they never collide, and how to validate a pipeline that depends on a paid API.
References#
- immich-geodata-cn README - Detailed documentation of the GeoNames file formats
- Analysis of Immich Reverse Geocoding - A similar project’s author analyzing the query mechanism
- GeoNames Documentation - Official file format documentation
- immich-geodata-zh-tw project documentation - Per-region processing details and development notes