Add places to a ZIP-code CSV

Enrich a ZIP-code CSV with city, state, and timezone using Python, preserving leading zeroes and original rows.

PythonLast tested Download and run ↓
On this page

Run the example

Download Python

enrich-zip-code-csv.python.py

Download the file, then run these commands in its folder using a macOS or Linux terminal. You need Python 3.9 or later and a secret API key.

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

The first run uses the included sample. The CSV instructions below show how to save your own results. Review rows can produce exit code 2 even when the output was written successfully.

Need a key? Set up this example

Add city and state

Enrich a ZIP-code CSV with city, state, and timezone before a customer import. This Python script reads a zip column, appends the location fields, and writes a new file with every original cell and row.

1. Run your file

Save this as customers.csv:

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

Then run the downloaded script:

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

The output filename must be new. Omit the input filename to use the included sample. CSV goes to standard output when --output is omitted; never redirect onto the input file.

2. Look up each ZIP once

Python
result = matched(parse.postal(postal, country="US"), postal)

country="US" makes the lookup unambiguous. ZIPs stay strings: 02108 keeps its leading zero. 02108-1234 reuses the 02108 lookup; 2108 stays a review row instead of receiving a guessed zero.

The script looks up each distinct ZIP once per run, including failures. Repeated customer rows remain separate. Only the ZIP and country go to ParseAPI.

3. Keep the new columns

Selected output columns for the sample; every original column is also retained:

csv
id,zip,parse_city,parse_status
1,02108,Boston,matched
4,2108,,invalid_zip_format
5,,,missing_zip
Python
return {**outcome("matched", http_status=200), "parse_postal": postal,
        **{f"parse_{name}": data[name] or "" for name in names}}

The output appends parse_postal, parse_city, parse_state, parse_timezone, and three status/error columns. parse_status: matched means a postal record was found. Unknown fields become empty cells.

Blank and malformed ZIPs are flagged locally. HTTP 404 becomes not_found; a timeout or other failed check becomes error. Neither establishes that a street address is invalid. Account or rate-limit errors stop new lookups and mark remaining uncached ZIPs not_attempted.

The CSV reader preserves quoted commas, line breaks, leading zeroes, and UTF-8 text. Use distinct, nonempty headers and 1-200 data rows within 64 KiB. The script rejects malformed rows, existing output columns, and existing output files before making requests. Calls use a ten-second timeout with no automatic retries.

Exit 0 means all rows matched, 2 means the output includes review rows, and 1 means a startup or file error. The sample intentionally includes review rows. Import ZIP columns into spreadsheets as text; CSV does not store types.

Keep any original city alongside the suggested one. A ZIP area's place label may differ from an accepted mailing city; use a separate address check when you need street delivery validation. See the Postal reference for coverage and fields. For files beyond this example's 200-row limit, use Bulk Postal.

Complete example

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

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.reader(stream, strict=True)
    fields = next(reader, None)
    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 cells in reader:
        if len(cells) != len(fields):
            raise ValueError("Every row must contain the same number of cells as the header.")
        rows.append(dict(zip(fields, cells)))
        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}


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")
    # example:result
    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
    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:
                    # example:request
                    result = matched(parse.postal(postal, country="US"), postal)
                    # /example:request
                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})
    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? Set up this server example. For every field and parameter, see Postal API reference.

Build something else

All tutorials →