Result stability and reproducibility
- Status:
Documents current behaviour. Nothing here is a new feature; the guarantees and the non-guarantees both already hold.
- Scope:
What makes a lookup return the same answer twice, and what can change it.
- See also:
Getting started (pinning the data distribution), Usage (the lookup methods themselves), Architecture (how the two distributions are released), Why timezonefinder doesn’t simplify polygons (why the geometry is exact in the first place).
Summary
A lookup is a pure function of the coordinates and the installed boundary data. There is no sampling, no tolerance, no random tie-breaking and no dependence on call order, caching or process state. Given the same coordinates and the same installed versions, every call returns the same answer, on every machine and every supported accelerator.
What that does not mean is that the answer is fixed forever. An answer can change when you install a different version of the data and, for the coordinates geometry does not decide by itself, when you install a different version of the code. Applications that need an answer to stay reproducible across deployments - audit trails, stored records, regression baselines - have to pin both, not merely stay away from borders.
Warning
A data update is never a “patch” in the semantic-versioning sense, whatever its version number
looks like. timezonefinder-data versions read <format>.<year>.<letter>[.postN] and
describe which upstream dataset a release carries, not how much an answer may move. Only the
leading component means anything to the resolver, and it marks the binary format generation.
Nothing in the scheme is a compatibility promise: a bump from 3.2026.3 to 3.2026.4, or
even to 3.2026.3.post1, can change what a lookup returns for coordinates nowhere near a
border, and does so without any timezonefinder release. See
Data versions are not semantic versions.
Within one installation
Deterministic, for both finder classes and every acceleration path. TimezoneFinder computes
exact point-in-polygon results over the unsimplified vertices, and TimezoneFinderL reads a
precomputed answer out of the shortcut index. Neither approximates at query time, so the
C extension, the numba kernel and the pure-Python fallback all return identical results -
which is what Point-in-Polygon Acceleration Paths compares for speed alone.
The one documented exception is a point lying exactly on the edge of a polygon, such as one on
a border two zones share. Such a point has no guaranteed answer - see the note under
certain_timezone_at() in Usage. It is still deterministic; it is simply not
guaranteed to be the zone you would expect.
Across data releases
Data versions are not semantic versions
The two distributions are versioned on separate axes and by different schemes, and reading the data one as if it followed the code one is the mistake this page exists to prevent.
timezonefinder follows semantic versioning: a major release is where a documented semantic
change is allowed to land, and the changelog describes every release.
timezonefinder-data does not. Its version reads <format>.<year>.<letter>[.postN]:
<format>is the binary data format generation - the only component with a machine-checked meaning.timezonefinderrequires the data inside the format generation it can read, so a format change becomes a dependency-resolution error rather than a failure at the first lookup.<year>.<letter>names the timezone-boundary-builder release the dataset was compiled from (2026cbecomes2026.3). It is an upstream identifier, not a severity..postNidentifies revised compiled data made from the same upstream release - typically a rebuild under changed converter rules.
None of that is a compatibility statement. There is no data version bump that promises your
answers are unchanged, and no component of the scheme that distinguishes a dataset correcting one
village from one that reshapes a country: a .postN rebuild can move answers through channel 3
below without a single boundary coordinate changing.
Two consequences follow, and both are easy to miss:
A data update reaches you without a code release. Datasets ship on their own schedule, so an unpinned
timezonefinder-datacan change your answers whiletimezonefinderstays exactly where it is - no upgrade, no release notes, nothing in your dependency diff beyond a transitive version.The code changelog does not record dataset changes. It describes releases of
timezonefinder. Boundary revisions and regenerated indexes are not in it, so a careful reading of it cannot tell you whether an answer moved; that record is the data release history and upstream’s own release notes. A code changelog with nothing relevant in it is therefore not evidence that results are unchanged.
The remedy is the pin below, not vigilance about version numbers.
Three channels a dataset can change an answer through
timezonefinder-data ships a new dataset whenever timezone-boundary-builder publishes one,
independently of code releases. A new dataset can change your answer through three independent
channels (all of them, and not only the first, are what the warning above is about), and only the first is what “the borders moved” usually brings to mind:
Boundary geometry. Upstream revises boundaries. This is not bounded by a small distance: a re-assigned region, a municipality moved between zones or a zone that is split or renamed can change the answer for coordinates far from any previous border.
Polygon precedence. A point inside overlapping polygons is resolved by which candidate the lookup tests first. That is a convention rather than a geometric fact. A point exactly on a border two polygons share is not exposed to it: the point-in-polygon test gives such a point to a single polygon, whatever order the candidates are tested in. A cell with an overlap is one the reordering gate below refuses, so the converter keeps its inherited zone precedence - but that precedence orders zones by their total vertex count in the cell, so a dataset that adds, drops or reshapes a polygon can reorder them and decide such a point differently. Zones with equal totals are ordered by their timezone identifier, so compiling the same dataset twice always yields the same order. Data compiled before that rule left such ties to chance, so the first release built with it can change answers even where no boundary moved: such a point here, and - because the tie can decide which zone a cell tests last - every
TimezoneFinderLanswer in a few cells.Shortcut composition and ordering. The shortcut index is regenerated for every dataset. Which cells exist, which candidates a cell holds and in which order they are tested are all outputs of that run - and the rules that run follows are code, so a code-side optimization reaches you through this channel too, as the section on code releases below describes.
Channel 3 affects the two finder classes very differently, and the difference is the practical one:
TimezoneFinderreads the order but does not depend on it, for the reasons set out in the next section. The dependence is real - the lookup returns the first candidate found to contain the point, andtimezone_at()returns the last remaining zone without a point-in-polygon test at all (see the note on the method) - so what holds the answer still is the converter’s discipline in what it is willing to reorder, not anything in the query.TimezoneFinderLis its candidate order. In a cell containing several zones it returns the last candidate - the zone the full lookup would fall back on after every earlier candidate failed its geometry check - without testing any geometry against your point. A regenerated index that orders that cell differently therefore changesTimezoneFinderL’s answer for every point in the cell, even when the boundary geometry is byte-identical, and those points need not be anywhere near a border.timezones_at()splits the two, and the split is not clean at one edge. Its first element istimezone_at()’s answer, so where two zones contain the point it inherits that method’s dependence on the zone order. Wherever some candidate contains the point - with the packaged data, everywhere but in slivers between neighbouring boundaries - which zones it reports follows from the geometry alone, since it tests every candidate rather than stopping at the first match, so reordering a cell’s candidates cannot change the set. Where none does, it answerstimezone_at()’s untested final-zone fallback, and that single element is pure candidate order, exactly asTimezoneFinderL’s answer is. The order of the elements after the first is the stored candidate order in every case and may change freely with a regenerated index. Only the first element is guaranteed, and it is guaranteed by beingtimezone_at()’s answer, not by anything about the order.
So TimezoneFinderL should be treated as a suggestion that is stable only for a fixed dataset.
It is documented as approximate for a related reason: its answer is not an estimate of which zone
covers your point or most of the cell.
Why reordering leaves a TimezoneFinder answer alone
Two different reorderings are permitted, resting on two different arguments. They are worth keeping apart, because one is a proof about structure and the other is a geometric certification that can in principle be wrong.
Within one zone’s block: structural, and exact. This is the only reordering permitted when the
gate below refuses. CellOptimizer then swaps an adjacent pair only where both candidates carry
the same zone, runs its greedy pass per zone group, concatenates the groups in their original
order, and appends the final zone’s group untouched. The sequence of zones and the final zone
therefore survive byte for byte; only polygons within one zone’s block move.
No geometry is needed to see that the answer cannot change. The lookup returns the zone of the first candidate containing the point, and any polygon of a zone yields that same zone, so which polygon of the winning zone matched is irrelevant. The stopping index is the start of the final block, which is unchanged because block order and sizes are. A point inside no candidate still reaches the same untested final zone.
Across zones: geometric, and conditional. Changing the final zone or interleaving zones breaks
the argument above, so it is permitted only where safe_to_reorder establishes, for an envelope
enclosing the cell, that
no two candidates of different zones intersect with positive area, and
the candidates jointly cover the envelope.
Given both, every point in the cell lies inside a candidate, and inside candidates of at most one zone. The answer is then “the zone whose polygon contains this point” - a function of the geometry alone, and so the same under any permutation. Coverage is also exactly what makes the untested final zone sound: a point that reaches it is inside none of the earlier zones’ polygons, so coverage forces it inside one of the final zone’s.
This half is only as strong as that certification, and it is worth knowing what it assumes. The envelope is the cell’s own rectangle in the quantized coordinate frame, grown by one integer coordinate unit on each side, so every point the cell can be asked about lies strictly inside it - an arithmetic fact rather than an argument. The containment tests are planar operations in that same frame, which is the frame the point-in-polygon kernel works in, so they are not standing in for anything. Everything unproven fails closed: invalid or repaired geometry is rejected, and any geometry error refuses the cell.
A cell at a pole or at the antimeridian certifies or fails on its geometry like any other. There is no seam in the certification, because the cells are rectangles of the coordinate plane rather than regions whose enclosure has to be derived.
Both arguments are asserted by tests, not only argued here.
tests/test_ordering_answer_invariance.py drives the real lookup over the committed
ambiguous-cell points: a within-zone shuffle cannot move an answer, a point contained by a single
zone’s candidates survives any permutation, and - so that neither of those asserts nothing - a
cross-zone rotation does move answers. It also pins TimezoneFinderL to the final candidate’s
zone. tests/test_shortcut_ordering.py covers the converter side: the restricted mode’s answer
over every hit pattern, the gate’s overlap and coverage refusals, that a cell’s region is exactly
the rectangle it owns, and that a pole or antimeridian cell is judged on its geometry like any
other rather than refused before it is read.
What no test establishes is the certification itself - that a positive verdict really implies coverage and disjointness for every point the cell is asked about. What carries that is the envelope containing the cell by construction, which is why it is stated above rather than left implicit.
Both arguments are about the packaged data, whose ocean zones cover the globe. If you compile
your own data and it leaves areas uncovered, a point inside none of a cell’s candidates is
attributed to the last zone untested, so the stored order decides it outright. Use
certain_timezone_at() there, which tests every candidate and returns None when none matches.
Across code releases
Three things can move an answer here, and the third is the one that surprises people:
Bug fixes in the lookup.
Documented semantic changes, which a major release may make.
Optimizations that change the query logic. Candidate ordering, shortcut resolution and the order in which geometry is tested are all fair game for performance work, and changing them changes how ambiguity is resolved.
All three are described in the changelog, which is where to look for whether a
particular release moved an answer and which. The third is not hypothetical: reordering candidate
polygons by the work a full lookup needs changes what TimezoneFinderL returns in a multi-zone
cell, and changing which cells the index is made of changes which cells are multi-zone at all - and
therefore TimezoneFinderL’s answer, cell by cell. Both have happened in a minor release.
Replacing the cells is the one channel the guarantee above does not cover, and it is worth being precise about why. That guarantee is about what the optimizer may reorder within a given set of cells. The order it starts from - the legacy one it keeps wherever the gate refuses - ranks a cell’s zones by how many vertices each has inside that cell, so it is a function of the cell. Make the cells smaller and a zone that dominated the larger cell may not dominate the smaller one, and the order flips without the optimizer touching it. Where some candidate contains the point and only one zone does, that changes nothing, because the answer is then the geometry’s. It can change the answer in exactly the two places where it is the order that decides:
A point inside no candidate at all - in the packaged data, slivers between neighbouring boundaries, almost all of them on the 7.5 degree meridians where the ocean zones meet. There
timezone_at()returns the final candidate untested, so a different final candidate is a different answer. This was never guaranteed, and is the subject of the note on the method.A point inside two zones’ polygons - the dataset’s genuine overlaps. There the first containing candidate wins, so the zone order decides which, and both answers are equally truthful about the geometry.
How much this amounts to in practice was measured when the cells changed, over every stored boundary vertex - the densest probe of these two cases there is, since a vertex is where slivers and overlaps live. The answer was a double-digit number of moved answers out of millions of vertices, almost all of the first kind, and none at all over a large area-weighted random sample or over the dataset’s two best-known overlap regions. The changelog entry for that release carries the counts; this page is about which answers can move and why, not about how many did once.
Note how such a change reaches you. The ordering optimizer and the shortcut builder live in the
code repository (scripts/) but their output ships in timezonefinder-data, so an
optimization of this kind arrives with a new dataset rather than with a code upgrade alone - which
is why 9.0.0 also raised the required data floor, so that an installation could not pair the new
code with an index built by the old ordering.
What no optimization can move: a point strictly inside exactly one polygon. There, the answer
follows from the geometry alone, and no ordering, index or traversal change reaches it. The
exposure is confined to points the geometry does not decide by itself - a point inside overlapping
polygons or inside none of a cell’s candidates, and every TimezoneFinderL answer in a multi-zone cell, which is
a stored ordering decision rather than a geometric one.
Code and data are versioned on separate axes and, as Data versions are not semantic versions sets out, under different schemes. Any dataset released for the current format generation works with any code release that accepts it - which is exactly why a compatible pair says nothing about whether the answers match. Architecture describes that split.
Pinning
Pinning is per axis, and the two axes cover different things. Pinning timezonefinder-data
freezes the artefacts: the boundary geometry, and the shortcut index with the candidate order
stored in it. That is the whole of channels 1 and 3, and the stored half of channel 2:
pip install timezonefinder "timezonefinder-data==<version>"
The release history lists the
available versions. Pin an exact version with ==: a range - even one as narrow as ~= within
the format generation - is not a pin, because the scheme it constrains carries no promise about
answers.
Pin the code as well. A data pin does not freeze the rules that read the data: the
point-in-polygon predicate’s behaviour for a point exactly on an edge, and the order in which the
lookup works through a cell’s candidates, are code. For the coordinates geometry does not decide by
itself, a code upgrade alone can still move the answer - so pin timezonefinder too, or accept
that one of the two axes is free.
Record both versions alongside any stored result you may have to reproduce or defend later - the installed ones are what an answer is reproducible against, and they are not necessarily the ones this documentation was built from:
import importlib.metadata as md
md.version("timezonefinder"), md.version("timezonefinder-data")
A numeric zone id needs the data version even when the coordinate does not. The ids returned
by timezone_id_at() and timezone_ids_at() are positions in the loaded dataset’s
timezone_names list, not identifiers assigned by IANA. Adding, removing or reordering a zone
can therefore change an id while the timezone name at the coordinate stays the same. Persist the
IANA name when it is the durable value; if an id itself must be reproduced, store the exact
timezonefinder-data version beside it.
A distance to the nearest border is not a substitute for pinning. It describes how much coordinate error the currently installed dataset tolerates at that point, which is a useful thing to know and a different question: it cannot see channels 2 and 3, and it does not bound how far channel 1 can move a border.