# tzf-rs: a fast timezone finder for Rust. [](https://github.com/ringsaturn/tzf-rs/actions/workflows/rust.yml) [](https://docs.rs/tzf-rs) [](https://crates.io/crates/tzf-rs) [](https://app.fossa.com/projects/git%2Bgithub.com%2Fringsaturn%2Ftzf-rs?ref=badge_shield)

- Released documentation: [docs.rs/tzf-rs](https://docs.rs/tzf-rs)
- Latest documentation(not released yet):
[ringsaturn.github.io/tzf-rs](https://ringsaturn.github.io/tzf-rs/tzf_rs/)
- Try it online: [tzf-web](https://ringsaturn.github.io/tzf-web/)
> [!NOTE]
>
> Version 2 is protobuf-free. The data source is the TZF embedded binary
> format (`.tzb`) shipped by
> [tzf-dist](https://github.com/ringsaturn/tzf-dist), and the public surface
> is two finder types: `DefaultFinder` and `EmbeddedFinder`. See
> [Migrating from v1](#migrating-from-v1).
## Quick start
```bash
cargo add tzf-rs
```
```rust
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:
```bash
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-geojson` — `to_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](#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:
```rust
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`](https://github.com/racemap/rust-tz-service) provides
a good example.
A Redis protocol demo could be used here:
[`ringsaturn/redizone`](https://github.com/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](#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):
```toml
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.
```toml
[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.
```rust,ignore
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:
```toml
tzf-rs = { version = "{version}", features = ["export-geojson"]}
```
Then you can use the following methods:
```rust
// 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
);
}
}
```
```bash
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](https://github.com/ringsaturn/tzf-wasm), has exported this feature
and it has been deployed to the [tzf-web](https://ringsaturn.github.io/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`](https://github.com/ringsaturn/tzf/blob/main/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`](https://github.com/ringsaturn/geometry-rs) to
verify whether a polygon contains a point.
- Using YStripes(inspired by Josh Baker's
[`tg`](https://github.com/tidwall/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](https://github.com/twitchax/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](https://github.com/ringsaturn/tzf-rs/actions/workflows/rust.yml).
## 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](https://blog.ringsaturn.me/en/posts/2023-01-31-history-of-tzf/).
- Original Go repo: [`ringsaturn/tzf`](https://github.com/ringsaturn/tzf)
- Binary timezone data (`.tzb` / `.tzm`):
[`ringsaturn/tzf-dist`](https://github.com/ringsaturn/tzf-dist)
- Geometry: use
[`ringsaturn/geometry-rs`](https://github.com/ringsaturn/geometry-rs) which is
[`tidwall/geometry`](https://github.com/tidwall/geometry)'s Rust port.
- Continuous Benchmark compared with other packages:
[`ringsaturn/tz-benchmark`](https://github.com/ringsaturn/tz-benchmark)
See [Project tzf](https://project-tzf.ringsaturn.me/docs/getting-started/) for
more information.
### Bindings
- Ruby, see [`HarlemSquirrel/tzf-rb`](https://github.com/HarlemSquirrel/tzf-rb)
- Python, see [`ringsaturn/tzfpy`](https://github.com/ringsaturn/tzfpy)
- Wasm, see [`ringsaturn/tzf-wasm`](https://github.com/ringsaturn/tzf-wasm)
- PostgreSQL extension, see
[`ringsaturn/pg-tzf`](https://github.com/ringsaturn/pg-tzf)
## 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.
```shell
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](https://search.nixos.org/packages?channel=unstable&type=packages&query=tzf-rs).
## LICENSE
This project is licensed under the [MIT license](./LICENSE). The data is
licensed under the
[ODbL license](https://github.com/ringsaturn/tzf-dist/blob/main/LICENSE_DATA),
same as
[`evansiroky/timezone-boundary-builder`](https://github.com/evansiroky/timezone-boundary-builder)
[](https://app.fossa.com/projects/git%2Bgithub.com%2Fringsaturn%2Ftzf-rs?ref=badge_large)