Skip to content

Repository files navigation

tzf-rs: a fast timezone finder for Rust. Rust Documentation Crates.io Version FOSSA Status

Time zone map of the world

Note

Version 2 is protobuf-free. The data source is the TZF embedded binary format (.tzb) shipped by tzf-dist, and the public surface is two finder types: DefaultFinder and EmbeddedFinder. See Migrating from v1.

Quick start

cargo add tzf-rs
use tzf_rs::DefaultFinder;

fn main() {
    let finder = DefaultFinder::new();
    // Please note coords are lng-lat.
    println!("{:?}", finder.get_tz_name(116.3883, 39.9289));
    println!("{:?}", finder.get_tz_names(116.3883, 39.9289));
}

By default the tzf CLI binary is built as well. If you don't want/need it, you can omit the default features and build like this:

cargo build --no-default-features --features bundled

Finders

  • DefaultFinder — the recommended general-purpose finder. Expands the file's geometry into materialized polygons at load time. get_tz_name answers most queries from the preindex tiles in ~100 ns and falls back to exact point-in-polygon (YStripes-accelerated ray casting) for boundary cases; get_tz_names is always polygon-exact.
  • EmbeddedFinder — the low-memory finder. Queries the .tzb bytes in place, without expanding geometry: total footprint is roughly the ~4 MB file plus a small open-time index (chunk skip blocks and per-group latitude stripes, ~100 KB on lite). Boundary queries take a few microseconds instead of hundreds of nanoseconds; results are identical to DefaultFinder.

Both also load caller-supplied bytes: DefaultFinder::from_tzb / EmbeddedFinder::from_tzb (EmbeddedFinder takes &'static [u8] or an owned Vec<u8>). Both expose get_tz_name, get_tz_names, timezonenames and data_version (the upstream release the file was built from, e.g. 2026c), and — with export-geojsonto_geojson, get_tz_geojson, to_preindex_geojson and get_tz_preindex_geojson.

Cargo features

Feature Default Effect
bundled yes Embeds the lite .tzb (~4 MB) from the crates.io tzf-dist package.
clap yes Builds the tzf CLI binary.
export-geojson no GeoJSON export methods (pulls in serde / serde_json).
full no Full-precision .tzb (~14 MB) plus DefaultFinder::new_full(). The data is git-only; see Setup 100% Accurate Lookup. Mutually exclusive with bundled.

Minimum supported Rust version: 1.88 (edition 2024 plus let-chains).

There is no wasm-specific feature. wasm32-unknown-unknown builds with --no-default-features --features bundled (drop clap). Load-time polygon assembly is distributed over std::thread::available_parallelism(), which returns an error on wasm; the loader then takes the single-threaded path, so no threads are spawned there.

Best Practices

It's expensive to init tzf-rs's DefaultFinder/EmbeddedFinder, so please consider reusing instances or creating one as a global variable:

use std::sync::LazyLock;
use tzf_rs::DefaultFinder;

static FINDER: LazyLock<DefaultFinder> = LazyLock::new(DefaultFinder::new);

fn main() {
    // Please note coords are lng-lat.
    println!("{:?}", FINDER.get_tz_name(116.3883, 39.9289));
    println!("{:?}", FINDER.get_tz_names(116.3883, 39.9289));
}

For reuse, racemap/rust-tz-service provides a good example.

A Redis protocol demo could be used here: ringsaturn/redizone.

Setup 100% Accurate Lookup

By default, tzf-rs uses simplified shape data. The error around borders is small and bounded: every simplified boundary stays within ~111 m of the full-precision border. See Accuracy for measured numbers. If you need 100% accurate lookup, use the full-precision dataset (~14 MB).

full.tzb exceeds the crates.io package size limit, so the tzf-dist package published to the registry ships only lite.tzb. The full feature therefore needs the tzf-dist git source. There are two ways to get it.

A. Take tzf-rs itself from git (the v1 recipe, still supported):

tzf-rs = { git =  "https://github.com/ringsaturn/tzf-rs", rev = "v{X}.{Y}.{Z}", features = ["full"], default-features = false }

B. Keep tzf-rs from crates.io and patch the data crate: add a [patch.crates-io] section to the workspace root manifest.

[dependencies]
tzf-rs = { version = "2", features = ["full"], default-features = false }

[patch.crates-io]
tzf-dist = { git = "https://github.com/ringsaturn/tzf-dist", tag = "v0.0.2026-c-tzb1" }

Either way default-features = false is required: full and the default bundled feature are mutually exclusive, and enabling both is a compile_error!. Without one of these two recipes, features = ["full"] fails to compile, because the registry tzf-dist package contains no full dataset.

use tzf_rs::DefaultFinder;

fn main() {
    let finder = DefaultFinder::new_full();
    println!("{}", finder.timezonenames().len());
    let tz_name = finder.get_tz_name(139.767125, 35.681236);
    println!("tz_name: {}", tz_name);
}

This setup requires more time and memory to build the DefaultFinder.

Advanced Usage - Export GeoJSON

Note

This feature is designed for data visualization purposes and I can't guarantee the performance when using it in high-performance scenarios. Please do proper performance tests and necessary optimizations before using it in high performace production, for example caching the exported GeoJSON data or push to CDN.

It's a common use case make some visualization of timezone boundaries. For this purpose, tzf-rs provides methods to export specific timezone polygons as GeoJSON format.

To enable this feature, you need to build tzf-rs with export-geojson feature:

tzf-rs = { version = "{version}", features = ["export-geojson"]}

Then you can use the following methods:

// examples/query_tokyo.rs
use tzf_rs::DefaultFinder;

fn main() {
    let default_finder = DefaultFinder::new();
    let lng = 139.6917;
    let lat = 35.6895;

    let tz_name = default_finder.get_tz_name(lng, lat).to_owned();
    println!(
        "The timezone at longitude {}, latitude {} is: {}",
        lng, lat, tz_name
    );

    // Get the polygon boundary for the timezone.
    if let Some(boundary_file) = default_finder.get_tz_geojson(&tz_name) {
        // It's a GeoJSON FeatureCollection whose features contain
        // "MultiPolygon" geometry for the timezone.
        println!("Found GeoJSON feature for timezone: {}", tz_name);
        let mut polygons: usize = 0;
        for feature in boundary_file.features {
            polygons += feature.geometry.coordinates.len();
        }
        println!(
            "Total number of polygons in feature collection: {}",
            polygons
        );
    }
}
cargo run --example query_tokyo --features export-geojson

EmbeddedFinder exports the same GeoJSON, decoding only the requested timezone's rings from the file on demand.

For now, tzf-rs' binding in Wasm, named tzf-wasm, has exported this feature and it has been deployed to the tzf-web for online usage.

Migrating from v1

v1 loaded protobuf artifacts (CompressedTopoTimezones, PreindexTimezones); those artifacts are no longer published, and v2 removes every protobuf-typed API. Mappings:

v1 v2
DefaultFinder::new() unchanged
DefaultFinder::new_full() unchanged (full feature, git-only data)
DefaultFinder::{get_tz_name,get_tz_names,timezonenames,data_version} unchanged
Finder (polygon-only) DefaultFinder (get_tz_names stays polygon-exact)
FuzzyFinder (tile-only) removed — the preindex is the fast path inside every finder
Finder::from_compressed_topo(pb) / from_pb(pb) DefaultFinder::from_tzb(&[u8])
FuzzyFinder::from_pb(pb) removed — no separate tile-only finder
FinderOptions / *_with_options removed — YStripes is always on
tzf_rs::pbgen module removed — no protobuf types in the public API
tzf_rs::revert_timezones(&pb) removed — took a protobuf type
finder.finder.get_tz_geojson(...) finder.get_tz_geojson(...)
FuzzyFinder::to_geojson() DefaultFinder::to_preindex_geojson() -> Option<BoundaryFile>
FuzzyFinder::get_tz_geojson(name) -> Option<FeatureItem> DefaultFinder::get_tz_preindex_geojson(name) -> Option<BoundaryFile>
feature bundled (pb lite data) feature bundled (lite .tzb)
feature full (pb full data, git-only) feature full (full .tzb, still git-only)
features clap, export-geojson unchanged
new: EmbeddedFinder, tzf_rs::Error

Behavior changes:

  • get_tz_names results are now sorted lexicographically.
  • get_tz_name on DefaultFinder answers from the preindex tile when one covers the point (v1 DefaultFinder semantics; v1 Finder users who need polygon-exact multi-results use get_tz_names).
  • The byte constructors return Result<_, tzf_rs::Error>: files are CRC-checked and structurally validated at open, instead of silently yielding an empty finder.
  • New: EmbeddedFinder, an in-place low-memory mechanism (~4 MB total).
  • MSRV is now 1.88.

Accuracy

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 tzf's 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%

See BORDER_CHANGE.md in the tzf repository for the complete evaluation results.

Only queries that land within ~111 m of a timezone border can differ from the full-precision result, and most of that band is far narrower. If your use case is sensitive inside that band, enable the full feature and use DefaultFinder::new_full().

Performance

The tzf-rs package is intended for high-performance geospatial query services, such as weather forecasting APIs. Most queries can be returned within a very short time, averaging around 150-300 nanoseconds with DefaultFinder.

Here is what has been done to improve performance:

  1. Using the simplified dataset by default.
  2. Using pre-indexing (the .tzb FUZZY section) to handle most queries in about 100 nanoseconds.
  3. Using a finely-tuned Ray Casting algorithm package ringsaturn/geometry-rs to verify whether a polygon contains a point.
    • Using YStripes(inspired by Josh Baker's tg's ) to accerate polygon queries. This polygon index works when the pre-indexing missing, especially for queries around the border.
    • Also the dense 1°×1° grid index carried by the .tzb file to quickly find candidate polygons, inspired by Aaron Roney's rtz.

That's all. There are no black magic tricks inside the tzf-rs.

Benchmark numbers (Apple M3 Max, bundled lite dataset 2026c, make bench):

Target Scenario Median estimate
DefaultFinder get_tz_name, 154,248-city sweep 25.1 ms total (~163 ns/query)
EmbeddedFinder get_tz_name, 154,248-city sweep 147.0 ms total (~950 ns/query)
DefaultFinder get_tz_name, edge city (preindex miss) 434 ns
EmbeddedFinder get_tz_name, edge city (preindex miss) 4.03 µs
DefaultFinder get_tz_names, edge city 531 ns
EmbeddedFinder get_tz_names, edge city 5.71 µs
DefaultFinder open (new()) 13.0 ms
EmbeddedFinder open (new()) 2.05 ms

The whole-dataset sweep is the number to compare against: the per-query *_random_city benches draw a single random city and reuse it for the whole measurement, so their absolute value varies with the draw.

tzf-rs consumes the .tzb profile only. The .tzm memory image the Go runtime uses exists for zero-copy ring aliasing, which geometry-rs's owned polygon storage cannot exploit — measured here it saved ~3 ms of open time while costing more memory, so it is not supported.

Peak RSS (macOS, make memory): ~44 MiB for DefaultFinder (v1 needed ~82 MiB), ~6 MiB for EmbeddedFinder.

You can view more details from latest benchmark from GitHub Actions logs.

References

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.

See Project tzf for more information.

Bindings

Command line

The binary helps in debugging tzf-rs and using it in (scripting) languages without bindings. Either specify the coordinates as parameters to get a single time zone, or to look up multiple coordinates efficiently specify the ordering and pipe them to the binary one pair of coordinates per line.

tzf --lng 116.3883 --lat 39.9289
echo -e "116.3883 39.9289\n116.3883, 39.9289" | tzf --stdin-order lng-lat

If you are using Nixpkgs, you can install the tzf command line tool, please see more in Nixpkgs.

LICENSE

This project is licensed under the MIT license. The data is licensed under the ODbL license, same as evansiroky/timezone-boundary-builder

FOSSA Status

About

Get timezone via longitude&latitude in Rust in a fast way

Topics

Resources

Stars

108 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages