ZipPostLookup 1.0.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package ZipPostLookup --version 1.0.1
                    
NuGet\Install-Package ZipPostLookup -Version 1.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ZipPostLookup" Version="1.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ZipPostLookup" Version="1.0.1" />
                    
Directory.Packages.props
<PackageReference Include="ZipPostLookup" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ZipPostLookup --version 1.0.1
                    
#r "nuget: ZipPostLookup, 1.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ZipPostLookup@1.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ZipPostLookup&version=1.0.1
                    
Install as a Cake Addin
#tool nuget:?package=ZipPostLookup&version=1.0.1
                    
Install as a Cake Tool

ZipPostLookup

Fast, extensible in-memory postal code lookup for .NET.
Built-in data for the United States (43,501 ZIP codes), Canada (~901,512 postal codes, 621k compressed entries via FSA range compression), and Mexico (32,893 postal codes). All three countries are fully curated ✅.

Code counts and status, last updated on: (2026-06-13 10:48:49)


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)

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

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");

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 Harden the CountryDataTools data-curation CLI — route all DB access through its service layer and centralise every SQL string, so the pipeline is consistent and maintainable.
AI Import Helper An import auto command that ingests any CSV/TSV/JSON, 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.
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 Add automated test coverage to the CountryDataTools pipeline (currently covered only by the library's own test suite).
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");

// All IZipPostLookup methods work identically
CodeEntry? entry = us.GetByCode("90210");

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 54 ✅ Curated
CA 901,512 Province 28 ✅ Curated
MX 32,893 Estado 17 ✅ Curated

* Distinct codes are the number of unique Zip/Postal codes in the country's set.

Code counts and status, last updated on: (2026-06-13 10:48:49)


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

MIT

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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