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.
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.pyBefore 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:
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 ZIPThe 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.
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, rowsKeep 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.
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.
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:
python3 enrich-zip-code-csv.python.py customers.csv --output customers-enriched.csvThe 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.
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.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
- Autofill city and state from a ZIP code in JavaScript
- Find ZIP codes within a radius in JavaScript
- Calculate distance between ZIP codes in JavaScript
- Build a store locator in JavaScript