Add places to a ZIP-code CSV
Give a Python script your ZIP-code CSV. Get city, state, and timezone in new columns.
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.
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install parseapiexport PARSEAPI_KEY='YOUR_SECRET_API_KEY'python3 enrich-zip-code-csv.python.pyAdd city and state
Give the script a CSV with a zip column. It writes a new CSV with city, state, and timezone, keeping every original cell and row.
Use Python 3.9 or later. Install the SDK and set the secret PARSEAPI_KEY using the setup above.
1. Run your file
Save this as customers.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 ZIPThen run the downloaded script:
python3 enrich-zip-code-csv.python.py customers.csv --output customers-enriched.csvThe 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
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
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 your original city alongside the suggested one. A postal area's place label is not every accepted mailing city, and this lookup does not validate street delivery. See the Postal reference for coverage and fields.
Complete example
The complete Python source. The script reads your API key from PARSEAPI_KEY.
View and copy the complete source
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
- Autofill city and state from a ZIP code
- Choose an Australian suburb from a postcode
- Find nearby ZIP codes
- Calculate distance between ZIP codes
- Find an open store nearby
- Build a local weather card