Mexican postal codes from Python
Python usually reaches this API from a script rather than a request: a file of customer addresses that needs an estado and a municipio against every row. That changes what matters — nobody is watching, so the failures have to be told apart in code.
Python 3.14 · requests 2.34 · run on 2026-08-17
A session, not a request per row
A Session keeps the connection pool and the headers in one place, which for a few thousand rows is the difference between one TLS handshake and a few thousand of them. Mount a retry policy on it and transient network trouble stops being your problem.
postalkit.py
import os
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
BASE = os.environ.get("POSTALKIT_BASE", "https://api.postalkit.mx")
class QuotaExhausted(RuntimeError):
"""The monthly allowance is gone. Waiting will not help."""
class RateLimited(RuntimeError):
"""The per-minute burst limit. Waiting the given seconds will."""
def __init__(self, retry_after: int) -> None:
super().__init__(f"Rate limited, retry in {retry_after}s")
self.retry_after = retry_after
def client(key: str) -> requests.Session:
session = requests.Session()
session.headers.update(
{"Authorization": f"Bearer {key}", "Accept": "application/json"}
)
session.mount(
"https://",
HTTPAdapter(
max_retries=Retry(
total=3,
backoff_factor=0.5,
status_forcelist=[502, 503, 504],
allowed_methods=["GET"],
)
),
)
return session
def postal_code(session: requests.Session, code: str) -> dict | None:
response = session.get(f"{BASE}/v1/postal-codes/{code}", timeout=5)
if response.status_code == 404:
return None
if response.status_code == 429:
if response.headers.get("X-RateLimit-Remaining") == "0":
raise QuotaExhausted(response.json()["message"])
raise RateLimited(int(response.headers.get("Retry-After", 60)))
response.raise_for_status()
return response.json()["data"]
The two exception classes are the point of this file. Both limits answer 429 and a script cannot ask anyone which one it hit, so it has to read the headers: the burst limit sends Retry-After and no rate-limit headers, and the monthly quota sends X-RateLimit-Remaining set to zero and no Retry-After. One is a pause. The other is the end of the run, and a retry loop that cannot tell them apart will sit there until you kill it.
Note what is not in status_forcelist. Putting 429 in there hands the decision to urllib3, which will happily retry a quota that has nothing left to give.
Enriching a file of addresses
The single highest-value line in this section is the dictionary. A customer list is not a list of distinct postal codes — a national retailer with fifty thousand orders has a few thousand codes between them, and the rest are repeats you would otherwise pay for one at a time.
enrich.py
import time
from postalkit import RateLimited, client, postal_code
CODES_PER_MINUTE = 55 # The burst limit is 60. Leave yourself room.
def enrich(session, rows):
cache: dict[str, dict | None] = {}
started = time.monotonic()
calls = 0
for row in rows:
code = row["postal_code"].strip().zfill(5)
if code not in cache:
if calls and calls % CODES_PER_MINUTE == 0:
time.sleep(max(0.0, 60 - (time.monotonic() - started)))
started = time.monotonic()
try:
cache[code] = postal_code(session, code)
except RateLimited as limited:
time.sleep(limited.retry_after)
cache[code] = postal_code(session, code)
calls += 1
record = cache[code]
row["state"] = record["state"]["name"] if record else ""
row["municipality"] = record["municipality"]["name"] if record else ""
yield row
Run over five rows containing three distinct codes, that makes three requests. A row whose code is not in the catalog gets empty columns rather than an exception, which is what you want in a batch — one bad address should not end the job.
zfill(5) earns its place. A CSV that has been through a spreadsheet has almost certainly lost the leading zero from every code in Mexico City, and 6600 read back as 06600 is the difference between the Cuauhtémoc borough and nothing at all.
Before you run it on the whole file
Three things worth doing first
-
Count the distinct codes before you start.
It is one line with a set, and it tells you whether the run fits in the allowance you have. Finding that out at row forty thousand is a worse way to learn it.
-
Keep the codes as text in pandas too.
read_csv will infer an integer column and drop the zeros before you have written a line of your own. Pass dtype={"postal_code": str} and the problem never starts.
-
Persist the cache if the job will run again.
The dictionary above lives for one run. The catalog is re-checked monthly and rarely moves, so a JSON file or a table beside your data means the second run costs almost nothing.