Usage
Note
Also check out the API documentation or the code.
Global Functions
Starting with version 7.0.0, timezonefinder provides global functions:
from timezonefinder import timezone_at, timezone_id_at
tz = timezone_at(lng=13.358, lat=52.5061) # 'Europe/Berlin'
zone_id = timezone_id_at(lng=13.358, lat=52.5061)
tz = timezone_at_land(lng=13.358, lat=52.5061)
tz = unique_timezone_at(lng=13.358, lat=52.5061)
zones = timezones_at(lng=13.358, lat=52.5061)
names = timezone_names_at(lngs=[13.358, 2.3522], lats=[52.5061, 48.8566])
geometry = get_geometry(tz_name="Europe/Berlin", coords_as_pairs=True)
The functionality of these global functions is equivalent to the respective methods of the TimezoneFinder class documented below.
Note
Concurrent lookups are safe, through the global functions and on any shared TimezoneFinder. The global functions create their shared instance exactly once, under a lock. The data a lookup reads is loaded once and never modified, and the few small arrays some lookups build on first use and keep on the instance are read-only and identical whichever thread builds them, so two threads racing to build one only duplicate that work.
On a free-threaded build the GIL stays disabled. Nothing this package installs re-enables it, so lookups run on all threads at once; tests/test_free_threading.py is what says so, on a free-threaded interpreter in CI. A dependency shipping without Py_mod_gil would turn it back on for the whole process, which is what that test refuses.
For parallel throughput, still create one ``TimezoneFinder`` per thread, as shown in the warning box below. Threads sharing one instance contend on its objects - reference counts above all, not a lock - so a shared instance scales far worse with the thread count than per-thread instances do: 1.60x against 4.84x at eight threads. An instance cannot be passed between processes: create one in each worker.
Releasing a finder is not a lookup. cleanup(), and leaving a with TimezoneFinder() as tf: block, frees the data every lookup reads, so on a shared instance it must wait until every thread using it has finished.
For custom configurations (e.g., different data locations, in-memory mode), create separate TimezoneFinder instances for each configuration.
Note
Lazy initialisation: expect the first call to be slightly slower due to the instance creation and singleton initialization. This also introduces overhead for every function call to access the global instance. For any performance-critical use, create your own TimezoneFinder instance instead.
Instance Initialisation
For more control and better performance, create one TimezoneFinder class instance and reuse it for the whole bounded operation:
from timezonefinder import TimezoneFinder
with TimezoneFinder() as tf:
first = tf.timezone_at(lng=13.358, lat=52.5061)
second = tf.timezone_at(lng=2.3522, lat=48.8566)
Use the in_memory argument to read all polygon data into memory for faster access at the cost of memory consumption and initialisation time (see TimezoneFinder Memory Footprint for what each mode costs):
with TimezoneFinder(in_memory=True) as tf:
tz = tf.timezone_at(lng=13.358, lat=52.5061)
Prefer the context-manager form for scripts, jobs and other bounded operations. In the
default memory-mapped mode a finder holds coordinate files open, and with releases
them deterministically at block exit instead of relying on garbage collection. Cleanup
also runs when an operation raises, while the original exception continues to propagate,
and the block makes the finder’s usable lifetime visible at its call site:
with TimezoneFinder() as tf:
tz = tf.timezone_at(lng=13.358, lat=52.5061)
# the coordinate files are closed here
A finder must not be used after leaving its context or calling cleanup(); its data is
released and a lookup that reads geometry raises AttributeError. A long-running
service or worker should instead keep one instance alive, reuse it rather than constructing
one per lookup, and call cleanup() during orderly shutdown.
Use the argument bin_file_location to use data files from another location (e.g. your own compiled files):
with TimezoneFinder(bin_file_location="path/to/files") as tf:
tz = tf.timezone_at(lng=13.358, lat=52.5061)
Note
Compiled data does not have to be regenerated on every upgrade. The coordinate files record
the encoding they use, and a directory stays valid for as long as that encoding is unchanged -
which is across most releases. When it does change, the changelog says so and loading raises a
ValueError naming the file, rather than silently returning wrong timezones; regenerate with
scripts/file_converter.py from the current checkout at that point.
Warning
For parallel computation, give each thread its own TimezoneFinder instance. A shared instance returns the same answers, but threads using it contend on its objects and gain far less from parallelism. An instance cannot be passed between processes either:
import threading
from timezonefinder import TimezoneFinder
def lookup_in_thread(lng, lat):
# Each thread creates its own instance
tf = TimezoneFinder(in_memory=True)
return tf.timezone_at(lng=lng, lat=lat)
threads = [threading.Thread(target=lookup_in_thread, args=(13.4, 52.5))]
# ...
The global functions (timezone_at(), etc.) are also safe to call concurrently, but they share one instance and add overhead per call, so they suit occasional concurrent lookups rather than throughput-critical ones.
timezone_at()
This is the default function to check which timezone a point lies within.
If no timezone has been matched, None is being returned.
Using the global function:
from timezonefinder import timezone_at
tz = timezone_at(lng=13.358, lat=52.5061) # 'Europe/Berlin'
tz = timezone_at(lng=1.0, lat=50.5) # 'Etc/GMT'
Using a TimezoneFinder instance:
from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
tz = tf.timezone_at(lng=13.358, lat=52.5061) # 'Europe/Berlin'
tz = tf.timezone_at(lng=1.0, lat=50.5) # 'Etc/GMT'
Note
To reduce the risk of mixing up the coordinates, the arguments lng and lat have to be given as keyword arguments
Note
This function is optimized for speed: The last possible timezone in proximity is always returned (without checking if the point is really included).
timezone_id_at()
timezone_id_at() performs the same scalar lookup as timezone_at() but returns
the numeric zone id directly instead of converting it to a name. Use it when the next
operation indexes, joins or groups by zone and no name is needed yet:
from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
zone_id = tf.timezone_id_at(lng=13.358, lat=52.5061)
name = None if zone_id is None else tf.zone_name_from_id(zone_id)
# 'Europe/Berlin'
It is also available as the global function timezone_id_at() and on
TimezoneFinderL. The corresponding land lookup is timezone_id_at_land();
it returns None for ocean coordinates exactly where timezone_at_land() does.
Scalar id lookups return None when there is no answer. NO_ZONE_ID (-1) is
reserved for the integer arrays returned by the batch API, where None would require
an object array.
Warning
A zone id is only the position of the IANA name in the loaded dataset’s
timezone_names list. It can refer to a different zone after a data update.
Persist the name instead, or persist the exact timezonefinder-data version with
the id when the result must be reproduced later.
Batch lookups
To look up many coordinates, pass them as two arrays - one per axis - instead of calling timezone_at() in a loop.
The validation, the coordinate scaling and the shortcut lookup then run once over the whole batch rather than once per point:
from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
lngs = [13.358, 2.3522]
lats = [52.5061, 48.8566]
zone_ids = tf.timezone_ids_at(lngs=lngs, lats=lats) # numpy int16 array
names = tf.timezone_names_at(lngs=lngs, lats=lats) # ['Europe/Berlin', 'Europe/Paris']
timezone_ids_at() is the primary form and timezone_names_at() the convenience on top of it.
Prefer the ids whenever the names are not the end product: a caller doing millions of lookups should not pay for millions of string lookups it maps straight back to something else.
Both are also available as global functions.
Ids can be named later, in one call, with zone_names_from_ids() - so a pipeline can join, filter or group on the ids and pay for the names once, over whatever survives:
zone_ids = tf.timezone_ids_at(lngs=lngs, lats=lats)
# ... join, filter, group by zone id ...
names = tf.zone_names_from_ids(zone_ids) # ['Europe/Berlin', 'Europe/Paris']
It is the batch counterpart of zone_name_from_id() and answers None for NO_ZONE_ID, so a batch of ids round-trips to exactly what timezone_names_at() would have returned.
Any other invalid id - a negative that is not the sentinel, or one past the last zone - raises ValueError rather than naming a zone counted from the end of the dataset.
Above a batch of ~128 ids the conversion is a numpy gather rather than a Python loop, which is several times faster per id; below it the loop wins and is used instead.
Any 1-D array-like works for either axis - a list, a tuple, an array.array, a numpy array, a pandas Series.
Anything numpy.asarray() turns into a 1-D numeric array is accepted, which is what makes the last of those work without this package knowing pandas exists.
A C-contiguous float64 numpy array is used without copying.
Warning
Hand over 64-bit floats.
A float32 array is accepted and converted, but it has already lost precision this package cannot recover: one step of float32 at ±180° spans ~1.7 m, against the ~1.11 cm a stored coordinate is quantised to and the ~11 cm grid the boundary data itself is published on.
A point closer to a border than that can be rounded onto the wrong side of it before the lookup begins - and accuracy at borders is what this library exists for.
float64 resolves far finer than the stored data, so it costs nothing.
Data Format has the full comparison, and timezonefinder.utils.coordinate_resolution() computes it for any dtype.
Answers come back in the input’s own order, one per coordinate, so a column of them can be assigned straight back:
df["timezone"] = tf.timezone_names_at(lngs=df["longitude"], lats=df["latitude"])
The index is not consulted - a Series is read positionally, like every other array-like - and assigning a plain list to a column aligns positionally too, so the two agree.
For the id form, wrap it to keep the frame’s index: pd.Series(tf.timezone_ids_at(...), index=df.index).
Note
Coordinates are passed one axis per argument, never as a single (N, 2) array.
Such an array would have to be read by column position, and a swapped pair is still a valid coordinate for most of the populated world - so the mistake would return a real but wrong timezone instead of raising.
Where the scalar methods answer None, a batch answers NO_ZONE_ID (-1) in the id array and None in the name list.
An out-of-range coordinate - which includes NaN and infinity - raises by default, matching the scalar methods.
That is also how a missing value in a dataframe arrives: pandas stores both None and pd.NA as NaN, so a null row is an out-of-range coordinate and on_invalid decides what happens to it.
A None in a plain Python list is treated as a mistake instead and raises TypeError, since a list has no missing-value convention to honour.
Pass on_invalid="skip" to answer the rest of the batch instead, rather than discarding the work already done for the coordinates that were fine:
from timezonefinder import NO_ZONE_ID, TimezoneFinder
tf = TimezoneFinder()
zone_ids = tf.timezone_ids_at(
lngs=[13.358, 999.0], lats=[52.5061, 0.0], on_invalid="skip"
)
# the second entry is NO_ZONE_ID; there is always one answer per input coordinate, in input order
Note
What a batch amortises is the per-call overhead, not the geometry.
Points whose cell a single timezone covers benefit most; points that fall through to the point-in-polygon tests are still resolved one at a time.
examples/batch_processing.py demonstrates the whole API.
That overhead has to be paid back, so a batch below a certain size is slower per point than the scalar method, and above a certain size a bigger batch stops helping. When Batched Lookups Pay measures both, and the script behind it takes your own coordinates - which matter, because points sharing a cell are answered together.
timezone_at_land() batches too, as timezone_ids_at_land() and timezone_names_at_land(), with the same arguments and the same on_invalid policies:
names = tf.timezone_names_at_land(lngs=[13.358, -30.0], lats=[52.5061, 0.0])
# ['Europe/Berlin', None] - the second point is at sea
The ocean check costs nothing per point here: ocean-ness is a fixed property of a zone id, so the whole answer array is masked in one operation rather than testing each answer’s name.
That makes the batch form cheaper per point than the scalar method, not merely equal to it.
An ocean match, an uncovered point and a skipped coordinate are all NO_ZONE_ID in the id array and None in the name list - one sentinel, as everywhere else in the batch API.
Note
certain_timezone_at() has no batch form.
It is a different loop: it tests every candidate polygon rather than stopping at the first match.
timezone_at_land()
This package includes ocean timezones (Etc/GMT...).
If you want to explicitly receive only “land” timezones use:
Using the global function:
from timezonefinder import timezone_at_land
tz = timezone_at_land(lng=13.358, lat=52.5061) # 'Europe/Berlin'
tz = timezone_at_land(lng=1.0, lat=50.5) # None
Using a TimezoneFinder instance:
from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
tz = tf.timezone_at_land(lng=13.358, lat=52.5061) # 'Europe/Berlin'
tz = tf.timezone_at_land(lng=1.0, lat=50.5) # None
The id-returning counterpart has the same land/ocean distinction:
zone_id = tf.timezone_id_at_land(lng=13.358, lat=52.5061)
no_land_zone = tf.timezone_id_at_land(lng=1.0, lat=50.5) # None
timezones_at()
Some timezones in the dataset genuinely overlap, so a point can lie in more than one.
timezone_at() picks one of them and gives no sign that it had to choose;
timezones_at() returns every zone whose polygons contain the point:
from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
tf.timezones_at(lng=13.358, lat=52.5061) # ['Europe/Berlin']
tf.timezones_at(lng=87.6168, lat=43.8256) # ['Asia/Urumqi', 'Asia/Shanghai']
Using the global function:
from timezonefinder import timezones_at
zones = timezones_at(lng=87.6168, lat=43.8256)
The list always contains what timezone_at() answers, and that membership is what
stops the two contradicting each other.
Where that answer sits is a second, stronger guarantee: it is always the first
element, so zones[0] is a drop-in for timezone_at() and the otherwise
meaningless tail has an anchor.
The remaining zones follow in the order the internal shortcut index happens to store
them - that order is tuned for lookup speed and means nothing, so do not read the second
element as “the next most likely zone”.
Which overlaps exist is a property of the dataset, not of this package. In the shipped data they are:
Asia/UrumqiinsideAsia/Shanghai- by far the most common case, and a second local time in real use across Xinjiang.Disputed or dual-administered areas:
Africa/Khartoum/Africa/Juba(Abyei),Asia/Tbilisi/Europe/Moscow(Abkhazia and South Ossetia),Asia/Jerusalem/Asia/Hebron, and a few maritime borders such asAmerica/Sitka/America/VancouverandAmerica/Punta_Arenas/America/Argentina/Rio_Gallegos.
Roughly 1 % of points on land fall in two zones.
Note
This is the only lookup that tests every candidate polygon of the point’s cell, so
it is slower than timezone_at().
timezone_at() itself is unchanged - it still stops at the first match.
A point whose cell a single zone covers is answered from the index with a
one-element list and no geometry at all.
Note
Completeness is bounded by the same candidate lists certain_timezone_at() uses:
a zone whose polygons are not listed for the point’s cell is not reported.
There is no batch form.
Note
Where no candidate polygon contains the point, this returns the one zone
timezone_at() answers there - which that method returns without testing it - rather
than an empty list, so the two still agree.
The packaged data’s ocean zones make that unreachable except in any sliver the stored boundaries of two neighbouring zones leave between them; custom data leaving areas uncovered reaches it everywhere those areas are.
certain_timezone_at() is the lookup that reports “none” in that case.
unique_timezone_at()
For fast execution timezonefinder internally uses precomputed “shortcuts” which store the possible zones in proximity.
Call unique_timezone_at() if you want to compute an exact result without actually performing “point-in-polygon” tests (<- computationally expensive).
This function will return None when the correct zone cannot be uniquely determined without further computation.
Using the global function:
from timezonefinder import unique_timezone_at
tz = unique_timezone_at(lng=longitude, lat=latitude)
Using a TimezoneFinder instance:
from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
tz = tf.unique_timezone_at(lng=longitude, lat=latitude)
Note
The “lightweight” class TimezoneFinderL answers from the shortcut index without testing geometry. In a cell containing several zones, its result is a fast suggestion rather than the most probable zone at the query point.
certain_timezone_at()
Note
DEPRECATED: Due to the included ocean timezones one zone will always be matched.
Use timezone_at() or timezone_at_land() instead.
This function is for making sure a point is really inside a timezone. It is slower, because all polygons (with shortcuts in that area)
are being checked until one polygon is matched. None is being returned in the case of no match.
Using the global function:
from timezonefinder import certain_timezone_at
tz = certain_timezone_at(lng=13.358, lat=52.5061) # 'Europe/Berlin'
Using a TimezoneFinder instance:
from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
tz = tf.certain_timezone_at(lng=13.358, lat=52.5061) # 'Europe/Berlin'
Note
A coordinate exactly on the edge of a timezone polygon has no guaranteed answer. On a border two zones share, the point-in-polygon test gives the point to one of them, which need not be the zone you expect. On the lng=-180.0 meridian, where the dataset is cropped at the antimeridian, and at the exact South Pole (lat=-90.0, at any longitude) it would give the point to no polygon at all, so every lookup reads lng=-180.0 as lng=180.0, the same meridian, and lat=-90.0 as the point one stored unit (~1.1 cm) north of the pole. Either spelling of such a point therefore answers the same zone, and with the packaged data certain_timezone_at() does not return None there.
Such a point is still answered deterministically - the same input gives the same output - it is simply not guaranteed to be the zone you expect. See Result stability and reproducibility for what makes an answer reproducible and what can change it.
closest_timezone_at()
removed in version 6.0.0
get_geometry()
For querying a timezone for its geometric multi-polygon shape use get_geometry().
output format: [ [polygon1, hole1,...), [polygon2, ...], ...]
and each polygon and hole is itself formated like: ([longitudes], [latitudes])
or [(lng1,lat1), (lng2,lat2),...] if coords_as_pairs=True.
Using the global function:
from timezonefinder import get_geometry
get_geometry(tz_name="Africa/Addis_Ababa", coords_as_pairs=True)
get_geometry(tz_id=400, use_id=True)
Using a TimezoneFinder instance:
from timezonefinder import TimezoneFinder
tf = TimezoneFinder()
tf.get_geometry(tz_name="Africa/Addis_Ababa", coords_as_pairs=True)
tf.get_geometry(tz_id=400, use_id=True)
check out the example script in examples/get_geometry.py for more details.
TimezoneFinderL
TimezoneFinderL is a light version of the TimezoneFinder class.
It is useful for quickly suggesting probable timezones without using as many computational resources (cf. speed tests).
Instead of using timezone polygon data this class instantly returns a suggestion from precomputed “shortcuts”.
For a cell containing several zones, it returns the zone that the full lookup would use only after
the other candidates had failed their geometry checks. The converter chooses that fallback while
arranging candidates for faster full lookups; it is not an estimate of which zone covers the query
point or most of the cell. Suggestions can therefore differ from
TimezoneFinder.timezone_at() anywhere in such a cell, not only near a border, and a
regenerated shortcut index can change them even when the boundary geometry is unchanged.
Treat a TimezoneFinderL result as stable only for a fixed timezonefinder-data
version - see Result stability and reproducibility.
Check the (API documentation) of TimezoneFinderL.
A heuristic suggestion can be retrieved with timezone_at():
from timezonefinder import TimezoneFinderL
tf = TimezoneFinderL() # reuse
query_points = [(13.358, 52.5061), ...]
for lng, lat in query_points:
tz = tf.timezone_at(lng=lng, lat=lat) # 'Europe/Berlin'
Certain results can be retrieved with unique_timezone_at():
tf.unique_timezone_at(lng=13.358, lat=52.5061) # 'Europe/Berlin'
Note
If you only use TimezoneFinderL, you may delete all unused timezone polygon data files in the folders data/boundaries and data/holes to obtain a truly lightweight installation (few MB).
Using vectorized input
Check numpy.vectorize and pandas.DataFrame.apply
Calling timezonefinder from the command line
A command line script is being installed as part of this package.
The interface has one command per input shape:
timezonefinder query [-v] [--in-memory] [-f {0,1,3,4,5}] LNG LAT
timezonefinder rows [-d DELIMITER] [--lng-col LNG_COL] [--lat-col LAT_COL]
[--header | --no-header] [--in-memory]
[-f {0,1,3,4,5}]
timezonefinder validate-data DIR
Single-query example:
timezonefinder query -f 4 40.5 11.7
With -v you get verbose output, without it only the timezone name is being printed.
With the argument of the flag -f one can choose between the different functions to be called:
0: timezone_at() = default (uses global function)
1: certain_timezone_at() (uses global function)
2: removed
3: TimezoneFinderL.timezone_at()
4: TimezoneFinderL.timezone_at_land()
5: timezone_at_land() (uses global function)
Note
A single invocation is orders of magnitude slower than using the package from
within Python, because it pays the full initialisation cost to answer one query.
Use rows (below) for more than a handful of coordinates.
Each command owns only the options that apply to it. For example, --lng-col is a
rows option and -v is a query option, so argparse rejects a cross-mode flag
before any finder is constructed. The earlier flat forms (bare coordinates and
--stdin) are not accepted; this command structure is part of the major-version CLI.
Validating compiled data
Validate a custom data directory before constructing a finder from it:
timezonefinder validate-data /path/to/compiled-data
The command exhaustively checks the file schemas, polygon coordinate tables and boundary
counts, latitude block indexes, hole references, hole-registry ranges and ownership,
shortcut index, and the grouping shared by zone_positions.npy and zone_ids.npy.
The converter runs the same entry point over what it writes, and the test suite runs it
over the packaged data. It is opt-in: finder construction and the lookup path do not
repeat work the build has already settled.
Looking up many coordinates at once
With rows the script reads delimited rows from standard input and writes
each row back out with a timezone column appended. The finder is built once,
so initialisation amortises across the whole input instead of being paid per
coordinate:
$ cat stores.csv
store_id,name,lat,lng
S-1,Amsterdam Centraal,52.37,4.89
S-2,Pacific Buoy,0.0,-150.0
$ timezonefinder rows < stores.csv
store_id,name,lat,lng,timezone
S-1,Amsterdam Centraal,52.37,4.89,Europe/Amsterdam
S-2,Pacific Buoy,0.0,-150.0,Etc/GMT+10
Because the answer arrives attached to the row it belongs to, annotating a file is one command - no projecting the coordinate columns out, and no rejoining the results afterwards.
Choosing the coordinate columns. With a header row the columns are found by
name: lng, lon, long, longitude or x for the longitude, and
lat, latitude or y for the latitude, matched case-insensitively.
Their position in the file does not matter. For a header that uses other names,
or for input with no header at all, name them explicitly - a header name, or a
1-based column number:
timezonefinder rows --lng-col 3 --lat-col 2 < headerless.csv
timezonefinder rows --lng-col longitude_deg --lat-col latitude_deg < f.csv
Header or not. Whether the first row names the columns or already holds data
is worked out from the row itself when nothing says otherwise. Say it outright with
--header or --no-header when that could go wrong - a header whose names are
all numbers reads as data, and a first data row whose coordinates are placeholders
reads as a header.
Note
The column order is never guessed. Input with no header and no
--lng-col/--lat-col is rejected outright, because guessing would fail
silently rather than loudly: for any point with a longitude between -90 and
90 - most of the populated world - a swapped pair is still a valid
coordinate, so it resolves to a real but wrong timezone. Swapping Moscow’s
pair yields Asia/Tehran, which looks like an answer.
Other flags. -f selects the lookup function for the whole stream, exactly
as it does for a single query. -d/--delimiter sets the field delimiter for
input and output alike, defaulting to , - spell tab as '\t'. -v is
rejected in this mode, since verbose output is per query and would break the
one-row-per-result contract, and so are lng/lat on the command line, since
the coordinates come from stdin. --in-memory reads the polygon coordinate data
into RAM rather than memory-mapping it, trading an order of magnitude more memory
for measurably faster lookups once the page cache is warm
(TimezoneFinder Memory Footprint and Timezone Finding Performance Benchmark carry
the current figures for both sides of that trade). It is worth passing for a long
stream and not for a single query, and does not apply to -f 3/-f 4, which
load no polygon data.
Rows that cannot be used. There is always exactly one output row per input
row, which is what lets a caller consume the two in step. A row that is too short,
holds a non-numeric coordinate, or names a coordinate outside the valid range is
written back out with an empty timezone cell, and a warning naming the row
number and the reason goes to stderr. A row with no fields at all - a blank line,
or one csv itself could not parse - is echoed back blank, since there is no row to
append a cell to. An empty cell is also what a genuine
“no timezone here” looks like - which -f 4 and -f 5 return for every ocean
point - so the two are told apart by the exit code: 1 if any input row was
rejected, 0 if every row was answered. A consumer that stops reading early -
| head, a closed pipe - ends the run with 141 instead, so that a truncated
pipeline is not mistaken for a file full of bad coordinates.
Standard CSV quoting is honoured on the way in and reproduced on the way out, so a field containing the delimiter survives intact. One consequence of that: a quoted field may span several physical lines, so the guarantee is one output row per input row, which is only the same thing as one line per line when no field is quoted across a newline.