Skip to main content

ziptz

US ZIP code to IANA time zone, in Go and in Python, from about 1.3 KB of tables. No dependencies, no data files, no network: both tables are string constants compiled into the source, and the first lookup unpacks them into a map — so the tables stay small on disk and answering one is a hash.

name, err := ziptz.Zone("94110")          // "America/Los_Angeles"
loc, err := ziptz.Location("10001")       // *time.Location
abb, err := ziptz.Abbrev("94110", when)   // "PST" in January, "PDT" in July
gen, err := ziptz.Generic("94110")        // "PT", whatever the date
ziptz.zone("94110")           # 'America/Los_Angeles'
ziptz.location("10001")       # ZoneInfo(key='America/New_York')
ziptz.abbrev("94110", when)   # 'PST' in January, 'PDT' in July
ziptz.generic("94110")        # 'PT', whatever the date

The two implementations answer identically for every ZIP code, down to the wording of the errors — the tables are generated into both in one pass by tools/genzips.py, and make test puts all 101,000 tokens through both and compares every answer. Not a claim; a build step.

What it is for, and what it is not for

It is small enough to stop being a dependency and start being a file. The library is a single 13 KB source file per language, 1.3 KB of which is the tables, and its own footprint once imported is about 120 KB on top of the standard library it needs. Neither side has a dependency, and Zone and Generic read no time zone database at all, so both answer on a machine that has none. That is what makes vendoring one file a real option rather than a compromise.

The 24 KB wheel is mostly not the library: the tests and their case file ship with it on purpose, so an installed copy can prove itself where it landed rather than only in a checkout.

It covers the places a US-only table usually forgets:

00601  America/Puerto_Rico     00802  America/Puerto_Rico   (US Virgin Islands)
96799  Pacific/Pago_Pago       96910  Pacific/Guam
96950  Pacific/Guam            (Northern Mariana Islands)

And it answers a 3-digit prefix, not just a whole ZIP, which is what you have when an address is partial or a form was only half filled in.

Two things it is deliberately or unavoidably bad at:

  • Zone identity. About 34 IANA zones are folded onto the 11 that agree with them today, so a ZIP in Knox County, Indiana answers America/New_York rather than America/Indiana/Knox. The clock is right; the name is coarser than the database's. When to regenerate explains what keeps that true.
  • Validating ZIP codes. A five-digit code with an assigned prefix always gets an answer, whether or not the Postal Service has ever issued it. An error means "no such prefix", never "no such ZIP".

Accuracy

Give all five digits and the answer is exact. Three digits — the prefix alone — gets the majority zone for that prefix, which is right for 33,558 of the 33,791 ZIP codes and wrong for the 233 that sit on the losing side of a zone boundary their prefix has to round the wrong way.

ziptz.zone("79835")   # America/Denver  — Canutillo, TX; five digits are exact
ziptz.zone("798")     # America/Chicago — the prefix rounds to the majority

One gap: PO-box and single-building ZIPs have no delivery area in the source data, so even given in full they fall back to their prefix's answer. Where a whole prefix is nothing but those, there is no answer to fall back to and the lookup is an error instead — 00501 (Holtsville, NY, an IRS building) is the named case, and cross-referencing a per-ZIP dataset finds about 275 of them across 19 prefixes: IRS centres like 73301 and 45999, federal agency ZIPs in 569xx, state government in 942xx, and PO-box banks in 311xx and 332xx. They are exactly the ZIPs with no delivery area, so the gap is one thing rather than two.

The tables carry today's zone names, not today's offsets — daylight saving comes from whatever tzdata the machine has, so a rule change needs no new release. They are wrong for historical dates: several places have changed zone (Kentucky/Monticello left Central in 2000, North Dakota/Beulah left Mountain in 2010) and the tables record only where each ZIP is now.

Install

go get github.com/choey/ziptz
pip install ziptz-us          # imports as ziptz; see below

The Python distribution is ziptz-us and the module is ziptz — the same split as python-dateutil/dateutil. ziptz on PyPI is a 2013-era name registration with no files ever attached, so pip install ziptz fails for everyone; the -us is also simply true, since this resolves US ZIP codes and nothing else. Go has no central registry, so the import path there is the repository's own.

One consequence worth knowing: importlib.metadata.version("ziptz") raises, because that asks the distribution name. ziptz.__version__ is the answer to use, and is the better one anyway — it works for a copied single file, where there is no installed distribution to ask about at all.

Or copy it. Each side is one standard-library-only file: drop ziptz.go into a package of your own, or ziptz.py next to whatever imports it — no build step, nothing to fetch, and the tables come along because they are source. That is a supported way to use this rather than a workaround: at 1.3 KB of tables, the library is smaller than most manifests that would name it.

Python therefore imports two ways, and both answer the same: ziptz.py alone is a module, and the directory around it is a package whose __init__.py hands through to that module — which is what lets a clone import ziptz with nothing installed. A test compares the two, since only the package form is what a wheel contains.

The Python side is annotated and ships py.typed, so mypy and editors read the signatures rather than treating the package as untyped. The annotations are from __future__ import annotations strings, which is what lets them be spelled datetime | None while the package still imports on the 3.9 it supports.

API

Go Python
Zone(token) (string, error) zone(token) -> str the IANA name for a 3- or 5-digit ZIP
Location(token) (*time.Location, error) location(token) -> ZoneInfo the same, loaded from the system tz database
Abbrev(token, at) (string, error) abbrev(token, at=None) -> str the abbreviation at a given instant — PST in winter, PDT in summer
Generic(token) (string, error) generic(token) -> str the name without daylight saving, e.g. PT
PrefixZone(p3) string prefix_zone(p3) -> str the majority zone for a prefix, "" if unassigned
ExactZone(zip5) string exact_zone(zip5) -> str the zone for one of the 233 ZIPs its prefix gets wrong, "" for the rest

Those first four report an error for anything that is not three or five ASCII digits, and for prefixes the Postal Service has never assigned. Python raises ZipError, a ValueError. Both error texts are written to be printed as-is.

The last two are the two table lookups Zone is built from, exposed for a caller that wants to know which of them answered. Neither reports an error: each returns "" both for anything that is not a well-formed prefix or ZIP and for anything it simply has no record of. For ExactZone the second is almost every ZIP — only the 233 exceptions have a record at all — so "" there means no exception; the prefix is the answer, not unknown. Zone is exactly the two composed in that order.

What Location costs

Location and location return the same thing under two names: *time.Location is Go's loaded zone and ZoneInfo is Python's, and neither language spells it the other's way. What differs is the price of asking twice, and that is the standard libraries' doing rather than this library's.

20,000 calls for one zone       (one machine; the ratio is the point, not the ms)
  Go   time.LoadLocation      459 ms    reads the tz database every call
  Py   ZoneInfo                 2 ms    interned by name; the first call does the work
  Py   ZoneInfo.no_cache    1,053 ms    what that cache is saving

Python interns by name, so location("94110") is location("90210") — two ZIPs, one zone, one object. Go does not, and every Location is a file read. Hold the result if you are calling it per row of anything, and note that Abbrev goes through Location and inherits the same cost.

Zone and Generic are the cheap ones in both languages: two map lookups and no I/O at all once the first call has unpacked the tables. Go allocates nothing per call; Python allocates one short slice, for the prefix.

The three names for one zone

Zone gives the IANA name, Abbrev the abbreviation at an instant, Generic the abbreviation with the daylight-saving question left out — CLDR's terms for the last two are the specific and generic non-location short formats.

94110  →  America/Los_Angeles      PST in January, PDT in July      PT
85001  →  America/Phoenix          MST all year                     MST
99546  →  America/Adak             HST in January, HDT in July      HAT

Which to print depends on whether you have an instant to be right about. A label on a clock face has one; a form field asking which coast you are on does not.

They come from different places, which decides what each needs and when each can be wrong. Abbrev asks the system's time zone database, so it follows rule changes without a new release of this library — but needs that database present, and needs the instant. Generic is a table here, eleven entries, and so answers on a machine with no tzdata at all. A zone that never shifts has no pair to generalise over, so its generic name is simply its abbreviation: Phoenix is MST, Honolulu HST, and the tests hold every entry to that rule.

Go has no default arguments, so Abbrev always takes the instant; Python's defaults to now. Pass an aware datetime — a naive one is read as system local time, the way astimezone() reads it.

Data

US Census ZCTA Gazetteer centroids (a US Government work, public domain) resolved through timezone-boundary-builder (ODbL), by way of timezonefinder. The generated output is 157 range records, 94 of which name a zone, and 233 exceptions — an aggregate, not a substantial extract — but both sources are credited here and in the source.

Regenerating

tools/genzips.py writes the tables into ziptz.go and ziptz.py in one pass, which is what keeps the two literals from drifting. Never edit them by hand.

python3 -m venv .venv && .venv/bin/pip install timezonefinder
.venv/bin/python tools/genzips.py      # or: make regen, if it is on your path

It is deliberately not a build step. timezonefinder pulls in numpy and a megabyte of boundary data, where ziptz itself needs neither, and a generator that ran at install time would let two builds of one version ship different tables. Checked-in generated source is what makes every install byte-identical — and what lets the Go and Python copies be compared character for character. go generate ./... runs the same script, for the same reason go:generate exists: it is a developer's command, not the build's.

The Census archive is cached in tools/cache/ (gitignored) and reused on every later run, so only the first regeneration touches the network. Pass a path to read a local copy instead.

When to regenerate

Almost never, and not for daylight-saving changes. The tables store zone names, not offsets or rules, so the answer to "is Denver on MDT today" comes from whatever tzdata the machine has. A state dropping daylight saving, or the country abolishing the switch, arrives with an OS update and needs nothing here.

Regenerate when the mapping itself moves:

what changed why it matters
a place changes zone Kentucky/Monticello left Central for Eastern in 2000; its ZIPs now belong to a different name
new or redrawn ZIP codes a new Census gazetteer describes them
a zone splits from the letter it folds onto CANONICAL collapses ~34 zones onto 11 letters, and that only holds while they keep the same rules

The last is the one that could go wrong quietly, so genzips.py re-tests it on every run: each folded zone is compared against its letter's zone every six hours for the next thirteen months, and the run aborts if any of them parts company. Indiana observed no daylight saving until 2006 and North Dakota/Beulah left Mountain in 2010, so this is not hypothetical.

genzips: these zones no longer track the letter they fold onto, so folding
them would serve the wrong hour:
  America/Phoenix parts from America/Denver on 2026-08-12
Give the divergent one its own letter in CANONICAL, and add that letter to
zones/ZONES in both ziptz libraries.

Tests

make test        # both suites, then the sweep below

make test-go and make test-py run one suite each. make sweep is the third thing make test does, and the one the cases cannot be: every ZIP there is, through both libraries, compared. All 1,000 prefixes and all 100,000 five-digit codes — 101,000 answers, zone names and error text alike — must come out identical, which is what makes "the two answer the same, ZIP for ZIP" a measured claim rather than a hopeful one. The cases above cover what someone thought of; this covers what nobody did.

An installed copy carries its tests and their data, so it can prove itself where it landed rather than only in a checkout:

python3 -m unittest ziptz.test_ziptz
go test github.com/choey/ziptz

The data is the point: testdata/cases.json holds the cases, the zones, the figures the generated tables should contain, and — in checks — the names of the properties both suites must test. Each suite maps every name to a test and fails on one it does not implement, so neither can quietly cover less than the other. Cases alone were not enough: the structural checks around them were written twice, once per language, and two hand-written lists drift — one suite had a check on the exception suffixes being in order for a while before the other did. Each case is a token and either the zone it must resolve to or the kind of failure it must produce, with a note saying why it is there.

{"token": "96799", "zone": "Pacific/Pago_Pago", "why": "the worst exception: American Samoa, an hour behind Honolulu"}
{"token": "00501", "error": "unassigned", "why": "known gap: Holtsville NY, a single-building ZIP; really America/New_York"}

Adding a case there is the whole edit — both suites pick it up. They cover the ordinary lookups, both sides of every kind of boundary (first and last exception in the table, first and last suffix of the largest group, a ZIP just outside one, the top and bottom of the prefix range), the malformed tokens (wrong length, letters, whitespace, a trailing newline, Arabic-Indic and fullwidth digits that Python's isdigit() accepts and this must not), and the known gaps — the ZIPs that resolve wrongly or not at all, pinned so that fixing one fails the file and makes someone update it.

Only two things are language-only, and the file says which: Go has no default arguments, so abbrev's default of now is Python's to test, and only Python can be imported two ways, as a module and as a package.

Licence

The code is MIT; see LICENSE.

The tables are a separate question, and NOTICE is the answer to it. They were produced from public-domain Census centroids resolved through timezone-boundary-builder, which is ODbL — so NOTICE carries that attribution and the reasoning for treating 1.3 KB of zone names as a produced work rather than an extract of the boundary database. It ships in the wheel and the sdist, and travels with the Go module. Keep it with any copy you make, including the copy-one-file install above.

Credits

The hard part was already done by other people. Evan Siroky builds the time zone boundaries out of OpenStreetMap, Jannik Michel makes them queryable in Python, and the US Census Bureau publishes the ZCTA centroids. This library is the small, boring artefact left over once their work has been asked 33,791 questions.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ziptz_us-0.1.0.tar.gz (47.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ziptz_us-0.1.0-py3-none-any.whl (26.4 kB view details)

Uploaded Python 3

File details

Details for the file ziptz_us-0.1.0.tar.gz.

File metadata

  • Download URL: ziptz_us-0.1.0.tar.gz
  • Upload date:
  • Size: 47.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for ziptz_us-0.1.0.tar.gz
Algorithm Hash digest
SHA256 7b1b577c2d0bcd4ca491d1d04a28f353baed52b8cb56cb65e038629221353fcb
MD5 c0f9fbdfed59a21a35e51399fbddd5cd
BLAKE2b-256 63eab665cafe6fad308514c8bbcea2831d717a1e0cd28db93852a9d2c660a713

See more details on using hashes here.

File details

Details for the file ziptz_us-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: ziptz_us-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 26.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.12 {"installer":{"name":"uv","version":"0.10.12","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for ziptz_us-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 c816e95d030bfb752e4f72a1d11fe50b285e3a1bc9de63b2bac62ba011e55e9b
MD5 7574e41b59428f98d59a216a3063aa1f
BLAKE2b-256 ffef6ff604f7e93b19c1ccb74a6688b6d14540542f695867deefe1be80aed888

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.2

2 files

0.1.1

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page