To detect a user's timezone from an IP address, resolve the IP to a geographic location, then resolve that location to an IANA timezone identifier such as Europe/London. Store the identifier, never a fixed UTC offset. The identifier carries the rules for when clocks change; a stored offset is a snapshot that breaks at the next transition.
That two-step chain is simple. The difficulty sits in reading the client's real IP behind a proxy, choosing between the browser and the server, keeping three separate datasets current, and handling the two hours a year when local time either does not exist or happens twice. This guide covers four ways to resolve a timezone in production, two of them from an IP address and one from coordinates directly, with runnable code and what to weigh before picking each one.
Key Takeaways
- Timezone detection is a two-step chain: an IP address or a set of coordinates resolves to a location, and that location resolves to an IANA identifier such as
Europe/London. - Behind a load balancer or CDN, the socket address belongs to the proxy, not the user. Extract the real client IP from
X-Forwarded-Forwith a trusted-proxy count or list, never the leftmost entry. - Store the IANA identifier, never a UTC offset. Offsets go stale on their own schedule: British Columbia, Alberta, and Morocco all changed how they handle DST in 2026 alone.
- Country-level detection is dependable; city-level is not. A VPN, a mobile carrier, or a corporate gateway can put a user's IP somewhere they've never been.
- Browser detection (
Intl.DateTimeFormat) beats IP detection on precision but only runs client side. Production systems use the IP as the default and the browser to confirm. - Open data resolves country level from a bare IP, and exact zone boundaries once you already have coordinates. Getting from a bare IP to city level needs geolocation infrastructure that registries simply don't publish.
Can an IP Address Actually Tell You?
An IP address is the numeric identifier assigned to a device on a network. It carries no timezone field. What it yields is an approximate location: reliably a country, often a region, less often a city.
Because timezones are geographic, a location is enough to derive a zone. How much precision you need depends on the country: some countries use a single zone nationwide, others span several, and an IP resolving to a multi-zone country needs more than a country-level guess.
IANA Timezone Identifier vs UTC Offset
"Timezone" means two different things in practice, and only one of them is safe to store.
An IANA timezone identifier (Europe/London, America/New_York, Asia/Kolkata) points to a rule set. A UTC offset (+01:00, -06:00) is a single number. Europe/London knows it is UTC+00:00 in winter and UTC+01:00 in summer. A stored +00:00 knows nothing and is wrong for half the year.
| Format | Example | Survives a clock change? | Store it? |
|---|---|---|---|
| IANA identifier | Europe/London |
Yes | Yes |
| UTC offset | UTC+1, +0100 |
No | No |
| Abbreviation | GMT, BST, CST |
Ambiguous, CST has several meanings |
No |
| Windows identifier | GMT Standard Time |
Yes, but not portable | Windows only |
British Columbia stopped changing its clocks and settled on permanent UTC-07:00 on 8 March 2026. Alberta did the same on UTC-06:00, though its change did not take legal effect until 18 June 2026. Morocco moves to permanent UTC+00:00 on 20 September 2026, under a decree published in Morocco's official gazette.
Note when each one actually bites, because it is not all at once. Morocco's change moves the wall clock on 20 September, so a stale tzdata bundle is wrong from that date. British Columbia and Alberta already sit on the offset they intend to keep, so nothing looks broken until the rest of North America falls back on 1 November and those two provinces do not.
Both provinces are modelled as changing on that same 1 November date, and for a reason that has nothing to do with the actual legal calendar. The tz database's own maintainers have documented why: CLDR keys its timezone display names to a tm_isdst flag, and flipping that flag mid-year breaks how operating systems label the zone until CLDR ships an updated release. Both provinces are held at the placeholder date of 1 November until that fix lands, regardless of when the change actually took legal effect. Correct rules and correctly modelled rules are not always the same thing, and if you own the pipeline, that gap is yours to track.
How Do You Get the Real Client IP Behind a Proxy?
Every server-side method below assumes you have the user's actual IP, and in production that assumption breaks first. If your app sits behind a load balancer, reverse proxy, or CDN, the connection originates from that intermediary. Reading the raw socket address (request.remote_addr and equivalents) returns the proxy, and every visitor appears to live in your data centre.
The client address travels in X-Forwarded-For, a comma-separated list where each proxy appends the address it saw:
X-Forwarded-For: <client>, <proxy 1>, <proxy 2>
The leftmost entry is the original client, and it is also client-supplied and trivially spoofable. MDN documents two safe selection methods: a trusted proxy count, where you count in from the right by the number of proxies you operate, and a trusted proxy list, where you scan from the right and take the first address that is not one of yours.
def client_ip(headers, remote_addr, trusted_proxies=1):
"""Return the client IP using the trusted-proxy-count method.
X-Forwarded-For grows left to right: each proxy appends the address it
saw. With `trusted_proxies` proxies you control, count in from the right
by that number and take the address at that position. Everything
further left is client-supplied and must not be trusted.
"""
hops = [ip.strip() for ip in headers.get("X-Forwarded-For", "").split(",") if ip.strip()]
if not hops:
return remote_addr # direct connection, no proxy in front
index = len(hops) - trusted_proxies
return hops[index] if index >= 0 else None # misconfigured, fail closed
Some reverse proxies set X-Real-IP with a single value instead of a list. Some CDNs add a dedicated originating-client header, such as CF-Connecting-IP. And Forwarded (RFC 7239) is the standardised equivalent of X-Forwarded-For, though far less widely deployed. Whichever you read, overwrite it at your own edge so inbound copies cannot be spoofed.
Method 1: Read the Timezone in the Browser
The most accurate timezone available costs nothing and never touches an IP. Every current browser exposes the operating system setting:
Intl.DateTimeFormat().resolvedOptions().timeZone; // "Europe/London"
That returns an IANA identifier from the user's own machine, so a traveller with a correctly configured laptop gets the right answer even on a VPN.
The Temporal API returns the same identifier with proper date and offset handling. It reached TC39 Stage 4 in March 2026 and is part of ES2026:
Temporal.Now.timeZoneId(); // "Europe/London"
Temporal.Now.zonedDateTimeISO().offset; // "+01:00"
Temporal ships natively in Chrome 144 and later, Firefox 139 and later, Edge 144 and later, and Node.js 26, where it is enabled by default. Safari and iOS Safari do not support it yet, and because iOS browsers ship WebKit in practice, that gap covers all iPhones and iPads. Feature-detect or load a polyfill before shipping.
The limit: this needs a rendered page and executed JavaScript. It is unavailable when you send a scheduled email, run a nightly job, render a first server-side response, process a webhook, or serve a non-browser API client. It is also user-controlled, which rules it out on its own for fraud screening or compliance logs.
Method 2: Build an IP-to-Country-to-Timezone Pipeline from Open Data
You can build a working pipeline entirely from public-domain and open data, with no vendor account. It takes two datasets.
Dataset One: IP to Country
The five Regional Internet Registries (AFRINIC, APNIC, ARIN, LACNIC, RIPE NCC) publish their allocations daily as plain text in the RIR statistics exchange format. Each record gives a registry, country code, resource type, range start, count, date, and status.
import bisect, ipaddress, urllib.request
RIR_FILES = [
"https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest",
"https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest",
"https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest",
"https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest",
"https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest",
]
def load_ipv4_ranges():
"""registry|cc|type|start|value|date|status -> sorted (start, end, cc)."""
rows = []
for url in RIR_FILES:
with urllib.request.urlopen(url) as resp:
text = resp.read().decode("utf-8", "replace")
for line in text.splitlines():
if line.startswith("#") or "|" not in line:
continue
f = line.split("|")
if len(f) < 7 or f[2] != "ipv4":
continue
if f[1] in ("", "*") or f[6] not in ("allocated", "assigned"):
continue # skips header and summary rows
start = int(ipaddress.IPv4Address(f[3]))
rows.append((start, start + int(f[4]) - 1, f[1]))
rows.sort()
return rows, [r[0] for r in rows]
def country_for_ip(ip, rows, starts):
n = int(ipaddress.IPv4Address(ip))
i = bisect.bisect_right(starts, n) - 1
if i < 0:
return None
start, end, cc = rows[i]
return cc if n <= end else None
Dataset Two: Country to Timezone
The IANA time zone database ships zone1970.tab, a public-domain table mapping ISO country codes to zone identifiers. Countries with more than one zone appear on more than one row.
def zones_by_country(path="zone1970.tab"):
table = {}
with open(path, encoding="utf-8") as fh:
for line in fh:
if line.startswith("#") or not line.strip():
continue
cols = line.rstrip("\n").split("\t")
for cc in cols[0].split(","):
table.setdefault(cc, []).append(cols[2])
return table
zones = zones_by_country()
zones["PK"] # ['Asia/Karachi'] -> resolved
zones["US"] # ['America/New_York', ...] -> ambiguous, country is not enough
The limit: this resolves cleanly only for single-zone countries. For the United States, Canada, Russia, Australia, Brazil, and Mexico you get a list, not an answer. The registry field can also disagree with reality in a way that has nothing to do with precision. Run this pipeline against 51.12.0.1 and it returns GB, because that block's registry record traces back to a legacy assignment rather than to wherever the address is deployed today. Method 4, further down, resolves this same address to Europe/Stockholm, because it geolocates the current deployment instead of reading the registry field. RIPE Labs has documented a related case: a single 2020 reclassification moved 3.4 million IPv4 addresses into the "US" country code as a pure administrative change, with no address physically moving anywhere. You get no city from this pipeline, no coordinates, and no transition data.
Method 3: Resolve Coordinates to a Timezone with timezonefinder
If you already hold coordinates, from a device GPS, a map interaction, or a geocoded address, timezonefinder resolves a zone offline. The library is MIT licensed; its boundary data comes from timezone-boundary-builder, built from OpenStreetMap and published under the Open Database License.
from timezonefinder import TimezoneFinder
tf = TimezoneFinder(in_memory=True) # create once, reuse
tf.timezone_at(lng=-0.0931, lat=51.5142) # 'Europe/London'
It runs a point-in-polygon test against timezone boundaries without simplifying the polygons, so it stays correct at a border, and it covers more than 440 zone identifiers.
The limit: the boundary dataset is a real dependency, roughly 62 MB once installed, noticeable in a container image or serverless bundle. Boundaries are republished as borders change, so pin the timezonefinder-data package to a specific version to hold a deployment to one boundary release rather than floating on pip install timezonefinder. The dataset also covers open ocean with Etc/GMT offset zones, so there is no coordinate on Earth that returns None; an offshore point still comes back with a zone name that looks like a real answer. And it answers only the coordinate question: no IP handling, no current time, no next clock change.
Why a Timezone Name Is Not Enough: DST Gaps and Overlaps
Twice a year in observing regions, local time misbehaves in two specific ways, and both fail silently.
- A gap. When clocks spring forward, an hour of local time never happens. London jumps from 01:00 straight to 02:00 on 29 March 2026, so
2026-03-29 01:30is not a real moment. Accept it from a form and you have stored a timestamp that never existed. - An overlap. When clocks fall back, an hour happens twice. London runs 01:00 to 02:00 in BST on 25 October 2026, then repeats it in GMT.
2026-10-25 01:30matches two instants an hour apart, and nothing in the string says which the user meant.
Python models this with PEP 495 fold:
from datetime import datetime
from zoneinfo import ZoneInfo
london, utc = ZoneInfo("Europe/London"), ZoneInfo("UTC")
# Gap: this local time never existed, and it does not survive a round trip.
ghost = datetime(2026, 3, 29, 1, 30, tzinfo=london)
print(ghost.astimezone(utc).astimezone(london)) # 02:30, not 01:30
# Overlap: one wall clock reading, two real instants.
first = datetime(2026, 10, 25, 1, 30, tzinfo=london, fold=0)
second = datetime(2026, 10, 25, 1, 30, tzinfo=london, fold=1)
print(first.astimezone(utc), second.astimezone(utc)) # 00:30 UTC and 01:30 UTC
Any scheduler, reminder, billing cycle, or calendar invite that ignores this creates wrong appointments twice a year, and the bug reports arrive months after the code ships.
Note what the standard library will not give you: a list of upcoming transitions. zoneinfo reports the offset at an instant you name. To find out when a zone next changes, you probe forward hour by hour or parse the TZif binary yourself. Anything that warns users, pre-computes a schedule, or pins a recurring event across a clock change has to build that.
What Does the Do-It-Yourself Stack Actually Take to Maintain?
Put the open-data path together, registry files plus boundary data plus the tz database, and the maintenance ledger reads like this.
- Three datasets, three cadences. Registry files refresh daily. Timezone boundaries are republished as borders change. The IANA tz database shipped 2026a on 1 March, 2026b on 22 April, and 2026c on 8 July, and rule changes sometimes land only weeks before they take effect. Morocco's move to permanent UTC+00:00 on 20 September 2026 arrived in 2026c; a runtime pinned to an older release is wrong from that date.
- Bundle weight. Boundary data alone is tens of megabytes to ship and load.
- A country-level ceiling. Registry data cannot separate Denver from Chicago, and the countries where that matters hold the most users.
- No transition data. The current offset is easy. Knowing when it changes is what products actually need.
- One pipeline per input format. The first requirement for airport codes, shipping locations, or plain address strings adds another dataset and another integration each.
Method 4: Resolve a Timezone in One API Call
The Timezone Lookup API collapses the whole chain into one request and keeps every underlying dataset current.
Call it with no parameters and it resolves the calling machine's own IP:
curl "https://api.apifreaks.com/v2.0/geolocation/timezone?apiKey=YOUR_KEY"
Pass an address explicitly for the server-side case, using the client IP you extracted from X-Forwarded-For:
curl "https://api.apifreaks.com/v2.0/geolocation/timezone?apiKey=YOUR_KEY&ip=51.12.0.1"
The response carries location and timezone together, including the transition data the standard library withholds:
{
"ip": "51.12.0.1",
"location": {
"continent_code": "EU",
"continent_name": "Europe",
"country_code2": "SE",
"country_code3": "SWE",
"country_name": "Sweden",
"country_name_official": "Kingdom of Sweden",
"is_eu": true,
"state_prov": "Skane County",
"state_code": "SE-M",
"district": "Malmo",
"city": "Malmo",
"zipcode": "211 43",
"latitude": "55.59669",
"longitude": "13.00110"
},
"time_zone": {
"name": "Europe/Stockholm",
"offset": 1,
"offset_with_dst": 2,
"date": "2026-09-08",
"date_time": "2026-09-08 10:14:50",
"date_time_txt": "Tuesday, September 08, 2026 10:14:50",
"date_time_wti": "Tue, 08 Sep 2026 10:14:50 +0200",
"date_time_ymd": "2026-09-08T10:14:50+0200",
"current_time": "2026-09-08 10:14:50.593+0200",
"current_time_unix": 1788855290.593,
"time_24": "10:14:50",
"time_12": "10:14:50 AM",
"week": 37,
"month": 9,
"year": 2026,
"year_abbr": "26",
"current_tz_abbreviation": "CEST",
"current_tz_full_name": "Central European Summer Time",
"standard_tz_abbreviation": "CET",
"standard_tz_full_name": "Central European Standard Time",
"is_dst": true,
"dst_savings": 1,
"dst_exists": true,
"dst_tz_abbreviation": "CEST",
"dst_tz_full_name": "Central European Summer Time",
"dst_start": {
"utc_time": "2026-03-29 TIME 01:00",
"duration": "+1.00H",
"gap": true,
"date_time_after": "2026-03-29 TIME 03:00",
"date_time_before": "2026-03-29 TIME 02:00",
"overlap": false
},
"dst_end": {
"utc_time": "2026-10-25 TIME 01:00",
"duration": "-1.00H",
"gap": false,
"date_time_after": "2026-10-25 TIME 02:00",
"date_time_before": "2026-10-25 TIME 03:00",
"overlap": true
}
}
}
Four things there replace work from the sections above:
dst_startanddst_endwithgapandoverlapflags. These give the exact UTC instant of the next change, name which failure mode it creates, and spell out the wall-clock times on either side. In the response above,dst_start.date_time_beforereads 02:00 anddate_time_afterreads 03:00, meaning 02:00 to 02:59 never happens that day. This goes well beyond a bareis_dstboolean, and is the schedule-safety data you would otherwise hand-roll by probing TZif files.- Six time formats in one payload, including RFC 2822 and ISO 8601, so nothing needs reformatting downstream.
- Six other ways in. The same endpoint accepts GPS coordinates, a city or address string, an IATA code, an ICAO code, a UN/LOCODE, or an IANA zone name. IATA and ICAO lookups return full airport details and UN/LOCODE lookups return the location type, so travel and logistics systems skip the separate airport and port datasets entirely. Both endpoints in the Timezone APIs category share those input methods.
- Localisation. Geolocation fields return in nine languages through the
langparameter for IP and address lookups.
Each call costs one credit, only successful responses are charged, and a free key includes 10,000 credits with no card.
This resolves everything server-side in a single request, which fits naturally into request-time flows like sign-up or session start. For fully offline or air-gapped systems with no network path at all, Method 3's coordinate lookup runs entirely locally.
Two neighbouring endpoints matter here. To convert an existing timestamp between two zones instead of reading the current time, the Timezone Converter endpoint accepts the same inputs other than IP, since converting a timestamp needs a named zone on both ends rather than a network location to resolve. If the same request also needs city, ISP, ASN, and currency, the IP Geolocation API returns a full time_zone object with the same DST transition fields alongside the location data, so one call covers both.
How Accurate Is IP-Based Timezone Detection?
Accuracy depends entirely on the granularity you need.
A study in ACM SIGCOMM Computer Communication Review compared commercial and free geolocation databases against ground-truth data from a large European ISP and concluded that databases can claim country-level accuracy but not city-level, with some locations off by hundreds or thousands of kilometres. A 2020 controlled study at the Applied Networking Research Workshop measured hop-level addresses on end-to-end network paths rather than end-user connections, and found that even country-level results degrade for addresses on networks with a global presence.
For timezone work, that is less damaging than it sounds: most countries use a single zone, so country-level precision resolves them outright. The risk concentrates in multi-zone countries, where a city-level miss moves a user an hour or more.
Four factors make it worse:
- VPNs and proxies. The IP reflects the exit server, not the person. VPN use is routine, not an edge case. To check whether a resolved timezone came from an anonymised connection, the IP Threat Intelligence API returns an
is_vpnflag for the same IP. - Mobile carriers. Carrier-grade NAT routes many subscribers through a handful of gateways, placing users in a hub city.
- Corporate networks. A single egress gateway makes every office look like one location.
- Gaps. Some addresses resolve to no location at all, including unallocated ranges, satellite links, and parts of IPv6 space.
Where a wrong hour has consequences, such as legal deadlines, medical scheduling, or payroll cutoffs, always expose a manual override, and prefer coordinates or an airport code when your system already holds one.
The Production Pattern That Survives Real Users
Working systems combine the methods rather than picking one.
- Default from the IP, server side, so nothing is empty or wrong on first render.
- Confirm in the browser with
Intl.DateTimeFormat().resolvedOptions().timeZone, and prefer it when the two disagree. - Let the user override, and save the choice. A stored preference beats any inference.
- Store the IANA identifier against the account, not an offset or an abbreviation.
- Store timestamps in UTC and convert on display, so a rule change never rewrites history. (This is one piece of a larger pattern; for the full storage, format, and validation approach, see How to Handle Unix Timestamps and Timezones in REST APIs.)
- Fall back to UTC when nothing resolves. It is unambiguous, has no DST, and clearly marks the value as undetermined.
Conclusion
Every link in the chain is available as open data: registry files for the country, zone1970.tab for the zone, timezonefinder for coordinates, zoneinfo for the arithmetic. That path is worth understanding and worth choosing when country precision is all you need. It stops being enough the moment a product needs city precision, a second input format, or to know when the next clock change lands rather than just the current offset. The Timezone Lookup API covers that gap in one request.
Frequently Asked Questions
Can you get a timezone from an IP address?
Yes, indirectly. An IP resolves to an approximate location, and that location maps to an IANA identifier such as Europe/London. The result is an estimate: reliable at country level, weaker at city level, and wrong when the user is on a VPN.
Can you convert an IP address to a location?
Yes. That is the first half of the two-step chain this guide covers: an IP resolves to a geographic location through an IP geolocation lookup, and that location then maps to a timezone. The location alone (country, region, city, coordinates) is available without going any further, if a timezone is not what you need.
What is my IP timezone?
It is the IANA timezone matching the location your public IP resolves to, which is your network's location rather than necessarily your own. Calling a timezone endpoint with no parameters auto-detects the caller's IP and returns it.
How accurate is IP-to-timezone detection?
Country level is dependable; city level is not. Published measurement studies find geolocation databases accurate by country while placing individual addresses hundreds of kilometres from their true position. For single-zone countries that is sufficient. For multi-zone countries, expect occasional errors and provide an override.
How do I resolve a timezone from an IP on the server?
Extract the client IP with the client_ip() function from earlier, pass it to a timezone endpoint, and store the returned identifier against the user:
import os
import requests
ip = client_ip(request.headers, request.remote_addr)
res = requests.get(
"https://api.apifreaks.com/v2.0/geolocation/timezone",
params={"ip": ip},
headers={"X-apiKey": os.environ["APIFREAKS_KEY"]}
)
time_zone = res.json()["time_zone"]
time_zone["name"] # "Europe/Stockholm"
time_zone["dst_end"]["overlap"] # True, the next transition repeats an hour
Pass the key as the X-apiKey header, as above, or as an apiKey query parameter.
What is the difference between IP-based and browser-based timezone detection?
IP-based detection runs server side, before any JavaScript, and reflects the network's location. Browser-based detection reads the operating system setting and is more precise, but needs a rendered page and is user-controlled. Use the IP as the default and the browser as confirmation.
Should I store the timezone name or the UTC offset?
The name. America/Denver carries the rules for when clocks change; UTC-7 is a snapshot, and several regions changed their rules during 2026 alone.
What is the best fallback timezone?
UTC, since it clearly marks the value as undetermined rather than silently guessing wrong.
Is there a free way to get timezone data from an IP?
Yes, on both paths. You can build it from public-domain registry data and the IANA time zone database at no cost beyond maintenance, or use a hosted endpoint on a free tier. An APIFreaks key includes 10,000 credits with no card, and a timezone lookup costs one credit per successful call.
