Part of a Data Solution
- Explore →
Postal Codes Solution
Every worldwide postal-code dataset in one bundle — the ultimate global reference for precise postal codes.
Premium
You're viewing a free sample
Get the complete dataset — all rows and files — delivered instantly after checkout, with lifetime access to the latest version.
- Secure checkout via Stripe
- Instant download after payment
- Lifetime access to the latest version
- Creative Commons Attribution 4.0 International license
API Access
Access dataset files directly from scripts, code, or AI agents.
Browse dataset files
API Access
Access dataset files directly from scripts, code, or AI agents.
Each file has a stable URL (r-link) that you can use directly in scripts, apps, or AI agents. These URLs are permanent and safe to hardcode.
Start with these files — they give you everything you need to understand and access the dataset.
- 1. Fetch datapackage.yaml to inspect schema and resources
- 2. Download data resources listed in datapackage.yaml
- 3. Read README.md for full context
Data Files
postal-codes-es-sample
| Field | Type | Description | Constraints | Title |
|---|---|---|---|---|
| country_code | string | ISO 3166-1 alpha-2 country code — always `ES` | { "pattern": "^ES$", "required": true } | Country Code |
| postal_code | string | Five-digit Spanish postal code (código postal). Leading zeros are significant: the first two digits are the INE province code 01-52. | { "pattern": "^(0[1-9]|[1-4]\\d|5[0-2])\\d{3}$", "required": true } | Postal Code |
| place_name | string | Municipality name in natural reading order | { "required": true } | Place Name |
| admin_name1 | string | Autonomous community (comunidad autónoma) | { "required": true } | Administrative Name 1 |
| admin_code1 | string | ISO 3166-2:ES autonomous-community code, ES- prefix stripped | { "pattern": "^[A-Z]{2}$", "required": true } | Administrative Code 1 |
| admin_name2 | string | Province (provincia) | { "required": true } | Administrative Name 2 |
| admin_code2 | string | INE province code (CPRO), 01-52 | { "pattern": "^\\d{2}$", "required": true } | Administrative Code 2 |
| admin_name3 | string | Municipality (municipio) | { "required": true } | Administrative Name 3 |
| admin_code3 | string | INE municipality code (CPRO+CMUN), five digits | { "pattern": "^\\d{5}$", "required": true } | Administrative Code 3 |
| latitude | number | Representative latitude for the postal code, WGS84 / EPSG:4326, decimal degrees, 5 dp | Latitude | |
| longitude | number | Representative longitude for the postal code, WGS84 / EPSG:4326, decimal degrees, 5 dp | Longitude | |
| accuracy | string | Granularity of the coordinate. `POSTAL_CODE` = a point resolved for this specific postal code, not a municipality centroid. `MUNICIPALITY` = a PO-box code with no point of its own: the median of the per-code points in its municipality. Empty where no point exists. | Accuracy | |
| alternative_city_name | string | Pipe-separated aliases: localities inside the municipality, and the municipality's other official-language or register name form | Alternative City Name |
Download
Download sample CSVAbout
- Last updated
- 6 October 2026
- Total rows
- 100
- Format
- CSV
- File size
- 14.6 kB
- Sources
- CartoCiudad, Instituto Geografico Nacional / CNIG (Obra derivada de CartoCiudad 2026 CC-BY 4.0 scne.es) - postal code to municipality relations and per-postal-code coordinates
- Relacion de municipios y sus codigos por provincias, Instituto Nacional de Estadistica (INE), updated 2026-01-01 - Elaboracion propia con datos extraidos del sitio web del INE; the INE neither participates in nor endorses this dataset
- GeoNames (CC BY 4.0) - independent cross-check and locality names; see README.md for the multi-municipality, cross-province-routing and accuracy-tier notes
About this dataset
Spain (ES): migration notes
Internal record of the P5 migration from the old scripts to the producer: the discrepancies found, their causes, and who decided what. This is a dated record, so the numbers are as of the migration and not kept current. It is not shipped in the zip.
- Issue: pc-28e.7.20 (closed 2026-10-03)
- Commit:
e440ce6(worktree commit8946ec5), landed in batch 5 - Parity verdict:
format-only(gate pass); no sign-off needed - Producer:
custom, refresh disabled
Sources
| Input | URL | Notes |
|---|---|---|
CartoCiudad INSPIRE WFS ad:PostalDescriptor (IGN/CNIG, CC BY 4.0) | https://www.cartociudad.es/wfs-inspire/direcciones?service=WFS&version=2.0.0&request=GetFeature&typeNames=ad:PostalDescriptor | Every postal code ↔ INE municipality relation (22 MB). Fetched in pages of 1,000 (count/startIndex, about 13 s each, 3.5 minutes in all), each page retried, and stitched into one sources/pd_all.gml. The single count=16000 request used until 2026-10-06 stalled past 600 s (pc-28e.17). The server reports numberMatched per page, so the total comes from resultType=hits (15,258 on 2026-10-06); the producer stops unless the pages add up to it with no gml:id twice |
| INE "Relación de municipios y sus códigos por provincias" | https://www.ine.es/daco/daco42/codmun/<yy>codmun.xlsx | The year is in the file name and INE's catalogue page is script-driven, so the producer probes this year, then the two before. On 2026-10-03, 27codmun.xlsx was 404 and 26codmun.xlsx was used (last modified 2026-03-13). The resolved URL goes in es.inputs.json |
| GeoNames postal export | https://download.geonames.org/export/zip/ES.zip | Fills municipalities CartoCiudad misses; locality names for alternative_city_name |
CartoCiudad geocoder findJsonp?type=Codpost | https://www.cartociudad.es/geocoder/api/geocoder/findJsonp | One request per distinct WFS postal code (about 10,850), at most 6 at once, about 17 minutes on 2026-10-03. Resumable; answers cached in sources/codpost.jsonl |
CartoCiudad geocoder candidatesJsonp | https://www.cartociudad.es/geocoder/api/geocoder/candidatesJsonp | Resolves PO-box codes whose place name isn't an INE name; cached in sources/candidates.jsonl |
scripts/muni_corrections.csv | committed | 7 hand-made rows, one per recent municipality segregation that neither bulk source reaches, each with its own geocoder evidence string. Kept unchanged from the old builder |
scripts/po_box_codes.csv | committed | New. 288 PO-box / large-customer codes (mostly XX070 / XX071 / XX080, plus installation codes) with the place name each was published under. Derived once, read-only, from the live premium zip's MUNICIPALITY rows on 2026-10-03 |
- The old builder read our own published file.
resolve_po_box_codes.pydownloaded the livees/es.csvfrom R2 with credentials, using it only as the list of PO-box codes that exist. No upstream source lists those codes, and CartoCiudad's bulk layer omits them. The frozenpo_box_codes.csvreplaces that read. - The PO-box place names in the table were reconstructed. The agent took each one as the row's first alternative name once the register form is removed, falling back to the municipality's own name.
- TLS: CartoCiudad's certificate chain fails the stdlib's default CA store on this machine (
self-signed certificate in certificate chain), so the producer fetches withrequests(certifi bundle), not the sharedhttp_get.requestsis inrequirements.txt(checked when landing). - The live zip's
datapackage.jsonhas nosources(stripped on 2026-08-24 by project rule), so the harvest step took them from the repo's old descriptor (sources_from: repo). datapackage.yamlsourcesstill names the CartoCiudad home page and INE's catalogue page (codmun00i.htm) rather than the URLs fetched; the fetched URLs are ines.inputs.json.
Parity against the live zip (2026-10-03, 17:46 UTC)
Rows: 14,538 live and 14,538 fresh, none only on one side. 0 changed cells, no duplicate keys, header unchanged. The only byte difference is that the live CSV quotes every field. A second run with --no-fetch (cached answers) produced a byte-identical CSV.
No upstream drift showed up, even though the coordinates come from a live geocoder crawl. A probe for 01001 before the crawl returned 42.84912 / -2.67239, exactly the live value. The 2026-08-19 licensing note records 148 PO-box rows whose alternative_city_name differed from R2 in an earlier rebuild; none differed this time.
The informational sample check against https://postal.datahub.io/es/es.csv was unexplained. Nobody looked into it.
Decisions
| Decision | By | Basis |
|---|---|---|
Run the per-postcode CartoCiudad geocoder crawl inside produce.py (about 10,850 requests, ≤6 concurrent, resumable, cached in sources/) | User, 2026-10-03 (session d33e1b7e), choosing "Approve the crawl" over "Switch coordinate source" and "Defer es" | The agent stopped at D1 before crawling. 14,250 of 14,538 rows have POSTAL_CODE points from this crawl and the other 288 are medians of them; no bulk source gives the same points |
| Replace the R2 read of our own es.csv with a committed PO-box reference table | User, 2026-10-03 (session d33e1b7e), choosing "Commit as reference table" over "Drop them" (−288 rows) and "Keep the R2 read" | The old read was circular and needed R2 credentials |
| Derive that table from the cached live zip, codes and place names only; no R2 credential reads in the producer; document it as frozen | Orchestrator instruction | |
Include the PO-box candidatesJsonp calls in the crawl | Orchestrator instruction | |
Resolve the INE file by year instead of hard-coding 26, and record the resolved URL | Orchestrator instruction; "this year, then the two before" is the agent's | |
Keep muni_corrections.csv as a committed reference table | Agent; the orchestrator's instruction kept it alongside the new table. Not put to the user | Hand-picked evidence (an unaccented "Vencillon", "Fornes, Arenas del Rey") can't be re-derived automatically |
Fetch with requests instead of the shared stdlib http_get | Agent | stdlib TLS fails on CartoCiudad |
| Parse the xlsx with zipfile + ElementTree | Agent | openpyxl isn't installed |
For the geocoder query "place, province", take the province term from GeoNames' most common admin_name2 for that postal prefix, falling back to INE's | Agent; flagged to the orchestrator as a choice that could be questioned | The old published file's province names can't be recovered; parity matches |
| Unresolved PO-box codes are reported and left out | Agent: a faithful port | All 288 resolved on 2026-10-03 |
accuracy description now covers MUNICIPALITY and empty values, not only POSTAL_CODE | Agent | The GeoNames accuracy wording doesn't apply to es |
README Contents table now lists the four files in the zip (it listed es.zip and validation_report.json, which aren't shipped) | Agent | |
| README "no empty cells" claim now says empty where the geocoder has no point | Agent | |
| Remove row, distinct-code, relation, duplicate-key and fill-percentage counts and the "Last updated 2026-08-18" line from README; keep admin-unit counts (8,132 municipalities, 52 provinces, 19 communities) | Orchestrator instruction (P5 brief doc rules, which the brief labels user decisions) |
Transforms carried over
Folded from scrape_postal.py → build_admin.py → build_dataset.py (pass 1) → resolve_po_box_codes.py → fetch_coordinates.py → build_dataset.py (pass 2) → package_delivery.py. The old two-pass ordering trap (running the PO-box step before pass 1 silently emptied the PO-box table) is gone.
- INE municipality code = the last 5 digits of the WFS
AD_ADMINUNITNAME_MUN_id; relations deduplicated on the (code, municipality) pair; codes outside01–52dropped (none as of 2026-10). - Communal territories dropped: CartoCiudad files shared land (mancomunidades, parzonerías) under phantom provinces 53/54. The build stops if that would lose a postal code.
- GeoNames fill: an INE municipality with no CartoCiudad relation gets every GeoNames code filed under it whose province prefix agrees.
muni_corrections.csvrows applied; a row is skipped once a bulk source covers its municipality.- The postal prefix is the serving post office, not always the province (Condado de Treviño carries 01xxx);
admin_code2/admin_name2stay the municipality's own INE province. - INE names with a trailing article ("Coruña, A") put in natural order, only for a real article.
admin_code1= ISO 3166-2:ES community code withES-stripped;admin_code2= INE CPRO.alternative_city_name: GeoNames localities for this (code, municipality), minus the municipality's own name and names of other municipalities in the same provinces; then the literal register form if it was inverted; then each half of a bilingual name. Deduplicated ignoring accents and case, pipe-joined.- PO-box codes not in the WFS layer: municipality by place-name match against INE within the province (all spellings), else the Palma de Mallorca → Palma (07040) alias, else the first geocoder candidate in that province.
- Geocoder
qtakes the padded code andidthe integer form (the padded id gives HTTP 500 for provinces 01–09). - Coordinates rounded to 5 dp;
POSTAL_CODEfor a geocoded point. A PO-box code gets the median of its municipality's per-code points,MUNICIPALITY; blank if none. - Sorted by (postal_code, admin_code3); primary key (postal_code, admin_code3) checked unique.
- Old asserts are now
ProducerErrors: unknown CPRO, duplicate INE code, all 52 provinces and 19 communities present, corrections and PO rows name a known municipality and carry evidence, WFS not truncated, no code lost by the communal drop. No row-count asserts. - Old scripts,
scripts/README.md,delivery_README_template.mdandpublish_r2.pyremoved. ATTRIBUTION.txt unchanged.
Open questions
- New PO-box codes won't appear by themselves.
po_box_codes.csvis a frozen copy of our own 2026-08 output, so a code Correos adds later stays out until someone edits the table. This is what the user approved; it's listed here because no refresh route exists. - Resolved (user, 2026-10-05, pc-28e.7.59): the geocoder province term from GeoNames'
admin_name2(INE fallback) is accepted. - Crawl cost on every full rebuild. Each fetch (not
--no-fetch) re-runs about 10,850 geocoder requests. Refresh is off, so this only happens on manual rebuilds. .licensing-notes.mdstill describes the old scripts (two-passbuild_dataset.py,resolve_po_box_codes.pyreading R2) and gives row counts. Filed as a comment on pc-hfz.- Admin-unit counts stay in the README by rule; any remaining counts belong to the pc-hfz sweep.
Provenance
Reconstructed 2026-10-05 from the agent transcript d33e1b7e-5027-498a-a97f-ba914ee2524e/subagents/agent-a959c157213db63f8.jsonl (the D1 stop and the final hand-back), the orchestrator session d33e1b7e-… (the AskUserQuestion answers, the SendMessage to the agent and the landing), the harvest step in session 068ca403-…, commit e440ce6, the bd notes and close reason, and docs/parity/p5.md.