Calculate local time with UTC and location

Posted on 2024-01-05, by Racum. Tags: GIS Python GeoJSON

When working with remote sensing, it is very common to save time data in a single timezone (usually UTC), this article shows how to convert that into local time based on the provided coordinates.



To perform the conversion, we need two Python libraries:

  • pytz: parses the timezones by name, and provides a comprehensive database of current and historical time changes (like summer daylight savings).
  • Shapely: handles geometric data, used in this case to represent the coordinate point and a method to check if a an area contains it.


$ pip install pytz shapely

Geographical data

The only concept of “where” inside pytz is the name of the timezone, by itself it is not aware about coordinates; that’s why we need to provide a proper geographical data that can inform the timezone name based on a coordinate point.

Download the file with the timezones from CARTO (click “Download” below the table, than the icon for the GeoJSON format). It is named ne_10m_time_zones.geojson and has 3.4MB.

The GeoJSON has a structure like this (simplified):

    "type": "FeatureCollection",
    "features": [
            "type": "Feature",
            "properties": {"tz_name1st": "Europe/Paris", "zone": 1, ... },
            "geometry": ...

And renders like this:

A map of the world highlighting the timezone areas.

Important: timezones, like frontiers, are a political matter! Some contested areas between zones could have different timezones depending on who you ask, Make sure to always get the most up-to-date map, and consider different maps picked dynamically based on the region informed by the user.


Here is the full code, the explanation comes later:

import json
from datetime import datetime, timedelta, timezone
from pytz import timezone as pytz_timezone
from pytz.exceptions import UnknownTimeZoneError
from shapely import Geometry, Point
from shapely.geometry import shape

def timezone_from_name(tz_name: str, zone: float) -> timezone:
        return pytz_timezone(tz_name)
    except UnknownTimeZoneError:
        return timezone(timedelta(hours=zone))

def parse_feature(feature: dict) -> tuple[Geometry, timezone]:
    geometry = shape(feature['geometry'])
    tz = timezone_from_name(
    return geometry, tz

with open('ne_10m_time_zones.geojson', 'r') as geojson:
    AREA_TO_TZ = [parse_feature(f) for f in json.load(geojson)['features']]

def local_time(utc_time: datetime, point: Point) -> datetime:
    for area, tz in AREA_TO_TZ:
        if area.contains(point):
            if not utc_time.tzinfo:
                utc_time = utc_time.replace(tzinfo=timezone.utc)
            return utc_time.astimezone(tz)
    raise Exception(f'No timezone found for {point}.')


>>> from datetime import datetime
>>> from shapely import Point

>>> utc_time = datetime.fromisoformat('2024-01-05T08:45:00Z')
>>> berlin = Point(13.409414, 52.520848)
>>> tokyo = Point(139.745410, 35.658603)
>>> hawaii = Point(-157.845233, 21.311893)

>>> local_time(utc_time, berlin)
datetime.datetime(2024, 1, 5, 9, 45, tzinfo=<DstTzInfo 'Europe/Paris' CET+1:00:00 STD>)

>>> local_time(utc_time, tokyo)
datetime.datetime(2024, 1, 5, 17, 45, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>)

>>> local_time(utc_time, hawaii)
datetime.datetime(2024, 1, 4, 22, 45, tzinfo=<DstTzInfo 'Pacific/Honolulu' HST-1 day, 14:00:00 STD>)


The function local_time() just iterates over a prepared list of tuples AREA_TO_TZ, that looks like this:

        <MULTIPOLYGON (((42.897 37.325, 42.937 37.32, 42.98 37.332, 43.005 37.347, 4...>,
        <DstTzInfo 'Asia/Riyadh' LMT+3:07:00 STD>
        <MULTIPOLYGON (((83.989 60.827, 83.997 60.826, 84.26 60.856, 84.331 60.806, ...>,
        <DstTzInfo 'Asia/Omsk' LMT+4:54:00 STD>

This AREA_TO_TZ is being processed outside of the function to be a global constant, so we don’t need to re-process the GeoJSON all the time. To generate it, we transform all inner “features” from the GeoJSON with the parse_feature() function.

But we need to do some “data massage” first: not all tz_name1st values are compatible with pytz, that’s why we fallback to the zone property if pytz fails (see timezone_from_name()).

Performance-wise, this code is a bit naive: it needs to iterate over the areas checking if it contains the point. It could be enhanced with some clever geo-indexing. Another alternative is to load all geometries inside a PostGIS table, making the use of the GIST index over the geometry field, but this is outside the scope of this article.


This code could be adapted to other similar geographic checks, for example: return the country of a point (beware of contested ares), check if a point is in range of one of many antenna coverages, detect in what ocean or see a ship is sailing, etc. The only challenge is to find the right GeoJSON for your need, and make sure you can get the data you want from inside its properties.