ZipPostLookup 1.0.2
dotnet add package ZipPostLookup --version 1.0.2
NuGet\Install-Package ZipPostLookup -Version 1.0.2
<PackageReference Include="ZipPostLookup" Version="1.0.2" />
<PackageVersion Include="ZipPostLookup" Version="1.0.2" />
<PackageReference Include="ZipPostLookup" />
paket add ZipPostLookup --version 1.0.2
#r "nuget: ZipPostLookup, 1.0.2"
#:package ZipPostLookup@1.0.2
#addin nuget:?package=ZipPostLookup&version=1.0.2
#tool nuget:?package=ZipPostLookup&version=1.0.2
ZipPostLookup
Fast, extensible in-memory postal code lookup for .NET.
Built-in data for the United States (43,501 ZIP codes), Canada (~901,487 postal codes, 620k compressed entries via FSA range compression), and Mexico (32,896 postal codes). All three countries are fully curated ✅.
Code counts and status, last updated on: (2026-07-02 17:28:19)
Installation
dotnet add package ZipPostLookup
Legacy Systems
Targets netstandard2.0, covering .NET Framework 4.6.1+ / .NET Core 2+ / .NET 5–7
dotnet add package ZipPostLookup.Legacy
Quick Start
using ZipPostLookup;
// US lookup — uses the built-in default registry (no setup required)
var entry = ZipPostLookup.GetByZip("10001");
// => 10001 New York, New York (America/New_York)
Console.WriteLine(entry.PlaceName); // New York
Console.WriteLine(entry.Admin1Code); // NY
Console.WriteLine(entry.Admin1); // New York
Console.WriteLine(entry.Timezone); // America/New_York
Console.WriteLine(entry.GetLocalTime());
IZipPostLookup Methods
All static methods on ZipPostLookup target the built-in US registry. GetByCode / GetByZip, TryGetByCode / TryGetByZip, GetAllByCode / GetAllByZip, and GetByName / GetByCity are identical overloads — use whichever reads more naturally for your context.
| Method | Returns | Description |
|---|---|---|
GetByCode(code) |
CodeEntry? |
Default entry for a postal code, or null if not found |
GetByZip(zip) |
CodeEntry? |
Overload — identical to GetByCode |
TryGetByCode(code, out entry) |
bool |
Try-Parse pattern — preferred for user-supplied input |
TryGetByZip(zip, out entry) |
bool |
Overload — identical to TryGetByCode |
GetAllByCode(code) |
IReadOnlyList<CodeEntry> |
All entries for a postal code (some codes have multiple place names) |
GetAllByZip(zip) |
IReadOnlyList<CodeEntry> |
Overload — identical to GetAllByCode |
GetClosest(zip) |
CodeEntry? |
Nearest ZIP code by numeric distance when no exact match exists |
GetByName(name) |
IReadOnlyList<CodeEntry> |
All entries for a place name (case-insensitive) |
GetByCity(city) |
IReadOnlyList<CodeEntry> |
Overload — identical to GetByName |
GetByState(code) |
IReadOnlyList<CodeEntry> |
All entries for an admin level 1 code (e.g. "NY", "ON") |
GetByStateName(name) |
IReadOnlyList<CodeEntry> |
All entries for an admin level 1 name |
GetByAdmin(value, levelName) |
IReadOnlyList<CodeEntry> |
All entries for a named admin level — resolves aliases from the country schema |
GetByTimeZone(tz) |
IReadOnlyList<CodeEntry> |
All entries for an IANA timezone identifier |
GetBatch(batch) |
IReadOnlyDictionary<string, CodeEntry?> |
Look up multiple codes at once; efficient when duplicates are expected |
GetRandom() |
CodeEntry |
Random entry using Random.Shared |
GetRandom(random) |
CodeEntry |
Random entry using a provided Random instance |
GetAll() |
IReadOnlyList<CodeEntry> |
All entries sorted by postal code |
GetCountryInfo(country) |
CountryInfo? |
Country metadata (code count, curation status, format rules) |
ThrowReasonExceptions(on) |
void |
Opt in to exceptions explaining why a lookup missed (default off) — see below |
CodeEntry Properties
| Property | Type | Description |
|---|---|---|
ZpCode |
string |
Postal code — e.g. "10001" (US), "M5V3L9" (CA), "06600" (MX) |
PlaceName |
string |
Primary place name — city, locality, colonia, or community |
Timezone |
string |
IANA timezone identifier |
IsDefault |
bool |
true if primary entry when multiple place names share a code |
Admins |
AdminLevel[] |
Administrative subdivision levels — ordered largest to smallest |
Admin1 |
string? |
Value of the first admin level (state/province/estado name) |
Admin1Code |
string? |
Code of the first admin level ("NY", "ON", "JAL") |
Admin2 |
string? |
Value of the second admin level (county, municipio, etc.) |
Admin2Code |
string? |
Code of the second admin level |
Admin3 |
string? |
Value of the third admin level |
Admin3Code |
string? |
Code of the third admin level |
GetAdmin(levelName) |
AdminLevel? |
Lookup by level name or alias, e.g. GetAdmin("Province") |
GetLocalTime() |
DateTimeOffset |
Current local time in this entry's timezone |
Reason |
CodeReason |
Why this code is flagged — None, Flagged, CommonFake, or Obsolete (see Lookup Exceptions) |
Administrative subdivisions are stored as an AdminLevel[] array (ordered largest to smallest), each with Value, Code, and LevelName ("State", "Province", "Estado"). GetByAdmin and GetAdmin resolve level name aliases from the country schema.
var entry = ZipPostLookup.GetByCode("90210");
Console.WriteLine(entry.Admin1); // California
Console.WriteLine(entry.Admin1Code); // CA
Console.WriteLine(entry.Admins[0].LevelName); // State
var ca = canadaRegistry.GetByCode("T0A0A0");
Console.WriteLine(ca.Admin1); // Alberta
Console.WriteLine(ca.Admin1Code); // AB
Console.WriteLine(ca.Admins[0].LevelName); // Province
Targets
| Target | Minimum version |
|---|---|
| .NET | 5+ |
| .NET Core | 2.0+ |
| .NET Framework | 4.6.1+ |
| .NET Standard | 2.0+ |
| Mono | 5.4+ |
| Unity | 2018.1+ |
Lookups
// US
CodeEntry? entry = ZipPostLookup.GetByCode("90210");
CodeEntry? same = ZipPostLookup.GetByZip("90210"); // identical overload
if (ZipPostLookup.TryGetByCode(userInput, out var result))
Console.WriteLine(result.PlaceName);
IReadOnlyList<CodeEntry> all = ZipPostLookup.GetAllByCode("00603"); // Aguadilla + Ramey
CodeEntry? closest = ZipPostLookup.GetClosest("00001");
IReadOnlyList<CodeEntry> byName = ZipPostLookup.GetByName("New York");
IReadOnlyList<CodeEntry> byState = ZipPostLookup.GetByState("CA");
IReadOnlyList<CodeEntry> byTz = ZipPostLookup.GetByTimeZone("America/New_York");
CodeEntry random = ZipPostLookup.GetRandom();
CodeEntry seeded = ZipPostLookup.GetRandom(new Random(42));
// Canada
ZipPostRegistry ca = ZipPostLookup.CreateRegistry(CountryCode.CA);
CodeEntry? toronto = ca.GetByCode("M5V3L9"); // exact LDU
CodeEntry? anyLdu = ca.GetByCode("M5V5Z9"); // resolves via FSA range compression
IReadOnlyList<CodeEntry> ontario = ca.GetByState("ON");
IReadOnlyList<CodeEntry> bc = ca.GetByStateName("British Columbia");
// Mexico
ZipPostRegistry mx = ZipPostLookup.CreateRegistry(CountryCode.MX);
CodeEntry? ags = mx.GetByCode("20000"); // Aguascalientes
// Multi-country (US + Canada in one registry)
ZipPostRegistry na = ZipPostLookup.CreateRegistry(CountryCode.US, CountryCode.CA);
CodeEntry? newYork = na.GetByCode("10001");
CodeEntry? montreal = na.GetByCode("H2X1Y6");
// Implicit string conversion
ZipPostRegistry na2 = ZipPostLookup.CreateRegistry("us", "ca");
Lookup Exceptions (Opt-in)
By default a lookup that finds nothing returns null (or an empty list) — a silent miss. If you'd
rather be told why a lookup yielded nothing, opt in:
ZipPostLookup.ThrowReasonExceptions(true); // default is false — behaviour is unchanged unless you call this
ZipPostLookup.GetByCode("NOTAZIP"); // throws BadlyFormattedCodeException
ZipPostLookup.GetByCode("X0X0X0"); // still null — a valid format that simply isn't present
ZipPostLookup.GetByCode("10001"); // returns the entry as normal
When the toggle is on, GetByCode / GetByZip / GetAllByCode throw
BadlyFormattedCodeException for input that isn't a valid postal-code format for any supported
country (US, CA, MX) — the format is checked against the same per-country normalizers used for
lookups, so no data is needed. A well-formed code that just isn't in the data still returns
null/empty, and null input still returns null. Note that TryGetByCode / TryGetByZip
delegate to GetByCode, so with the toggle on they throw too rather than returning false —
keep the toggle off on paths that rely on the Try-pattern's no-throw contract. Reverse lookups
(GetByName / GetByState / GetByTimeZone) and GetBatch are unaffected by the toggle.
try
{
var entry = ZipPostLookup.GetByCode(userInput);
// ... use entry, or handle a null (well-formed but unknown) code
}
catch (BadlyFormattedCodeException ex)
{
Console.WriteLine($"'{ex.Code}' is not a valid US/CA/MX postal-code format.");
}
The toggle is per-registry — ThrowReasonExceptions on the static facade affects the default US
registry; registries from CreateRegistry / CreateCustomRegistry and ZpImageLookup instances each
carry their own setting. All exceptions derive from CodeReasonException, which exposes the offending
Code.
A found code can also carry a reason — CodeEntry.Reason (a CodeReason enum: None, Flagged,
CommonFake, Obsolete). With the toggle on, a found code flagged Obsolete or CommonFake throws
ObsoleteCodeException / CommonFakeDataException instead of returning the entry silently.
Flagged (a generic, unspecified flag) never throws — it's informational only, readable via
entry.Reason on a normal successful lookup.
ZipPostLookup.ThrowReasonExceptions(true);
try
{
var entry = ZipPostLookup.GetByCode(userInput);
}
catch (ObsoleteCodeException ex)
{
Console.WriteLine($"'{ex.Code}' is obsolete — decommissioned by the postal authority.");
}
catch (CommonFakeDataException ex)
{
Console.WriteLine($"'{ex.Code}' is known common fake/placeholder data.");
}
All three exceptions derive from CodeReasonException, so a single catch (CodeReasonException ex)
handles any of them uniformly.
Curation status: the reason-flag mechanism (enum, exceptions, CSV column, ZPI byte) ships in the library today. Identifying and flagging the actual obsolete/fake codes in the built-in US/CA/MX data is an ongoing curation effort — see the roadmap below.
ZipPostLookup Data Quality & Curation
ZipPostLookup is a fast, extensible, in-memory postal-code lookup library for .NET — but its real focus is data quality, integrity, and long-term maintainability.
→ Data Quality & Curation — the safeguards, data validation checks, and Gold Standard behind every release.
Roadmap to Version 2
Planned work toward the 2.0 release. The runtime library's public API is stable; most of this is data coverage, tooling, and ports.
| Item | Description |
|---|---|
| Audit CDT ✅ | Done. All CountryDataTools DB access routes through its service layer with every SQL string centralised in CommonQueries; the pipeline is consistent and maintainable. |
| AI Import Helper | An import auto command that ingests any CSV/TSV/JSON/Excel, auto-detects the postal-code column, and maps the remaining fields — using the built-in data as a ground-truth oracle — so new sources need no manual format description. In progress: sniff, oracle probe, column correlation, and ingestion phases are built and under test. |
| Code Reason Flags | Mark codes as obsolete or known fake data and let lookups throw to explain a miss. Shipped: ThrowReasonExceptions, BadlyFormattedCodeException (format check), ObsoleteCodeException / CommonFakeDataException (per-code flags), CodeEntry.Reason. Remaining: curating which US/CA/MX codes actually carry a non-None reason. |
| More countries | Expand built-in data beyond US, CA, and MX. The embedded CSV format is the contract; each new country ships as an embedded resource. |
| Unit Tests on CDT | Core coverage landed — 180+ tests over the CDT's rules, parsing, export stages, auto-import services, and DB integration (extending incrementally). |
| Python Port | A PyPI package mirroring the lookup API (get_by_code, get_by_name, get_by_state, …), backed by the same embedded CSV data. |
Why No Async Methods?
All lookup methods (GetByCode, GetByName, GetByState, etc.) are synchronous by design. Every registry is fully in-memory — GetByCode does a FrozenDictionary lookup that completes in ~6 ns. There is no I/O, no network, no disk access at query time.
Async is the right tool for I/O-bound work. Wrapping an in-memory lookup in Task or ValueTask would:
- Allocate a
Task<T>object on every call, adding GC pressure on the hot path - Signal to callers that the operation might be slow or involve I/O — which is false
- Add async state-machine overhead with zero benefit
The standard .NET libraries take the same position: FrozenDictionary, Dictionary, IMemoryCache.TryGetValue, and MemoryStream.Read are all synchronous and are called from async code constantly without issue.
If you need to run a lookup off the calling thread, use Task.Run — but given the nanosecond completion time this is rarely warranted:
// Synchronous — fine in async methods, completes in nanoseconds
var entry = registry.GetByCode("M5V3L9");
// Thread-pool dispatch if you genuinely need it
var entry = await Task.Run(() => registry.GetByCode("M5V3L9"));
The one async story on the roadmap is ICodeDataSource — if a future implementation fetches data from a remote source at construction time, a CreateRegistryAsync factory would be the appropriate addition. Lookup will remain synchronous regardless, since once loaded the data is entirely in memory.
Binary Image Lookup (net8.0+)
On .NET 8 and later, ZpImageLookup loads a pre-built Brotli-compressed binary image instead of parsing CSV rows. It implements IZipPostLookup and is a drop-in replacement with 17–26× faster startup and ~30× less memory at startup.
using ZipPostLookup.ZPImage;
// Load the built-in image
IZipPostLookup us = ZpImageLookup.FromBuiltIn(CountryCode.US);
IZipPostLookup ca = ZpImageLookup.FromBuiltIn(CountryCode.CA);
IZipPostLookup mx = ZpImageLookup.FromBuiltIn(CountryCode.MX);
// From a file on disk (e.g. a custom country image)
IZipPostLookup img = ZpImageLookup.FromFile("path/to/us.u16.zpi.br");
// From an untrusted source: bounds-check the structure and verify the
// {path}.sha256 sidecar before loading (both off by default for speed)
IZipPostLookup safe = ZpImageLookup.FromFile("path/to/us.u16.zpi.br",
validate: true, verifyHash: true);
// All IZipPostLookup methods work identically
CodeEntry? entry = us.GetByCode("90210");
Every shipped data file (.csv, .csv.br, .zpi.br) has a .sha256 sidecar committed
alongside it, so file integrity can be verified out-of-band as well.
See the Performance section for load-time benchmarks.
Dependency Injection
// Program.cs
services.AddSingleton<IZipPostLookup>(
_ => ZipPostLookup.CreateRegistry(CountryCode.US));
// Your service
public class AddressService
{
private readonly IZipPostLookup _lookup;
public AddressService(IZipPostLookup lookup) => _lookup = lookup;
public string? GetTimezone(string postalCode) =>
_lookup.GetByCode(postalCode)?.Timezone;
}
Custom Data Sources
public class MilitaryZipSource : ICodeDataSource
{
public string SourceName => "military-apo";
public IEnumerable<CodeEntry> GetEntries()
{
yield return new CodeEntry(
ZpCode: "09001",
PlaceName: "APO",
Timezone: "Europe/Berlin",
IsDefault: true,
Admins: new[] { new AdminLevel("Armed Forces Europe", "AE", "Territory") });
}
}
var registry = ZipPostLookup.CreateCustomRegistry(new MilitaryZipSource());
var entry = registry.GetByCode("09001");
Country Metadata
// Via the static facade
CountryInfo? us = ZipPostLookup.GetCountryInfo(CountryCode.US);
Console.WriteLine(us?.CountryName); // United States
Console.WriteLine(us?.CodeCount); // 43,498
Console.WriteLine(us?.CurationStatus); // Curated
// All countries in the embedded data
foreach (var (id, info) in CountryInfoRegistry.All)
Console.WriteLine(info); // e.g. "US — United States (43,265 codes, curated)"
Performance
On .NET 8+, all indexes use FrozenDictionary. The .zpi.br frozen image loads 17–26x faster than the CSV registry at startup with ~30x less memory:
| Country | CSV registry build | ZP image load | Speedup |
|---|---|---|---|
| US | 136 ms | 7.8 ms | 17x |
| MX | 375 ms | 14.3 ms | 26x |
| CA | 1,373 ms | 55.2 ms | 25x |
The CA build was ~33 s before the 2026-06-12 indexing fix (a quadratic insert in registry construction); it is now ~1.4 s, which is why the image's cold-start lead over CSV is far smaller than it once was. The image still wins decisively on startup memory (~30× less) and remains the better default for cold-start-sensitive deployments.
GetByCode runs at ~5.8 ns (hit) / ~31 ns (miss) on .NET 8+ — 5.6x faster than Zip2City on hot-path lookups with zero allocations on hits. GetBatch is more efficient than a sequential loop when duplicate codes are expected; see ZipPostLookup.Benchmarks/README.MD for full results including batch lookup guidance, international lookups, and ZPI vs CSV comparisons.
Data
| Country | Distinct Codes* | Admin Levels | Timezones | Status |
|---|---|---|---|---|
| US | 43,501 | State | 56 | ✅ Curated |
| CA | 901,487 | Province | 29 | ✅ Curated |
| MX | 32,896 | Estado | 20 | ✅ Curated |
* Distinct codes are the number of unique Zip/Postal codes in the country's set.
Code counts and status, last updated on: (2026-07-02 17:28:19)
Contributing Data
New country data is prepared using the companion ZipPostLookup.CountryDataTools CLI. The general flow: obtain a GeoNames or OSM source file → validate and fix with validate / fix → import with ingest candidate → resolve timezone discrepancies → export with export --all. Run snapshot to regenerate all three countries' CSV and ZPI artifacts in one step. Mark exported files as EmbeddedResource in ZipPostLookup.csproj and add tests covering at minimum: a known code lookup, an admin/state lookup, a timezone lookup, and a GetAll count assertion.
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.2 | 276 | 7/6/2026 |
| 1.0.1 | 116 | 6/13/2026 |
| 1.0.0 | 268 | 6/12/2026 |
| 1.0.0-rc.2 | 83 | 6/12/2026 |
| 1.0.0-rc.1 | 82 | 6/9/2026 |