Add city and state to a ZIP-code CSV with Python

Enrich a CSV with city, state, and timezone using the Python SDK. Preserve leading zeros, reuse duplicate ZIP lookups, and keep failed rows available for review.

PythonLast tested Get the complete example ↓
On this page

Run the example

Download the complete source below. In a macOS or Linux terminal, install the SDK, set your secret API key, then run the file.

Install
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install parseapi
API key
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'
Run
python3 enrich-zip-code-csv.python.py

Before you start

Build a Python script that reads a CSV with a zip column and adds city, state, and timezone from the Postal API. The original columns and row order stay intact. New fields use a parse_ prefix, so a lookup never overwrites a city or state you already collected.

Use Python 3.9 or later, install the SDK, and set PARSEAPI_KEY with the setup above. Use a secret key from the keys page and run the script on your own machine or server. Only the five-digit ZIP and country=US are sent to parseAPI; other CSV cells stay in the script.

This example handles a small US-only file: up to 200 data rows and 64 KiB. It processes requests sequentially and looks up each distinct ZIP at most once per run. It does not validate a street address or prove that mail can be delivered there.

1. Read ZIP codes as strings

Save a file named customers.csv with this structure:

csv
id,zip,note
1,02108,Boston
2,94107,San Francisco
3,02108-1234,Same five-digit lookup
4,2108,Missing digit
5,,Missing ZIP
6,00000,Unknown ZIP

The standard-library CSV reader keeps cells as text. 02108 stays 02108; converting it to an integer would lose the leading zero. Quoted commas and line breaks are handled by the CSV parser. Files exported with a UTF-8 byte-order mark work too. Python CSV documentation.

Python
def read_rows(stream):
    reader = csv.DictReader(stream, strict=True)
    fields = reader.fieldnames
    if (not fields or "zip" not in fields or any(not field.strip() for field in fields)
            or len(fields) != len(set(fields))):
        raise ValueError("Use distinct, nonempty headers, including a column named zip.")
    if set(fields) & set(COLUMNS):
        raise ValueError("The input already contains output columns. Use the original CSV.")
    rows = []
    for row in reader:
        if None in row or any(value is None for value in row.values()):
            raise ValueError("Every row must contain the same number of cells as the header.")
        rows.append(row)
        if len(rows) > 200:
            raise ValueError("Use a CSV with 1 to 200 data rows for this example.")
    if not rows:
        raise ValueError("The CSV needs at least one data row.")
    return fields, rows

Keep the header zip lowercase. Other columns can have any distinct, nonempty names except the output column names. A missing header, duplicate header, or row with the wrong number of cells stops the script before API calls begin.

The script trims surrounding whitespace only for lookup. It accepts five digits or the 12345-6789 ZIP+4 form. ZIP+4 uses its first five digits for enrichment while the original full value remains in zip. A four-digit value such as 2108 is flagged for review; the script does not guess a missing zero.

2. Look up each distinct ZIP once

The SDK's postal method calls /postal/{code}. Pass country="US" so a numeric code cannot be confused with one in another country.

Python
for row in rows:
    value = row["zip"].strip()
    if not value:
        result = outcome("missing_zip")
    elif not re.fullmatch(r"[0-9]{5}(?:-[0-9]{4})?", value):
        result = outcome("invalid_zip_format")
    else:
        postal = value[:5]
        if postal in cache:
            result = cache[postal]
        elif stopped:
            result = outcome("not_attempted")
        else:
            try:
                result = matched(parse.postal(postal, country="US"), postal)
            except ParseAPIError as error:
                status = "not_found" if error.status == 404 else "error"
                result = outcome(status, error.code, error.status)
                if error.status in (401, 403, 429):
                    stopped = True
            except Exception:
                result = outcome("error", "request_failed")
            # Reuse completed answers and failures for duplicate ZIPs in this run.
            cache[postal] = result
    results.append({**row, **result})

Two rows containing 02108 and 02108-1234 reuse the same five-digit lookup. The cache lasts for this run and includes failed requests, so duplicate rows cannot repeatedly hit a failing service. Original row order and repeated customer records are preserved.

The client uses a ten-second timeout and disables automatic retries. An authentication, access, or rate-limit response stops new ZIP lookups. Rows whose ZIP already has a cached result can still use it; other valid ZIPs become not_attempted. Blank and malformed ZIPs are classified locally without making requests.

3. Separate matches from rows that need review

Only accept a response that echoes the requested ZIP and US country. The city, state, and timezone must each be text or null. In CSV, unavailable values become empty cells.

Python
def matched(data, postal):
    if (not isinstance(data, dict) or data.get("postal") != postal
            or data.get("country") != "US"):
        return outcome("error", "unexpected_response")
    names = ("city", "state", "timezone")
    if any(name not in data or (data[name] is not None and
           (not isinstance(data[name], str) or not data[name].strip())) for name in names):
        return outcome("error", "unexpected_response")
    return {**outcome("matched", http_status=200), "parse_postal": postal,
            **{f"parse_{name}": data[name] or "" for name in names}}

The added columns are parse_postal, parse_city, parse_state, parse_timezone, parse_status, parse_error, and parse_http_status.

  • matched: a postal record was returned. Individual enrichment fields may still be empty.
  • missing_zip: the original ZIP cell was blank or whitespace.
  • invalid_zip_format: the input was not a five-digit ZIP or the accepted ZIP+4 form.
  • not_found: the lookup returned HTTP 404. The code was not found in current coverage.
  • error: the request failed or its response did not match the expected shape.
  • not_attempted: a prior authentication, access, or rate-limit error stopped new lookups.

not_found is not proof that an address is invalid. Likewise, a timeout must not become a negative postal verdict. The error code and HTTP status, when available, let your next processing step distinguish these cases.

4. Write a new file

Run the downloaded script against your file:

Terminal
python3 enrich-zip-code-csv.python.py customers.csv --output customers-enriched.csv

The output path must be new. The script refuses to overwrite an existing file, including the input. Without --output, CSV goes to standard output and the short row summary goes to standard error. Do not redirect output over the input file: your shell can truncate the input before Python starts.

Omit the input filename to run the six-row sample. It should match the first three records, flag the four-digit and blank values locally, and report the unknown ZIP separately. Review the output as text to confirm 02108 remains present. When importing into a spreadsheet, set the ZIP column to text; CSV does not carry column types.

The exit code is 0 when every row matched, 2 when the CSV was written but some rows need review, and 1 for a startup, input, or output failure. The built-in sample intentionally exits with 2 because it includes review cases.

Use the enrichment in your application

A postal record's city is a primary place label, not a complete list of acceptable mailing city names. Keep your original city alongside parse_city and review differences instead of automatically replacing a customer's address. The returned timezone describes the postal area; use an actual event instant when you need to calculate an offset.

For a retry file, select the original columns from the rows you want to retry. The script deliberately rejects an input that already contains its output columns. Fix key or allowance problems before rerunning; a new run makes new API requests.

The Postal API reference describes additional fields and coverage. To use a ZIP interactively, build a city/state autofill form or a store locator from your own locations.

Complete example

The complete Python source. The script reads your API key from PARSEAPI_KEY.

Download Python
View and copy the complete source
enrich-zip-code-csv.python.py
import argparse
import csv
import io
import re
import sys
from pathlib import Path

from parseapi import ParseAPI, ParseAPIError

SAMPLE = """id,zip,note
1,02108,Boston
2,94107,San Francisco
3,02108-1234,Same five-digit lookup
4,2108,Missing digit
5,,Missing ZIP
6,00000,Unknown ZIP
"""
COLUMNS = ["parse_postal", "parse_city", "parse_state", "parse_timezone",
           "parse_status", "parse_error", "parse_http_status"]


# example:read
def read_rows(stream):
    reader = csv.DictReader(stream, strict=True)
    fields = reader.fieldnames
    if (not fields or "zip" not in fields or any(not field.strip() for field in fields)
            or len(fields) != len(set(fields))):
        raise ValueError("Use distinct, nonempty headers, including a column named zip.")
    if set(fields) & set(COLUMNS):
        raise ValueError("The input already contains output columns. Use the original CSV.")
    rows = []
    for row in reader:
        if None in row or any(value is None for value in row.values()):
            raise ValueError("Every row must contain the same number of cells as the header.")
        rows.append(row)
        if len(rows) > 200:
            raise ValueError("Use a CSV with 1 to 200 data rows for this example.")
    if not rows:
        raise ValueError("The CSV needs at least one data row.")
    return fields, rows
# /example:read


def outcome(status, error="", http_status=""):
    return {**dict.fromkeys(COLUMNS, ""), "parse_status": status,
            "parse_error": error, "parse_http_status": http_status}


# example:result
def matched(data, postal):
    if (not isinstance(data, dict) or data.get("postal") != postal
            or data.get("country") != "US"):
        return outcome("error", "unexpected_response")
    names = ("city", "state", "timezone")
    if any(name not in data or (data[name] is not None and
           (not isinstance(data[name], str) or not data[name].strip())) for name in names):
        return outcome("error", "unexpected_response")
    return {**outcome("matched", http_status=200), "parse_postal": postal,
            **{f"parse_{name}": data[name] or "" for name in names}}
# /example:result


def enrich(rows, parse):
    results = []
    cache = {}
    stopped = False
    # example:request
    for row in rows:
        value = row["zip"].strip()
        if not value:
            result = outcome("missing_zip")
        elif not re.fullmatch(r"[0-9]{5}(?:-[0-9]{4})?", value):
            result = outcome("invalid_zip_format")
        else:
            postal = value[:5]
            if postal in cache:
                result = cache[postal]
            elif stopped:
                result = outcome("not_attempted")
            else:
                try:
                    result = matched(parse.postal(postal, country="US"), postal)
                except ParseAPIError as error:
                    status = "not_found" if error.status == 404 else "error"
                    result = outcome(status, error.code, error.status)
                    if error.status in (401, 403, 429):
                        stopped = True
                except Exception:
                    result = outcome("error", "request_failed")
                # Reuse completed answers and failures for duplicate ZIPs in this run.
                cache[postal] = result
        results.append({**row, **result})
    # /example:request
    return results


def write_rows(stream, fields, rows):
    writer = csv.DictWriter(stream, fieldnames=fields + COLUMNS)
    writer.writeheader()
    writer.writerows(rows)


def main():
    parser = argparse.ArgumentParser(description="Add city, state, and timezone to a US ZIP CSV.")
    parser.add_argument("input", nargs="?", help="UTF-8 CSV with a zip column; defaults to a sample")
    parser.add_argument("--output", help="New CSV filename; defaults to standard output")
    args = parser.parse_args()
    try:
        if args.output and (Path(args.output).exists() or Path(args.output).is_symlink()):
            raise ValueError("The output path already exists. Choose a new filename.")
        if args.input:
            path = Path(args.input)
            if path.stat().st_size > 65536:
                raise ValueError("Use a CSV no larger than 64 KiB.")
            with path.open(encoding="utf-8-sig", newline="") as stream:
                fields, rows = read_rows(stream)
        else:
            fields, rows = read_rows(io.StringIO(SAMPLE))
        # Reads PARSEAPI_KEY. Reuse one client, with no automatic retries.
        with ParseAPI(timeout=10.0, retries=0) as parse:
            results = enrich(rows, parse)
        if args.output:
            # Exclusive creation also protects against a file appearing during the lookups.
            with Path(args.output).open("x", encoding="utf-8", newline="") as stream:
                write_rows(stream, fields, results)
        else:
            write_rows(sys.stdout, fields, results)
    except (ValueError, OSError, csv.Error) as error:
        print(f"Could not complete: {error}", file=sys.stderr)
        return 1
    review = sum(row["parse_status"] != "matched" for row in results)
    print(f"{len(results)} rows written; {review} need review.", file=sys.stderr)
    return 2 if review else 0


if __name__ == "__main__":
    sys.exit(main())

Need a key? Create a secret API key. For every field and parameter, see Postal API reference.

Build something else

All tutorials →