Skip to content
tzf v2

tzf v2

The Spring 2026 update finished the data side of tzf: topology-aware simplification, shared-edge deduplication, and polyline compression brought the full-precision dataset from about 90 MB to about 17 MB. What it did not change was how that data reaches memory. Every implementation still parsed a protobuf message into an object graph at startup, then built its query structures from that graph and discarded it.

v2 removes protobuf from both the runtime and the pipeline. Boundary data now ships in a container the finders read directly.

Reasons for removing protobuf

Three costs are attributable to the parse step.

Open time. Loading the full-precision dataset in Go took 288 ms. The same dataset in the new format opens in 78.5 ms. In Rust, the lite dataset went from 71 ms to 18 ms cold.

Peak memory during load. The decoded message and the query structures built from it are live at the same time, so the high-water mark exceeds the steady state by a wide margin: in the 2026-09-11 benchmark snapshot, Go’s full finder peaks at 315.0 MiB while retaining 147.0 MiB. That peak is what a container memory limit has to accommodate.

No in-place option. A protobuf message has to be fully parsed before any field is readable, which rules out answering a query by reading a few kilobytes of a file. There was no mechanism for deployments limited to a few tens of megabytes of RAM.

The pipeline that builds the data also carried protobuf, along with generated code and a schema compiler in the build. Intermediates now use native Go structs with encoding/gob; they are build-internal and are not distributed. Rebuilding the full dataset from raw GeoJSON produces a byte-identical full.tzb, which is how the change was verified.

The embedded binary format

The .tzb file is a sectioned little-endian container: a 64-byte header, a section table, the data sections, and a CRC32 footer. Directories are fixed-width records, every ring, group and chunk carries a bounding box, and coordinates are int32 degrees scaled by 100000. A reader locates a candidate timezone through a 1° × 1° grid section, then descends through the bounding boxes and decodes only the geometry that survives them.

Two profiles share the container, selected by a header byte:

ProfileExtensionGeometry storage
E (embedded).tzbChunked zigzag-LEB128 varint streams
M (memory image).tzmOne flat array of (int32, int32) pairs

The M profile stores points in the layout the query code uses, so ring storage points directly at the mapped file with no decode and no copy. It is larger: 10.18 MB against 3.97 MB for the same lite dataset. full.tzm would be about 67 MB and is not published; it is generated on the host that uses it, withcmd/tzb2tzm.

Section type 10 carries the tile preindex, which earlier versions distributed as a separate file. On the 2026c dataset it holds 87,572 tiles, 156 of which name two timezones, in about 880 KB. All three published artifacts carry it.

The full layout is documented inEmbedded Binary Format.

Finders

Go

Five constructors, all returning the tzf.F interface. The package exports no finder types and no options.

ConstructorDataResidentQuery
NewDefaultFinder()lite .tzm, aliased in place~12 MB heap + 10 MB read-only298 ns
NewEmbeddedFinder()lite .tzb, queried in place~3 MB~6 µs
NewFullFinder()full .tzb, expanded~145 MB~300 ns
NewFinderFromTZB(data)caller-supplied .tzbfile-dependent~290 ns
NewFinderFromTZM(data)caller-supplied .tzmfile-dependent~300 ns

NewEmbeddedFinder is new. It holds under 1 KB of heap beyond the file bytes and queries without allocating. For bytes the caller already owns, or an mmap’d region, x.NewFinderFromTZBReaderAt takes an io.ReaderAt; the x package is outside the module’s semantic-versioning promise.

GeoJSONer is asserted rather than being part of F, and now exports the preindex tiles as well as the boundary polygons.

Rust

tzf-rs 2.0 has DefaultFinder and EmbeddedFinder. FuzzyFinder, Finder andFinderOptions are gone: the preindex is the fast path inside both finders, and the YStripes index is always built. The crate reads .tzb only, and returnsError::Profile for .tzm bytes. The crates.io package carries lite.tzb (~4 MB); full.tzb (~14 MB) comes from git behind the full feature.

Byte constructors now return Result: files are CRC-checked and structurally validated at open, so malformed data surfaces as an error rather than an empty finder.

Python

tzfpy 2.0 binds tzf-rs 2.0. The four query functions are unchanged, and two GeoJSON exporters were added. Dropping the protobuf path took the wheel from 4.31 MB to 2.76 MB and finder initialization from 68 ms to 15 ms, with query latency unchanged. The _TZFPY_DISABLE_Y_STRIPES escape hatch was removed along with the option it controlled.

Measurements

Apple M3 Max, 2026c dataset. Open times and resident memory from the v2 design records; query latency, accuracy and memory columns from thetz-benchmark snapshot of 2026-09-11, taken locally on the same machine against the published 2.0.0 releases.

PathOpenResidentQuery, world cities
Go, full protobuf (v1)288 ms~153 MB~290 ns
Go, full .tzb expanded78.5 ms~145 MB~300 ns
Go, lite .tzm7.7 ms~12 MB heap + 10 MB read-only298 ns
Go, lite .tzb in place1.7 ms~3 MB~6 µs
Rust, lite protobuf (v1)71 ms77.8 MiB peak RSS316 ns
Rust, lite .tzb18 ms cold44.1 MiB peak RSS260 ns

Go and Rust return identical GetTimezoneName and GetTimezoneNames results over about 195,000 boundary-heavy samples per artifact, plus the world-cities set. Against full-precision ground truth, the lite finders disagree on 1 of 154,694 world cities (0.0006%), and that answer resolves to the same UTC offset.

A .tzm loader was also built for Rust. It saved about 3 ms of open time and raised peak RSS from 44.1 MiB to 68.7 MiB, and was removed; tzf-rs consumes the E profile only.

Migration

The query interfaces are unchanged in all three languages. Most call sites need only the dependency change.

  • Go: the module path becomes github.com/ringsaturn/tzf/v2. See theGo guide for the mapping table.
  • Rust: DefaultFinder::new() and new_full() keep their signatures; the removed types are listed in the Rust guide.
  • Python: see the Python guide.
  • Choosing a Finder covers which constructor fits a given deployment.

Status of the v1 line

tzf v1.2.x, tzf-rs 1.3.x and tzfpy 1.3.x remain available and continue to work. tzf-dist stops publishing the protobuf artifact set, so those versions are frozen at their last data release; updated boundaries require moving to v2. tzf-wasm 2.0.0 builds on tzf-rs 2.0.0 and tzf-web serves it. tzf-swift builds on the v1 line. tzf-rb is a third-party Ruby binding maintained by HarlemSquirrel and currently builds on the v1 line.

Acknowledgements

The YStripes index is from Josh Baker’stidwall/tg; tzf ports it rather than reimplementing it. The boundary data comes fromevansiroky/timezone-boundary-builder, which tracks IANA timezone database releases. The v2 implementation and the parity work between Go and Rust were carried out with Claude and Codex across multiple rounds of implementation, verification and refactoring.

Last updated on