- Released documentation: https://pkg.go.dev/github.com/ringsaturn/tzf/v2
- Try it online: tzf-web
Note
Version 2 is protobuf-free. The data source is the TZF embedded binary
format (.tzb) and its memory-image profile (.tzm), shipped by
tzf-dist, and the public surface
is five constructors that all return tzf.F. See
Migrating from v1.
Install via:
go get github.com/ringsaturn/tzf/v2Note
NewDefaultFinder uses simplified shape data so it is not entirely
accurate around the border, but the error is small and bounded: every
simplified boundary stays within ~111 m of the full-precision border. See
Accuracy for measured numbers.
It's expensive to init a tzf finder, please consider reusing it or creating it as a global var. Below is a global var example:
package main
import (
"fmt"
tzf "github.com/ringsaturn/tzf/v2"
)
var f tzf.F
func init() {
var err error
f, err = tzf.NewDefaultFinder()
if err != nil {
panic(err)
}
}
func main() {
// In longitude-latitude order
fmt.Println(f.GetTimezoneName(116.3883, 39.9289))
fmt.Println(f.GetTimezoneName(-73.935242, 40.730610))
}Every finder is safe for concurrent use.
v2 exposes exactly five constructors, all returning the same tzf.F
interface (GetTimezoneName, GetTimezoneNames, TimezoneNames,
DataVersion); the mechanism behind a finder is not part of the API. All
bundled artifacts carry the same dataset (2026c, 444 timezone names); the
mechanism and the precision differ between them.
| Constructor | Mechanism | Memory | Query (p50, random city / border) |
|---|---|---|---|
NewDefaultFinder() |
lite .tzm memory image: preindex fast path + polygon view aliasing rodata |
12.8 MiB heap + ~10 MB rodata | 208 ns / 542 ns |
NewEmbeddedFinder() |
lite .tzb queried in place, no geometry expansion |
<1 KB heap + ~4 MB rodata | 583 ns / 6.9 µs |
NewFullFinder() |
full-precision .tzb expanded at load |
146.7 MiB heap | 208 ns / 666 ns |
NewFinderFromTZB(data) |
any .tzb, always expanded; data released after load |
27.5 MiB heap (lite dataset) | 208 ns |
NewFinderFromTZM(data) |
any .tzm, always aliased in place; data retained |
as NewDefaultFinder |
208 ns |
Measured on Apple M3 Max with the 2026c dataset (make bench,
make bench-memory); "memory" is retained heap after GC, so the rodata the
go:embeded artifact occupies is listed separately.
GetTimezoneName is fuzzy-first: every bundled artifact carries a FUZZY
preindex section, so most queries resolve from a tile lookup without any
point-in-polygon work, and near a border the query falls through to exact ray
casting.
GetTimezoneNames runs the polygon scan in every finder. It is the call to
use when a point can belong to more than one timezone, which happens at the
nautical zone meridians and at disputed borders. Results are sorted
lexicographically, and a point exactly on a shared border belongs to every
touching polygon.
If you require a query result that is 100% accurate, use the full-precision finder (reuse it when possible):
package main
import (
"fmt"
tzf "github.com/ringsaturn/tzf/v2"
)
func main() {
finder, err := tzf.NewFullFinder()
if err != nil {
panic(err)
}
fmt.Println(finder.GetTimezoneName(139.6917, 35.6895))
}NewFullFinder() is more expensive to init and uses much more memory than
NewDefaultFinder(), but it provides 100% accuracy. See
Performance for details.
The choice between constructors is governed by memory and startup cost. On a
random city, median query latency is 208 ns for both NewDefaultFinder and
NewFullFinder (Apple M3 Max, 2026c); the two differ on the 0.41% of
boundary length the simplification displaces by more than 100 m (see
Accuracy) and in retained heap.
| Situation | Constructor | Reason |
|---|---|---|
| Long-lived service, memory is not constrained | NewDefaultFinder() |
Geometry stays in read-only data; retained heap 12.8 MiB |
| Answers must match the source boundaries exactly | NewFullFinder() |
Full-precision geometry; retained heap 146.7 MiB |
| Container with a tight memory limit, CLI, FaaS, IoT | NewEmbeddedFinder() |
Total footprint is the 3.97 MB file plus under 1 KB of heap; opens in 1.7 ms |
| No filesystem at runtime (scratch/distroless image, Wasm) | any pre-defined constructor | The artifacts are go:embeded by tzf-dist, so nothing is read from disk |
Data delivered out-of-band (object store, config map, own go:embed) |
NewFinderFromTZB(data) |
One expansion pass, after which data can be collected |
| mmap'd artifact shared between processes on one host | NewFinderFromTZM(mapped) |
Ring storage aliases the mapping, which the page cache shares; the mapping must stay live |
| Caller-owned bytes that must not be copied or modified | x.NewFinderFromTZBReaderAt(bytes.NewReader(data), int64(len(data))) |
v2 has no InPlace() option; see x |
| Single-core or cgroup CPU quota, startup latency matters | NewEmbeddedFinder(), otherwise NewDefaultFinder() |
Geometry expansion and index rebuild parallelize across cores and degrade to sequential under a quota; in-place open performs neither |
| Many cores available at startup | NewDefaultFinder() / NewFullFinder() |
Item assembly and the per-ring index build run across GOMAXPROCS |
Open times on Apple M3 Max with the 2026c dataset (16 cores / 1 core, the
single-core figure modelling a cgroup-quota pod):
| Mechanism | Open 16c | Open 1c |
|---|---|---|
lite .tzb in place (embedded) |
1.7 ms | 1.7 ms |
lite .tzm (default) |
7.7 ms | 28 ms |
lite .tzb expanded |
18.6 ms | ~41 ms |
full .tzb expanded (full) |
78.5 ms | 214 ms |
.tzb is the transport format. .tzm holds the same data in the layout the
query path uses directly, which makes the file larger; tzf-dist ships a
lite.tzm for NewDefaultFinder, and no full-precision .tzm is
distributed. Derive one locally to use the memory-image mechanism over your
own data:
go run github.com/ringsaturn/tzf/v2/cmd/tzb2tzm@latest -o lite.tzm lite.tzbThe transcode is protobuf-free and its output is byte-identical to building
the .tzm from source, so the compact .tzb can be shipped and the memory
image produced at deploy time or during the image build.
The two byte constructors differ in their contract for data:
NewFinderFromTZB releases it once loading is done. NewFinderFromTZM uses
it as live polygon storage, so the bytes must stay live and unmodified for the
finder's lifetime. Aliasing also requires the slice to be 8-byte aligned; a
misaligned or big-endian host falls back to a one-time decoded copy, which
produces the same results and does not reduce memory.
F covers the four query methods only, so GeoJSONer is a separate
interface and test doubles and third-party F implementations need not
produce geometry. Constructors return F, so assert the behavior:
finder, err := tzf.NewDefaultFinder()
if err != nil {
panic(err)
}
exporter, ok := finder.(tzf.GeoJSONer)
if !ok {
panic("finder cannot export geometry")
}
// Serialized GeoJSON FeatureCollection bytes.
tokyo, err := exporter.GetTZGeoJSON("Asia/Tokyo")
world := exporter.GetGeoJSON()
// The preindex tiles GetTimezoneName answers from directly — useful for
// visualizing where the fast path applies.
tiles, err := exporter.GetTZPreindexGeoJSON("Asia/Tokyo")
allTiles, err := exporter.GetPreindexGeoJSON()Every finder this package constructs satisfies GeoJSONer, including
NewEmbeddedFinder, which decodes only the requested timezone's rings from
the file on demand. Output is byte-identical across mechanisms.
Note
This feature is designed for data visualization purposes. Please do proper performance tests before using it in a high-performance production path, for example by caching the exported GeoJSON or pushing it to a CDN.
github.com/ringsaturn/tzf/v2/x
holds experimental surface: useful in production, and exempt from the
module's semantic-versioning promise. Within v2.y.z, a bump of y may
change or remove anything in x; only z bumps are guaranteed not to. The
root package keeps the normal promise: breaking changes only at v3. The
convention follows golang.org/x/...; pin an exact version if you depend on
x and cannot absorb a break at a minor release.
It holds one entry point, for in-place queries over any io.ReaderAt: a
file, an mmap'd region, an embedded flash adapter, or a bytes.Reader over
bytes already in memory.
import (
"bytes"
"github.com/ringsaturn/tzf/v2/x"
)
// In-place over caller-owned bytes (v2 has no InPlace() option):
finder, err := x.NewFinderFromTZBReaderAt(bytes.NewReader(data), int64(len(data)))
// Or straight off a file, without reading it into memory:
file, err := os.Open("lite.tzb")
info, err := file.Stat()
finder, err := x.NewFinderFromTZBReaderAt(file, info.Size())Semantics match NewEmbeddedFinder, including the FUZZY fast path, and the
result satisfies tzf.GeoJSONer. ReaderAt access is serialized internally
to keep queries allocation-free, so throughput does not scale with core count
the way it does for the byte-backed and expanded finders.
In addition to using tzf as a library in your Go projects, you can also use the tzf command-line interface (CLI) tool to quickly get the timezone name for a set of coordinates. To use the CLI tool, you first need to install it using the following command:
go install github.com/ringsaturn/tzf/v2/cmd/tzf@latestOnce installed, you can use the tzf command followed by the latitude and longitude values to get the timezone name:
tzf -lng 116.3883 -lat 39.9289Alternatively if you want to look up multiple coordinates efficiently you can specify the ordering and pipe them to the tzf command one pair of coordinates per line:
echo -e "116.3883 39.9289\n116.3883, 39.9289" | tzf -stdin-order lng-latYou can download the original data from https://github.com/evansiroky/timezone-boundary-builder.
The preprocessed binary data can be obtained from
https://github.com/ringsaturn/tzf-dist, which has Go's embed support. The
artifact set is:
| Artifact | Size | Backs |
|---|---|---|
lite.tzb |
3.97 MB | NewEmbeddedFinder, x, generic TZB use |
lite.tzm |
10.18 MB | NewDefaultFinder |
full.tzb |
13.77 MB | NewFullFinder |
All three carry the same data_version, and every file bundles its FUZZY
preindex section, so there is no separate preindex artifact to keep in sync.
The .tzb container is a sectioned little-endian format with a CRC32 footer;
.tzm holds the same content in the profile whose sections are already the
query-time structures.
The data pipeline for tzf can be illustrated as follows. It runs directly from the upstream raw GeoJSON, with no protobuf step at any stage; the gob intermediates are build-internal and are never distributed:
graph TD
Raw[GeoJSON from evansiroky/timezone-boundary-builder]
Full[Timezones .gob, full precision]
Simplified[Timezones .topology.gob<br/>topology-aware simplified]
SimplifiedTopo[TopoTimezones .topology.topo.gob]
FullTopo[TopoTimezones .topo.gob]
SimplifiedCompressTopo[CompressedTopoTimezones<br/>.topology.compress.topo.gob]
FullCompressTopo[CompressedTopoTimezones<br/>.compress.topo.gob]
Preindex[PreindexTimezones<br/>.topology.preindex.gob]
LiteTZB[lite.tzb ~4MB]
LiteTZM[lite.tzm ~10MB]
FullTZB[full.tzb ~14MB]
Raw --> |cmd/geojson2tzpb|Full
Full --> |cmd/reducetzpb -topology|Simplified
Full --> |cmd/deduplicatetzpb|FullTopo
FullTopo --> |cmd/compresstopotzpb|FullCompressTopo
Simplified --> |cmd/deduplicatetzpb|SimplifiedTopo
SimplifiedTopo --> |cmd/compresstopotzpb|SimplifiedCompressTopo
Simplified --> |cmd/preindextzpb|Preindex
SimplifiedCompressTopo --> |cmd/topo2embed -profile e -preindex|LiteTZB
Preindex --> |cmd/topo2embed -preindex|LiteTZB
LiteTZB --> |cmd/tzb2tzm|LiteTZM
FullCompressTopo --> |cmd/topo2embed -profile e -preindex|FullTZB
Preindex --> |cmd/topo2embed -preindex|FullTZB
LiteTZM --> |tzf.NewDefaultFinder|D[DefaultFinder]
LiteTZB --> |tzf.NewEmbeddedFinder|E[EmbeddedFinder]
FullTZB --> |tzf.NewFullFinder|F[FullFinder]
full.tzb preserves full geometric precision with shared-edge deduplication
and polyline compression. lite.tzb / lite.tzm apply topology-aware
Douglas-Peucker simplification (~85% point reduction) first, so they may not
be perfectly accurate at some border areas; the deviation is bounded to
~111 m (see Accuracy).
I have written an article about the history of tzf, its Rust port, and its Rust port's Python binding; you can view it here.
The Douglas-Peucker simplification uses an epsilon of 0.001 degrees, which
caps boundary displacement at roughly 111 m by construction. Measured against
the full-precision 2026c dataset with internal/cmd/borderchange (spherical
model, certified via Lipschitz interval subdivision):
| Metric | Result |
|---|---|
| Certified maximum boundary displacement | 111.7 m (+1.0 m tolerance) |
| Boundary length displaced more than 100 m | 0.41% |
| Boundary length displaced more than 500 m | 0% |
| Total mis-assigned area | 16,962 km² (~0.003% of Earth) |
| Mis-assigned area within 100 m of the true border | 92.8% |
In other words, only queries that land within ~111 m of a timezone border can
ever differ from the full-precision result, and most of that band is far
narrower. If your use case is sensitive inside that band, use
NewFullFinder().
Verify the accuracy yourself by running the following commands:
# Runs the pipeline from the upstream raw GeoJSON; the gob intermediates
# land in tmp/tzf-dist-dev.
./scripts/build-tzf-dist-dev.sh
go run ./internal/cmd/topodecode \
tmp/tzf-dist-dev/combined-with-oceans.compress.topo.gob \
combined-with-oceans.dist.gob
go run ./internal/cmd/borderchange \
-epsilon 0.001 \
-certification-tolerance-m 0.5 \
-top-pairs 20 \
combined-with-oceans.dist.gob > BORDER_CHANGE.mdMore details: BORDER_CHANGE.md.
The tzf package is intended for high-performance geospatial query backend
services, such as weather forecasting APIs. Median query latency is 208 ns on
random world cities and 542 ns on border cases for NewDefaultFinder (Apple
M3 Max, 2026c).
Here is what has been done to improve performance:
- Using the simplified dataset by default.
- Using the pre-index carried in the file's FUZZY section to handle most queries without any point-in-polygon work.
- Using the internal
geompackage (fork of geojson) with a YStripes index (inspired by Josh Baker'stg) to verify whether a polygon contains a point. Also a dense 1°×1° grid index carried by the file to quickly find candidate polygons, inspired by Aaron Roney's rtz. - Storing ring coordinates as 1e5-scaled
int32pairs (8 bytes per point). In the.tzmprofile these are aliased directly from the embedded bytes, so no geometry is copied onto the heap at load.
That's all. There are no black magic tricks inside the tzf package.
Below is a benchmark run on my MacBook Pro with Apple M3 Max, 2026c dataset
(make bench, make bench-memory). Memory is retained heap after GC, so the
in-place finders report 0.00: their storage is the embedded read-only data.
| Target | Dataset | Scenario | Median (ns) | p99 (ns) | Approx throughput (ops/s) | Memory (MiB) |
|---|---|---|---|---|---|---|
| DefaultFinder | lite .tzm memory image | edge case · GetTimezoneName | 542.0 | 1709.0 | 1497.5K | 12.80 |
| EmbeddedFinder | lite .tzb, queried in place | edge case · GetTimezoneName | 6917.0 | 30250.0 | 109.7K | 0.00 |
| FullFinder | full .tzb, expanded at load | edge case · GetTimezoneName | 666.0 | 2417.0 | 1244.2K | 146.70 |
| DefaultFinder | lite .tzm memory image | random world cities · GetTimezoneName | 208.0 | 1083.0 | 3254.1K | 12.80 |
| EmbeddedFinder | lite .tzb, queried in place | random world cities · GetTimezoneName | 583.0 | 21917.0 | 431.4K | 0.00 |
| FinderFromTZB | lite .tzb, expanded at load | random world cities · GetTimezoneName | 208.0 | 1083.0 | 3196.9K | 27.50 |
| FullFinder | full .tzb, expanded at load | random world cities · GetTimezoneName | 208.0 | 1041.0 | 3311.3K | 146.70 |
| DefaultFinder | lite .tzm memory image | random world cities · GetTimezoneNames | 500.0 | 2292.0 | 1497.7K | 12.80 |
| EmbeddedFinder | lite .tzb, queried in place | random world cities · GetTimezoneNames | 8750.0 | 35625.0 | 99.7K | 0.00 |
| FullFinder | full .tzb, expanded at load | random world cities · GetTimezoneNames | 542.0 | 2250.0 | 1402.7K | 146.70 |
- https://ringsaturn.github.io/tz-benchmark/ displays a continuous benchmark comparison with other packages.
v1 loaded protobuf artifacts (CompressedTopoTimezones, PreindexTimezones);
those artifacts are no longer published, and v2 removes every protobuf-typed
API. The v1 line is frozen at its last data release; updated boundaries
require moving to v2.
Update the import path (github.com/ringsaturn/tzf →
github.com/ringsaturn/tzf/v2), then map the call sites:
| v1 | v2 |
|---|---|
tzf.NewDefaultFinder() |
tzf.NewDefaultFinder() (unchanged call sites; now backed by lite.tzm) |
tzf.NewFullFinder() |
tzf.NewFullFinder() (unchanged call sites) |
*tzf.Finder, *tzf.DefaultFinder (named types) |
removed; constructors return the tzf.F interface |
tzf.FuzzyFinder, tzf.NewFuzzyFinderFromPB |
removed, no replacement; the preindex is the fast path inside every finder |
tzf.NewFinderFromCompressedTopo(pb) |
tzf.NewFinderFromTZB(data) |
tzf.NewFinderFromCompressed(pb) |
tzf.NewFinderFromTZB(data) |
tzf.NewFinderFromPB(pb) |
convert offline with cmd/geojson2tzpb … cmd/topo2embed, then NewFinderFromTZB |
tzf.NewFinderFromRawJSON(...) |
removed; build a .tzb with the pipeline commands instead |
tzf.NewFinderFromTZBExpanded(data) |
tzf.NewFinderFromTZB(data) (expansion is now the only TZB behavior) |
tzf.NewDefaultFinderFromTZB/TZM, NewFuzzyFinderFromTZB |
tzf.NewFinderFromTZB / tzf.NewFinderFromTZM (composition is derived from the file) |
tzf.NewFinderFromTZBReaderAt(r, size) |
x.NewFinderFromTZBReaderAt(r, size); see the x stability policy |
| in-place querying over caller-owned bytes | x.NewFinderFromTZBReaderAt(bytes.NewReader(data), int64(len(data))); same policy |
f.(*tzf.Finder).GetTZGeoJSON(name) |
f.(tzf.GeoJSONer).GetTZGeoJSON(name) |
FuzzyFinder tile-bbox GeoJSON |
f.(tzf.GeoJSONer).GetTZPreindexGeoJSON(name) / GetPreindexGeoJSON() |
convert.Do, convert.Revert, reduce, preindex (public packages) |
internal; drive the pipeline through the cmd/ binaries |
Behavior changes:
GetTZGeoJSON/GetGeoJSONreturn serialized GeoJSON bytes; the*convert.BoundaryFilereturn type is gone and the boundary-file types are internal. Unmarshal into your own struct, or intomap[string]any, when the parsed tree is needed.GetTimezoneNamesresults are sorted lexicographically.- A point exactly on a shared border belongs to every touching polygon (exterior rings allow on-edge, hole rings do not).
- New:
NewEmbeddedFinder, an in-place low-memory mechanism (~4 MB total).
| Language or Sever | Link | Note |
|---|---|---|
| Go | ringsaturn/tzf |
|
| Ruby | HarlemSquirrel/tzf-rb |
build with tzf-rs |
| Rust | ringsaturn/tzf-rs |
|
| Swift | ringsaturn/tzf-swift |
|
| Python | ringsaturn/tzfpy |
build with tzf-rs |
| HTTP API | racemap/rust-tz-service |
build with tzf-rs |
| JS via Wasm(browser only) | ringsaturn/tzf-wasm |
build with tzf-rs |
| Online | ringsaturn/tzf-web |
build with tzf-wasm |
See Project tzf for more information.
- https://github.com/paulmach/orb (used via the
ringsaturn/orb fork, which drops the
BSON/
mongo-driverdependency) - https://github.com/tidwall/geojson
- https://github.com/tidwall/tg
- https://github.com/jannikmi/timezonefinder
- https://github.com/evansiroky/timezone-boundary-builder
- And other projects listed in NOTICE
If you use tzf in academic work, please cite it via CITATION.cff, or use GitHub's "Cite this repository" button.
This project is licensed under the MIT license.
The data is licensed under the
ODbL license,
same as
evansiroky/timezone-boundary-builder
