Saltar al contenido

Códigos postales de México desde Python

Python suele llegar a esta API desde un script y no desde una petición: un archivo de direcciones de clientes al que le falta un estado y un municipio en cada renglón. Eso cambia lo que importa: nadie está mirando, así que las fallas hay que distinguirlas en el código.

Python 3.14 · requests 2.34 · ejecutado el 2026-08-17

Una sesión, no una petición por renglón

Una Session mantiene el pool de conexiones y los encabezados en un solo lugar, lo que para unos miles de renglones es la diferencia entre un handshake TLS y unos miles de ellos. Móntale una política de reintentos y los problemas de red pasajeros dejan de ser tu problema.

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"]

Las dos clases de excepción son el punto de este archivo. Los dos límites responden 429 y un script no puede preguntarle a nadie con cuál se topó, así que tiene que leer los encabezados: el límite por minuto manda Retry-After y ningún encabezado de cuota, y la cuota mensual manda X-RateLimit-Remaining en cero y ningún Retry-After. Uno es una pausa. El otro es el fin de la corrida, y un ciclo de reintentos que no los distingue se va a quedar ahí hasta que lo mates.

Fíjate en lo que no está en status_forcelist. Meter 429 ahí le entrega la decisión a urllib3, que con todo gusto va a reintentar una cuota que ya no tiene nada que dar.

Enriquecer un archivo de direcciones

La línea más valiosa de esta sección es el diccionario. Una lista de clientes no es una lista de códigos postales distintos: una cadena nacional con cincuenta mil pedidos tiene unos cuantos miles de códigos entre todos, y el resto son repeticiones que si no pagarías de una en una.

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

Corrido sobre cinco renglones con tres códigos distintos, eso son tres solicitudes. Un renglón cuyo código no está en el catálogo recibe columnas vacías en lugar de una excepción, que es lo que quieres en un lote: una dirección mala no debería terminar el trabajo.

zfill(5) se gana su lugar. Un CSV que pasó por una hoja de cálculo casi seguro perdió el cero inicial de todos los códigos de la Ciudad de México, y leer 6600 de vuelta como 06600 es la diferencia entre la alcaldía Cuauhtémoc y nada.

Antes de correrlo sobre el archivo completo

Tres cosas que vale la pena hacer antes

  • Cuenta los códigos distintos antes de empezar.

    Es una línea con un set, y te dice si la corrida cabe en la cuota que tienes. Enterarte en el renglón cuarenta mil es una peor forma de averiguarlo.

  • Mantén los códigos como texto también en pandas.

    read_csv va a inferir una columna entera y a tirar los ceros antes de que hayas escrito una línea propia. Pásale dtype={"postal_code": str} y el problema nunca empieza.

  • Persiste el caché si el trabajo va a correr otra vez.

    El diccionario de arriba vive una sola corrida. El catálogo se revisa cada mes y casi nunca se mueve, así que un archivo JSON o una tabla junto a tus datos hace que la segunda corrida no cueste casi nada.

Pruébalo primero con cien renglones.

100 solicitudes al mes, gratis, sin tarjeta. Eso es una muestra real de casi cualquier archivo de direcciones, ya sin duplicados.

curl -s https://api.postalkit.mx/v1/postal-codes/06600 -H "Authorization: Bearer YOUR_API_KEY"