Postal Codes Dataset for Spain, ES

587
Updated:
Files:1
Size:1.95 MB
Rows:14,538
Formats:csv
License:CC-BY-4.0

Postal Codes Dataset for Spain, ES including name of the city, town, or place, various administrative divisions and alternative city names.

Part of a Data Solution

  • Postal Codes Solution

    Every worldwide postal-code dataset in one bundle — the ultimate global reference for precise postal codes.

    Explore →

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
20% off
$99.90$79.92
one-time payment

API Access

Access dataset files directly from scripts, code, or AI agents.

Browse dataset files
Dataset Files

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.

/logistics/postal-codes-es/
https://datahub.io/logistics/postal-codes-es/_r/-/.licensing-notes.md
https://datahub.io/logistics/postal-codes-es/_r/-/.migration-notes.md
https://datahub.io/logistics/postal-codes-es/_r/-/ATTRIBUTION.txt
https://datahub.io/logistics/postal-codes-es/_r/-/README.md
https://datahub.io/logistics/postal-codes-es/_r/-/datapackage.yaml
Key Files

Start with these files — they give you everything you need to understand and access the dataset.

datapackage.yaml— metadata & schema
https://datahub.io/logistics/postal-codes-es/_r/-/datapackage.yaml
README.md— documentation
https://datahub.io/logistics/postal-codes-es/_r/-/README.md
Typical Usage
  1. 1. Fetch datapackage.yaml to inspect schema and resources
  2. 2. Download data resources listed in datapackage.yaml
  3. 3. Read README.md for full context

Data Files

postal-codes-es-sample


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 commit 8946ec5), landed in batch 5
  • Parity verdict: format-only (gate pass); no sign-off needed
  • Producer: custom, refresh disabled

Sources

InputURLNotes
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:PostalDescriptorEvery 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.xlsxThe 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 exporthttps://download.geonames.org/export/zip/ES.zipFills municipalities CartoCiudad misses; locality names for alternative_city_name
CartoCiudad geocoder findJsonp?type=Codposthttps://www.cartociudad.es/geocoder/api/geocoder/findJsonpOne 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 candidatesJsonphttps://www.cartociudad.es/geocoder/api/geocoder/candidatesJsonpResolves PO-box codes whose place name isn't an INE name; cached in sources/candidates.jsonl
scripts/muni_corrections.csvcommitted7 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.csvcommittedNew. 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.py downloaded the live es/es.csv from 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 frozen po_box_codes.csv replaces 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 with requests (certifi bundle), not the shared http_get. requests is in requirements.txt (checked when landing).
  • The live zip's datapackage.json has no sources (stripped on 2026-08-24 by project rule), so the harvest step took them from the repo's old descriptor (sources_from: repo).
  • datapackage.yaml sources still names the CartoCiudad home page and INE's catalogue page (codmun00i.htm) rather than the URLs fetched; the fetched URLs are in es.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

DecisionByBasis
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 tableUser, 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 frozenOrchestrator instruction
Include the PO-box candidatesJsonp calls in the crawlOrchestrator instruction
Resolve the INE file by year instead of hard-coding 26, and record the resolved URLOrchestrator instruction; "this year, then the two before" is the agent's
Keep muni_corrections.csv as a committed reference tableAgent; the orchestrator's instruction kept it alongside the new table. Not put to the userHand-picked evidence (an unaccented "Vencillon", "Fornes, Arenas del Rey") can't be re-derived automatically
Fetch with requests instead of the shared stdlib http_getAgentstdlib TLS fails on CartoCiudad
Parse the xlsx with zipfile + ElementTreeAgentopenpyxl 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'sAgent; flagged to the orchestrator as a choice that could be questionedThe old published file's province names can't be recovered; parity matches
Unresolved PO-box codes are reported and left outAgent: a faithful portAll 288 resolved on 2026-10-03
accuracy description now covers MUNICIPALITY and empty values, not only POSTAL_CODEAgentThe 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 pointAgent
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 outside 01–52 dropped (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.csv rows 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_name2 stay 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 with ES- 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 q takes the padded code and id the integer form (the padded id gives HTTP 500 for provinces 01–09).
  • Coordinates rounded to 5 dp; POSTAL_CODE for 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.md and publish_r2.py removed. ATTRIBUTION.txt unchanged.

Open questions

  • New PO-box codes won't appear by themselves. po_box_codes.csv is 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.md still describes the old scripts (two-pass build_dataset.py, resolve_po_box_codes.py reading 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.