A ZIP code database is a file, usually a CSV, with the postal codes of one or more countries and their city, state and coordinates. You import it and query it locally. A ZIP code API answers the same questions over HTTP from a database that someone else maintains. A download fits offline systems, very high volumes in one country and one-off analysis. An API fits forms, several countries and anything with radius or distance search, where keeping your own copy current is more work than it looks. This guide compares the two, lists the free files and their gaps, and ends with a hybrid that caches API answers in your own database.
1. Side by side
| Database download | ZIP code API | |
|---|---|---|
| Setup | Download, import, index, write the queries | An API key and one HTTP request |
| Updates | You re-download and re-import | The provider updates the data |
| Coverage | Usually one file per country; free files vary in completeness | Many countries behind one interface (zipcodestack: 182) |
| Radius and distance | You build the geo queries | Built-in endpoints |
| Speed | Local query, no network | A network round trip; cache the answers |
| Cost | Free or licence fee, plus your engineering time | Free tier, then plans by volume |
| Offline use | Yes | No |
| Licence | Read it: attribution, share-alike or no redistribution | Terms of service; reselling the data is usually not allowed |
2. Free ZIP code databases and their gaps
GeoNames postal codes (download.geonames.org/export/zip): one tab-separated file per country with code, place, administrative areas and coordinates. According to its readme:
- About 120 countries, under the Creative Commons Attribution 4.0 licence: you have to credit GeoNames.
- Full codes for Canada, the Netherlands and the UK are in separate
_fullfiles; the UK file contains Royal Mail data from 2022. - Chile, Ireland and China have partial codes only, and Brazil only its major codes.
- For many countries the coordinates are worked out by matching place names, not surveyed.
US Census ZCTAs (census.gov): ZIP Code Tabulation Areas are polygons that approximate ZIP code areas, built from 2020 Census blocks. They are made for statistics and maps. The Census Bureau notes that not all valid ZIP codes are represented by a ZCTA.
Whatever file you use, check four things before you rely on it:
| Check | Why |
|---|---|
| Completeness | Partial files return “not found” for real codes |
| Date | A snapshot from years ago misses new codes |
| Licence | Attribution and redistribution terms apply to your product, too |
| Leading zeros | 02108 (Boston) becomes 2108 if a spreadsheet or a numeric column reads it |
3. How often postal codes change
Codes are added, retired and redrawn as areas are built up and delivery routes change. Now and then a country redesigns its whole system: Ireland introduced Eircodes in 2015, and South Korea moved from six-digit to five-digit codes the same year. A file you downloaded once drifts out of date quietly, and the first sign is usually a customer whose real postal code your form rejects.
4. When a download is the right choice
- Offline or isolated systems that cannot call an external service.
- Very high lookup volumes in one country where every millisecond counts, such as scoring millions of records in a batch.
- One-off analysis: joining a customer list to regions in a data warehouse, once.
- Polygons for maps: ZCTA shapes for choropleth maps of US statistics.
5. When an API is the better choice
- Several countries. Formats, sources and file layouts differ by country; an API gives you one response format.
- Radius search, distances, reverse geocoding and autocomplete, which take real work to build and tune on a raw table.
- Form validation. The validation endpoint tells you whether a code exists and returns its normalized form; see postal code regex for the format check in front of it.
- No update jobs and no schema migrations for reference data.
What a zipcodestack request costs depends on the endpoint:
| Endpoint | Requests counted |
|---|---|
Search (/v1/search) | 1 per postal code found, up to 100 codes per call |
| Validate, reverse geocoding, autocomplete | 1 per call |
| Distance | 1 per distance calculated |
| Radius, polygon | 10 per call |
| Codes by city or state | 1 per 10 codes returned |
| Coverage, provinces, status | Free |
The free plan has 1,000 requests a month for testing and personal projects. Commercial use starts with Small at $34.99 a month for 70,000 requests; Medium is $59.99 for 250,000, Large $159.99 for 2,500,000 and Business $249 for 6,000,000. All plans are on the pricing page.
6. A hybrid that works well
Call the API when a code is first needed and keep the answer in your own database for a while. Postal codes change slowly, so most lookups are answered locally, and stale entries refresh themselves. This Python example (Python 3.11, requests) keeps answers in SQLite for 30 days:
import json
import os
import sqlite3
import time
import requests
API_URL = "https://api.zipcodestack.com/v1/search"
MAX_AGE = 30 * 24 * 3600 # 30 days
db = sqlite3.connect("postal_cache.sqlite3")
db.execute(
"CREATE TABLE IF NOT EXISTS postal_cache ("
" country TEXT, code TEXT, places TEXT, fetched_at INTEGER,"
" PRIMARY KEY (country, code))"
)
def lookup(code: str, country: str) -> list:
"""The places of a postal code, from the cache or the API; [] if the code is not found."""
key = (country.upper(), " ".join(code.split()).upper())
row = db.execute(
"SELECT places, fetched_at FROM postal_cache WHERE country = ? AND code = ?", key
).fetchone()
if row and time.time() - row[1] < MAX_AGE:
return json.loads(row[0])
response = requests.get(
API_URL,
params={"codes": key[1], "country": key[0]},
headers={"apikey": os.environ["ZIPCODESTACK_KEY"]},
timeout=10,
)
response.raise_for_status()
body = response.json()
if "results" not in body: # parameter errors come back with status 200
raise ValueError(f"zipcodestack: {body}")
places = next(iter(body["results"].values()), []) if body["results"] else []
db.execute(
"INSERT OR REPLACE INTO postal_cache VALUES (?, ?, ?, ?)",
(*key, json.dumps(places), int(time.time())),
)
db.commit()
return places
if __name__ == "__main__":
for place in lookup("1010", "at"):
print(place["postal_code"], place["city"], place["latitude"], place["longitude"])
Against a local server that answers with the search documentation’s example, lookup("1010", "at") printed 1010 Wien, Innere Stadt 48.2077 16.3705; the second call for the same code, and the second call for a code that does not exist, were answered from SQLite without a request. One detail in the code: on most endpoints, a missing or invalid parameter comes back with status 200 and an error body instead of results, so the function checks for results before it caches anything.
Caching answers for your own application is the point of this pattern; passing the data on to others is a different matter, so read the terms of service before you build a product on top of it. To see the data first, try the free tools or the bulk ZIP code lookup, which turns a list of codes into a CSV with city, state and coordinates.