ML-ready ZIP-code-level metadata for the United States.
Project description
uszipinfo
ML-ready ZIP-code-level metadata for the United States.
A single, fast, typed Python package providing demographic, geographic, and metro-context features for every US ZIP code — including PO Boxes, military APO/FPO/DPO ZIPs, and US territories. Designed for machine learning pipelines and data analysis where you need rich features per ZIP without re-implementing the Census API plumbing.
import uszipinfo
info = uszipinfo.lookup("98004")
print(info.population) # 39161
print(info.urbanicity_tier) # 'urban'
print(info.msa_name) # 'Seattle-Tacoma-Bellevue, WA'
print(info.median_household_income) # 157784
print(info.pct_bachelors_or_higher) # 0.7488
Why this exists
Existing PyPI ZIP packages each have gaps:
| Package | Demographics | Recent data | ML features | PO Boxes | Military |
|---|---|---|---|---|---|
uszipcode |
Yes | Older ACS | Limited | Partial | No |
pgeocode |
No | Geographic only | No | No | No |
pyzipcode |
No | Basic only | No | No | No |
zipcodes |
No | Basic only | No | No | No |
uszipinfo |
Yes | Annual | Yes | Yes | Yes |
uszipinfo combines recent Census ACS demographics, HUD-style ZIP
crosswalks, Census Gazetteer geography, GeoNames postal coverage, and
engineered ML features into a single package with a clean, typed
Python API.
Coverage
The bundled data covers 41,994 ZIPs spanning all 50 states, DC, 5 US territories, and military APO/FPO/DPO addresses.
| ZIP type | Count | Description |
|---|---|---|
Standard |
32,164 | Residential ZIPs with full demographics |
PO_Box |
7,669 | PO Box-only ZIPs (city/state/lat/lon, null demographics) |
Unique |
1,377 | Institutional ZIPs (universities, large companies) |
Military |
784 | APO/FPO/DPO with state ∈ {AA, AE, AP} |
For Standard ZIPs, all 54 fields are populated (≥95% coverage on
demographic columns). For non-residential ZIPs (PO Box, Unique, Military),
demographics are deliberately null — they have no residential population
to measure — but zip_type, state, primary_city, and
geographic fields are populated.
Installation
pip install uszipinfo
The bundled Parquet data file (~7 MB) ships with the package — no API key, no separate download, no internet connection required at runtime.
Optional dependencies:
pip install uszipinfo[build] # for rebuilding the dataset from sources
pip install uszipinfo[dev] # pytest, ruff, mypy for development
Quick start
import uszipinfo
# Single ZIP lookup, returns a typed ZipInfo dataclass
info = uszipinfo.lookup("98004")
print(info.population)
# Bulk lookup, returns a DataFrame
df = uszipinfo.lookup_many(["98004", "98005", "98006"])
# Filter by criteria
urban_wa = uszipinfo.filter_zips(state="WA", urbanicity_tier="urban")
high_income = uszipinfo.filter_zips(min_median_household_income=100000)
nyc_metro = uszipinfo.filter_zips(msa_code="35620")
# Geographic queries
nearby = uszipinfo.nearest_zips("98004", n=10, max_distance_mi=20)
distance = uszipinfo.distance_mi("98004", "10001") # great-circle miles
# Load the full DataFrame
df = uszipinfo.load()
API reference
uszipinfo.load(year=None) -> pd.DataFrame
Return the full ZIP metadata as a pandas DataFrame with all 54 columns.
Pass year= to load a specific ACS vintage (only relevant if multiple
years are bundled; defaults to latest).
uszipinfo.lookup(zip_code, year=None) -> ZipInfo
Return a typed ZipInfo dataclass for a single ZIP.
- Accepts ZIP codes as 4- or 5-digit strings:
"02139","2139",2139 - Accepts ZIP+4 format:
"98004-1234"(suffix is dropped) - Raises
KeyErrorif the ZIP is not in the dataset - Raises
ValueErrorfor malformed input
uszipinfo.lookup_many(zip_codes, year=None) -> pd.DataFrame
Bulk lookup. Returns a DataFrame in the order of the input. ZIPs not present in the dataset are silently dropped from the output.
uszipinfo.filter_zips(year=None, **criteria) -> pd.DataFrame
Filter the dataset by criteria. Supports three forms:
- Equality:
state="WA",is_metro=True,urbanicity_tier="urban" - Membership:
state=["WA", "OR"] - Range:
min_population=1000,max_median_household_income=50000
Range filters use the prefix min_ (≥) or max_ (≤) on any numeric column.
uszipinfo.nearest_zips(zip_code, n=10, max_distance_mi=None) -> pd.DataFrame
Return the n nearest ZIPs to zip_code, sorted by great-circle distance.
Optionally filter to within max_distance_mi. Excludes the source ZIP.
uszipinfo.distance_mi(zip_a, zip_b) -> float
Return great-circle distance in miles between two ZIPs.
Raises KeyError if either ZIP is unknown, or ValueError if either ZIP
lacks coordinates (some PO Box ZIPs may have no lat/lon).
uszipinfo.haversine_mi(lat1, lon1, lat2, lon2) -> float
Direct great-circle distance from raw coordinates (degrees).
Module-level constants
uszipinfo.__version__ # package version, e.g., "1.0.0"
uszipinfo.DATA_YEAR # ACS vintage of the bundled data
uszipinfo.COLUMNS # list of all 54 column names
uszipinfo.ENUMS # allowed values for enum-like columns
Schema
54 columns across 10 categories. All percentage fields are in 0–1 range (not 0–100).
The Nullable column indicates whether the field can be None (in
Python) or NaN (in pandas). Non-nullable fields are guaranteed to have
a value for every record. Nullable fields may be missing for ZIPs where
the underlying data source is unavailable — typically PO Box, Unique,
and Military ZIPs that have no residential population for the Census to
measure.
Geographic identity
| Field | Type | Nullable | Description |
|---|---|---|---|
zip |
str | No | 5-digit ZIP, zero-padded. Primary key. |
state |
str | No | 2-letter USPS state code (or AA/AE/AP for military) |
state_name |
str | No | Full state name |
county |
str | Yes | Dominant county name (when ZIP spans multiple) |
county_fips |
str | Yes | 5-digit county FIPS code (state + county) |
primary_city |
str | Yes | Most-associated city name |
lat |
float | Yes | Interior point latitude |
lon |
float | Yes | Interior point longitude |
timezone |
str | Yes | IANA timezone (e.g., America/Los_Angeles) |
land_area_sq_mi |
float | Yes | Land area in square miles |
water_area_sq_mi |
float | Yes | Water area in square miles |
Metro / region
| Field | Type | Nullable | Description |
|---|---|---|---|
cbsa_code |
str | Yes | Core-Based Statistical Area code (5-digit) |
cbsa_name |
str | Yes | CBSA name (e.g., Seattle-Tacoma-Bellevue, WA) |
cbsa_type |
str | Yes | Metro / Micro |
msa_code |
str | Yes | Same as cbsa_code if Metro, else null |
msa_name |
str | Yes | Same as cbsa_name if Metro, else null |
csa_code |
str | Yes | Combined Statistical Area code (parent of CBSA) |
csa_name |
str | Yes | CSA name |
is_metro |
bool | No | True iff cbsa_type == Metro |
census_region |
str | Yes | Northeast / Midwest / South / West / Territories |
census_division |
str | Yes | One of 11 divisions (9 standard + 2 territory) |
Population
| Field | Type | Nullable | Description |
|---|---|---|---|
population |
int | Yes | Total population |
population_density |
float | Yes | Population per square mile of land |
households |
int | Yes | Total households |
median_age |
float | Yes | Median age in years |
pct_under_18 |
float | Yes | Percent of population under 18 |
pct_65_plus |
float | Yes | Percent 65 or older |
Economic
| Field | Type | Nullable | Description |
|---|---|---|---|
median_household_income |
int | Yes | USD |
pct_below_poverty |
float | Yes | Percent below federal poverty line |
pct_employed |
float | Yes | Labor force participation rate |
mean_travel_time_to_work_minutes |
float | Yes | Average commute time |
pct_no_vehicles |
float | Yes | Percent of households with no vehicles |
Education
| Field | Type | Nullable | Description |
|---|---|---|---|
pct_bachelors_or_higher |
float | Yes | Percent of adults 25+ with bachelor's or higher |
Housing
| Field | Type | Nullable | Description |
|---|---|---|---|
total_housing_units |
int | Yes | Total housing units |
pct_owner_occupied |
float | Yes | Percent of occupied units owner-occupied |
pct_vacant |
float | Yes | Percent of housing units vacant |
pct_single_family |
float | Yes | Percent that are 1-unit structures |
pct_multi_family |
float | Yes | Percent that are 5+ unit structures |
median_home_value |
int | Yes | USD |
vacancy_for_seasonal_use |
float | Yes | Percent vacant for seasonal use |
Race / ethnicity
All percentages in 0–1 range, sourced from Census ACS B03002 table.
| Field | Type | Nullable | Description |
|---|---|---|---|
pct_white |
float | Yes | Non-Hispanic white |
pct_black |
float | Yes | Black or African American |
pct_hispanic |
float | Yes | Hispanic or Latino (any race) |
pct_asian |
float | Yes | Asian |
pct_native_american |
float | Yes | American Indian / Alaska Native |
pct_pacific_islander |
float | Yes | Native Hawaiian / Pacific Islander |
USPS classification
| Field | Type | Nullable | Description |
|---|---|---|---|
zip_type |
str | No | Standard / PO_Box / Unique / Military (heuristic) |
The official USPS classification is not redistributable. zip_type is
inferred from population, area, and prefix conventions. Accuracy is high
for residential ZIPs and military ZIPs but may misclassify some edge
cases (e.g., very small Unique ZIPs as PO_Box).
Engineered features
| Field | Type | Nullable | Description |
|---|---|---|---|
urbanicity_tier |
str | Yes | rural (<100/sq mi) / suburban (100–1000) / urban (1000–10000) / dense_urban (>10000) |
climate_zone |
str | Yes | tropical / subtropical / temperate / continental / cold (latitude-based) |
is_college_town |
bool | No | Heuristic: high education + moderate density + non-trivial population |
is_resort_area |
bool | No | Heuristic: high seasonal-housing-vacancy ratio |
Build metadata
| Field | Type | Nullable | Description |
|---|---|---|---|
data_year |
int | No | ACS vintage year |
build_date |
date | No | Date the artifact was built |
build_version |
str | No | Package version that built this artifact |
Examples
Basic lookup
import uszipinfo
info = uszipinfo.lookup("90210")
print(f"{info.primary_city}, {info.state}")
# Beverly Hills, CA
print(f"Population: {info.population:,}")
# Population: 19,180
print(f"Median income: ${info.median_household_income:,}")
# Median income: $172,285
print(f"In MSA: {info.msa_name}")
# In MSA: Los Angeles-Long Beach-Anaheim, CA
Filter by demographic criteria
# All college-town ZIPs in Massachusetts
ma_college = uszipinfo.filter_zips(
state="MA",
is_college_town=True,
)
print(ma_college[["zip", "primary_city", "population"]])
# High-income suburban ZIPs nationwide
wealthy_suburbs = uszipinfo.filter_zips(
urbanicity_tier="suburban",
min_median_household_income=150000,
)
Bulk feature engineering for an ML model
import pandas as pd
import uszipinfo
# You have a DataFrame with a 'zip' column from your modeling pipeline
my_data = pd.DataFrame({"zip": ["98004", "98005", "98006", "10001"]})
# Enrich with ZIP metadata in one line
features = my_data.merge(uszipinfo.load(), on="zip", how="left")
# Use as model features:
# population, population_density, median_household_income,
# pct_bachelors_or_higher, pct_multi_family, urbanicity_tier, etc.
Geographic queries
# Find the 10 nearest ZIPs to Bellevue
nearby = uszipinfo.nearest_zips("98004", n=10)
print(nearby[["zip", "primary_city", "distance_mi"]])
# Distance between two ZIPs
miles = uszipinfo.distance_mi("98004", "10001")
print(f"Bellevue to Manhattan: {miles:.0f} miles")
# Bellevue to Manhattan: 2395 miles
# Nearby high-density ZIPs only
nyc_dense = uszipinfo.filter_zips(
state="NY",
urbanicity_tier="dense_urban",
)
Identifying non-standard ZIPs
# Check if a ZIP is a PO Box
info = uszipinfo.lookup("00501") # IRS administrative ZIP
print(info.zip_type)
# 'PO_Box'
# Find all military ZIPs in the dataset
military = uszipinfo.filter_zips(zip_type="Military")
print(f"Military ZIPs: {len(military)}")
# Check that demographics are appropriately null for non-residential ZIPs
po_box = uszipinfo.lookup("10101") # Manhattan PO Box
print(po_box.population) # None
print(po_box.primary_city) # 'New York'
print(po_box.zip_type) # 'PO_Box'
Data sources
All sources are public-domain or permissively licensed:
| Source | Provides | License | Refresh |
|---|---|---|---|
| US Census ACS 5-Year Estimates | Demographics, housing, economic indicators | Public domain | Annual (December) |
| US Census Gazetteer | Lat/lon, land/water area for ZCTAs | Public domain | Annual |
| US Census ZCTA-County Relationship | ZIP-to-county mapping | Public domain | Decennial |
| OMB CBSA Delineations | County-to-CBSA, MSA classification, CSA hierarchy | Public domain | Annual |
| GeoNames Postal Codes | Full ZIP coverage including PO Box / Military / Territory ZIPs, primary city, lat/lon | CC BY 4.0 | Continuous |
GeoNames attribution: data © GeoNames (https://www.geonames.org), used under CC BY 4.0.
USPS authoritative ZIP type classifications are NOT redistributable. The
zip_type field is inferred from public signals; see DATA_LICENSE for
details.
Building from source
The build pipeline is checked into the repo for transparency. Anyone can regenerate the bundled Parquet from primary sources.
# Get a free Census API key (recommended; avoids rate limiting):
# https://api.census.gov/data/key_signup.html
export CENSUS_API_KEY=your_key_here
# Run the build (downloads ~50 MB of source data, takes 1–2 minutes)
python -m pipeline.run \
--year 2022 \
--out src/uszipinfo/_data/zip_metadata_2022.parquet
# To guarantee coverage of a specific ZIP set
# (e.g., from a downstream system like AMD), pass --extra-zips:
python -m pipeline.run \
--year 2022 \
--extra-zips ./my_required_zips.csv \
--out src/uszipinfo/_data/zip_metadata_2022.parquet
The --extra-zips flag accepts a CSV with a single zip column. Any
ZIPs not covered by other sources will be added with synthesized records
(military prefix detection or skeleton fallback). This guarantees
uszipinfo.lookup(z) succeeds for every ZIP in your downstream system.
The build pipeline runs the following steps:
- Fetch GeoNames (master ZIP list, primary city, lat/lon)
- Fetch Census ACS (demographics)
- Fetch Census Gazetteer (authoritative geography)
- Fetch Census ZCTA-County (county FIPS)
- Fetch OMB CBSA delineation (metro context)
- Merge sources in priority order
- Derive ZIP types heuristically
- Engineer features (urbanicity, climate, college-town, resort)
- Validate (schema, value ranges, coverage thresholds)
If validation fails, the build refuses to write the artifact and prints all detected problems.
Versioning
uszipinfo follows semver:
- Major (1.0.0 → 2.0.0): schema-breaking changes (field rename, type change, removal)
- Minor (1.0.0 → 1.1.0): new ACS vintage, new fields added (additive), new helper functions
- Patch (1.0.0 → 1.0.1): bug fixes, doc updates, no data or schema change
The data vintage is independent of the package version and accessible via
uszipinfo.DATA_YEAR.
Performance
The bundled Parquet is loaded once on first call and cached in memory.
| Operation | Cold | Warm |
|---|---|---|
First import + lookup() |
~150 ms | — |
lookup() after first call |
~1 ms | <1 ms |
lookup_many(1000) |
~10 ms | ~5 ms |
load() returning full DataFrame |
~50 ms | ~10 ms |
filter_zips(...) |
~20 ms | ~10 ms |
nearest_zips() |
~30 ms | ~30 ms |
Memory footprint: ~20 MB for the in-memory DataFrame.
Testing
pip install uszipinfo[dev]
pytest tests/
The test suite includes:
- API correctness (lookup, filter, geo)
- Schema validation
- Coverage tests for known PO Box, Military, and Territory ZIPs
- Sanity checks against well-known ZIPs (90210, 10001, etc.)
Contributing
Issues and pull requests welcome. Areas where contributions are particularly valuable:
- Field additions: vehicle ownership detail, school district mapping, congressional district, etc.
- International expansion: a
zipinfoumbrella package covering postal codes for other countries - Vintage backfill: building artifacts for older ACS years for historical analysis
- Heuristic refinements: improvements to
is_college_town,is_resort_area, climate banding, etc.
License
- Code: MIT
- Data: Public domain (US government work) + GeoNames CC BY 4.0 (attribution required)
See LICENSE and DATA_LICENSE for full text.
FAQ
Why aren't there ~42,000 USPS ZIPs in your data when USPS says there are ~42,000 ZIPs?
There are. We have 41,994 ZIPs covering all USPS-deliverable ZIPs that appear in any of our sources. USPS allocates ZIPs continuously; our annual rebuild may lag a few months behind brand-new ZIP allocations.
Why are some demographic fields null?
Three reasons:
- PO Box / Unique / Military ZIPs have no residential population, so
Census doesn't tabulate demographics for them. The
zip_typefield tells you which kind. - Newly-allocated ZIPs may not yet have ACS data. They show up with
Standardzip_type but null demographics until the next ACS release. - Very small ZCTAs sometimes have null Census fields due to privacy suppression. This affects <1% of Standard ZIPs.
Why does urbanicity_tier say urban for my suburban ZIP?
Tiers are based on population density:
- rural: <100 / sq mi
- suburban: 100–1000 / sq mi
- urban: 1000–10000 / sq mi
- dense_urban: >10000 / sq mi
These bands are coarse on purpose. ML models often want a categorical
density signal; the boundaries are tuned to align with patterns in
delivery, retail, and other practical use cases. If you need finer
control, use the raw population_density field directly.
How accurate is is_college_town / is_resort_area?
These are heuristic flags with documented rules:
is_college_town:pct_bachelors_or_higher > 0.40,population_density between 500 and 5000,population > 5000is_resort_area:vacancy_for_seasonal_use > 0.15
They catch most well-known examples but will have false positives and false negatives. If you need authoritative classifications, validate against your own source.
How is zip_type derived?
Heuristic, in order:
- Military if state ∈ {AA, AE, AP} OR ZIP prefix is in known military ranges (090–099, 340, 962–966)
- PO_Box if no ZCTA data + zero population + zero land area
- Unique if non-zero population < 100 + measurable land area
- Standard otherwise
This is approximate. If you need authoritative USPS classification, purchase the USPS Address Information System or licensed equivalent.
Can I use this for non-US postal codes?
Not yet — v1 is US-only (including all 50 states + DC + 5 territories +
military). International support is a planned v2+ feature. For
international postal data, see pgeocode or GeoNames directly.
How often is this updated?
Annually after the December ACS release. Patch releases for bug fixes ship as needed.
Acknowledgments
Data sources:
- US Census Bureau (ACS, Gazetteer, ZCTA-County relationship files)
- Office of Management and Budget (CBSA delineations)
- GeoNames postal data (https://www.geonames.org)
This package is not affiliated with the US Census Bureau, USPS, OMB, or GeoNames.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file uszipinfo-1.0.0.tar.gz.
File metadata
- Download URL: uszipinfo-1.0.0.tar.gz
- Upload date:
- Size: 6.9 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
77af4f6061eb6f04180efe149020d990e6a8dc3c08a2302461a230267f150da6
|
|
| MD5 |
fb8f60bbc879b00f00c3ebc0a267016b
|
|
| BLAKE2b-256 |
170cf1debd0375ce4c67f25b70ba5115e55b6b7f69e9b520c52f0a31c75c06f8
|
Provenance
The following attestation bundles were made for uszipinfo-1.0.0.tar.gz:
Publisher:
publish.yml on arahas/uszipinfo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
uszipinfo-1.0.0.tar.gz -
Subject digest:
77af4f6061eb6f04180efe149020d990e6a8dc3c08a2302461a230267f150da6 - Sigstore transparency entry: 1508941838
- Sigstore integration time:
-
Permalink:
arahas/uszipinfo@bac69fcebd76545943d879e22cb4f7caae84876e -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/arahas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@bac69fcebd76545943d879e22cb4f7caae84876e -
Trigger Event:
release
-
Statement type:
File details
Details for the file uszipinfo-1.0.0-py3-none-any.whl.
File metadata
- Download URL: uszipinfo-1.0.0-py3-none-any.whl
- Upload date:
- Size: 6.9 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
648e9e2ee9f2fbc85b189d0b55c2d9354d93c14d82e64f4b7ec7d5c21ec96bb4
|
|
| MD5 |
6315bc210ebd0f46589696266d10cad4
|
|
| BLAKE2b-256 |
36f1b22832255db4900a90c7b7d860b9edd5a0d831a06bcba7e159943f648322
|
Provenance
The following attestation bundles were made for uszipinfo-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on arahas/uszipinfo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
uszipinfo-1.0.0-py3-none-any.whl -
Subject digest:
648e9e2ee9f2fbc85b189d0b55c2d9354d93c14d82e64f4b7ec7d5c21ec96bb4 - Sigstore transparency entry: 1508941924
- Sigstore integration time:
-
Permalink:
arahas/uszipinfo@bac69fcebd76545943d879e22cb4f7caae84876e -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/arahas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@bac69fcebd76545943d879e22cb4f7caae84876e -
Trigger Event:
release
-
Statement type: