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/Urumqi inside Asia/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 as America/Sitka/America/Vancouver and America/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.