Get the time zone from an IP address (and when the browser knows better)

The timezone field in an IP lookup is an IANA name such as Europe/Athens, which is exactly what date libraries want. Here is how to use it on the server, when the browser's own setting is the better source, and the DST mistakes to avoid.

On this page What the API returns

Showing "Your webinar starts at 18:00 your time", sending a reminder email at 9 in the morning local time, or bucketing analytics by local day: all of them need the user's time zone. Often you don't have it. The user hasn't set a preference, or the code runs on a server that never sees the browser.

An IP lookup gives you a good estimate. This guide shows how to get it, how to use it without DST bugs, and when another source is better.

What the API returns

Every lookup includes a timezone field:

bash
curl "https://ipapi.gr/api/lookup/8.8.8.8?key=YOUR_API_KEY"
json
{
  "ip": "8.8.8.8",
  "country_code": "US",
  "city": "Mountain View",
  "timezone": "America/Los_Angeles"
}

The response is trimmed. See the API reference for every field.

timezone is an IANA time zone name from the tz database, the same names your operating system, browsers and date libraries use. That matters for two reasons:

  • It's not an offset. America/Los_Angeles is UTC-8 in winter and UTC-7 in summer. A name carries the full history and future of DST rules. A fixed offset like -07:00 is wrong for half the year.
  • Every mainstream library accepts it. JavaScript Intl, Python zoneinfo, PHP DateTimeZone and Go time.LoadLocation all take it directly.

Like every field, timezone can be the string "No Data" when no value is known. Treat that as "unknown" and fall back to UTC or a default you choose.

IP or browser: which source should you trust?

If you can run code in the browser, ask it:

js
Intl.DateTimeFormat().resolvedOptions().timeZone; // e.g. "Europe/Athens"

That's the device's own setting. It's free, instant, involves no lookup, and it's right for travellers whose phone has switched zones. It's the best source when you have a browser.

The IP time zone is the better choice, or the only one, when:

  • The code runs on the server before any page is rendered, such as the first HTML response, emails and push notifications.
  • There is no browser at all: API clients, webhooks, server-to-server sign-ups.
  • You want a second opinion. A device set to UTC, or to a home time zone while travelling, is common. If the two sources disagree, ask the user.

A good order of precedence: user preference → browser time zone (saved when you see it) → IP time zone → UTC.

Node.js

The known() helper turns "No Data" into null, so the fallback is explicit:

js
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"

export async function timeZoneFor(ip) {
  try {
    const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`,
      { signal: AbortSignal.timeout(2000) });
    if (!res.ok) return null;
    const tz = known((await res.json()).timezone);
    return tz && isValidTimeZone(tz) ? tz : null;
  } catch {
    return null;
  }
}

function isValidTimeZone(tz) {
  try { new Intl.DateTimeFormat('en', { timeZone: tz }); return true; } catch { return false; }
}

// Show a UTC instant in the visitor's zone
const tz = (await timeZoneFor('8.8.8.8')) ?? 'UTC';
const start = new Date('2026-12-03T16:00:00Z');
console.log(new Intl.DateTimeFormat('en-US', {
  timeZone: tz, weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
  hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
}).format(start));
// "Thursday, December 3, 2026 at 8:00 AM PST"

isValidTimeZone guards against names your runtime's time zone data doesn't know. That can happen with a very new zone on an old runtime. Without the check, Intl would throw later, in the middle of rendering.

Python

Python 3.9+ ships zoneinfo. On Windows, or in slim containers without system time zone data, install the tzdata package so the names resolve.

python
import os
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import requests

session = requests.Session()


def known(v):
    return None if v in (None, "", "No Data") else v  # missing fields are "No Data"


def time_zone_for(ip: str) -> ZoneInfo:
    try:
        r = session.get(f"https://ipapi.gr/api/lookup/{ip}",
                        params={"key": os.environ["IPAPI_KEY"]}, timeout=2)
        name = known(r.json().get("timezone")) if r.status_code == 200 else None
        return ZoneInfo(name) if name else ZoneInfo("UTC")
    except (requests.RequestException, ValueError, ZoneInfoNotFoundError):
        return ZoneInfo("UTC")


tz = time_zone_for("8.8.8.8")
start = datetime(2026, 12, 3, 16, 0, tzinfo=timezone.utc)
print(start.astimezone(tz).strftime("%A %d %B %Y, %H:%M %Z"))
# Thursday 03 December 2026, 08:00 PST

PHP

php
<?php
function known($v) {
    return ($v === null || $v === '' || $v === 'No Data') ? null : $v; // missing fields are "No Data"
}

function time_zone_for(string $ip): DateTimeZone {
    $ch = curl_init('https://ipapi.gr/api/lookup/' . rawurlencode($ip) . '?key=' . urlencode(getenv('IPAPI_KEY')));
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 2]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $name = ($body !== false && $status === 200) ? known(json_decode($body, true)['timezone'] ?? null) : null;
    try {
        return new DateTimeZone($name ?? 'UTC');
    } catch (Exception $e) {
        return new DateTimeZone('UTC');   // unknown name in this PHP's tz data
    }
}

$tz = time_zone_for('8.8.8.8');
$start = new DateTimeImmutable('2026-12-03 16:00:00', new DateTimeZone('UTC'));
echo $start->setTimezone($tz)->format('l j F Y, H:i T');
// Thursday 3 December 2026, 08:00 PST

Go

time.LoadLocation reads the system's time zone files. In minimal container images that have none, add import _ "time/tzdata" to embed the database in your binary.

go
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "os"
    "time"
    _ "time/tzdata" // embedded tz database for scratch/distroless images
)

var client = &http.Client{Timeout: 2 * time.Second}

func known(s string) string {
    if s == "No Data" { // missing fields are "No Data"
        return ""
    }
    return s
}

func timeZoneFor(ip string) *time.Location {
    resp, err := client.Get("https://ipapi.gr/api/lookup/" + url.PathEscape(ip) + "?key=" + url.QueryEscape(os.Getenv("IPAPI_KEY")))
    if err != nil {
        return time.UTC
    }
    defer resp.Body.Close()
    var g struct {
        Timezone string `json:"timezone"`
    }
    if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&g) != nil {
        return time.UTC
    }
    name := known(g.Timezone)
    if name == "" {
        return time.UTC
    }
    loc, err := time.LoadLocation(name)
    if err != nil {
        return time.UTC
    }
    return loc
}

func main() {
    start := time.Date(2026, 12, 3, 16, 0, 0, 0, time.UTC)
    fmt.Println(start.In(timeZoneFor("8.8.8.8")).Format("Monday 2 January 2006, 15:04 MST"))
    // Thursday 3 December 2026, 08:00 PST
}

At the edge

On an edge worker, you can add the time zone to the request before it reaches your origin, so server-rendered pages show local times on first load:

js
// Cloudflare Worker; key stored as a secret (wrangler secret put IPAPI_KEY)
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"

export default {
  async fetch(request, env) {
    let tz = null;
    try {
      const ip = request.headers.get('CF-Connecting-IP');
      const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${env.IPAPI_KEY}`,
        { signal: AbortSignal.timeout(1000) });
      if (res.ok) tz = known((await res.json()).timezone);
    } catch { /* fall through to UTC */ }
    const upstream = new Request(request);
    upstream.headers.set('X-Visitor-TZ', tz ?? 'UTC');
    return fetch(upstream);
  }
};

Workers also expose request.cf.timezone directly. If you're on Cloudflare and only need the time zone, that's the cheaper option. The API is useful when you want the same answer at the edge and in your backend or batch jobs, or the other fields at the same time.

As with any header your origin trusts, strip X-Visitor-TZ from requests that didn't come through the worker.

Scheduling emails in local time

"Send the reminder at 09:00 the recipient's time" is the classic case, because emails are sent by a server and there's no browser to ask. Store the time zone name with the user when they sign up, using the browser value if you have it and the IP value otherwise. Then compute each send time with that name:

python
from datetime import date, datetime, time
from zoneinfo import ZoneInfo

def next_send_at_utc(tz_name: str, on: date, at: time = time(9, 0)) -> datetime:
    local = datetime.combine(on, at, tzinfo=ZoneInfo(tz_name))
    return local.astimezone(ZoneInfo("UTC"))

print(next_send_at_utc("Europe/Athens", date(2026, 3, 28)))  # 2026-03-28 07:00:00+00:00 (UTC+2)
print(next_send_at_utc("Europe/Athens", date(2026, 3, 30)))  # 2026-03-30 06:00:00+00:00 (UTC+3, after the DST change)

The DST change in between moves the UTC time by an hour. A stored offset would have sent one of those emails at the wrong local time.

Grouping analytics by the visitor's local day

"What time of day do people sign up?" is a different question from "at what UTC hour do sign-ups arrive?". With a global audience, the UTC version mixes morning in Europe with night in the Americas. Store the time zone name with each event (you already have it from the lookup) and convert at query time:

sql
-- PostgreSQL: sign-ups by the visitor's local hour, last 30 days
SELECT extract(hour FROM created_at AT TIME ZONE tz) AS local_hour, count(*)
FROM signups
WHERE created_at > now() - interval '30 days' AND tz IS NOT NULL
GROUP BY 1 ORDER BY 1;

created_at is a timestamptz, and tz holds names like Europe/Athens. Store NULL rather than "No Data" when the zone is unknown, so the query can filter those rows out cleanly. The same idea works in any warehouse that understands IANA names.

For reporting by your business day, such as daily revenue, keep using one fixed zone for everything. Local-day grouping is for questions about user behaviour, not for accounting.

DST and other traps

  • Store names, not offsets. Never save +02:00 as "the user's time zone". Save Europe/Athens and let the library compute the offset for each date.
  • Store instants in UTC. Convert to local time only for display or scheduling.
  • Local times can be missing or repeated. When clocks spring forward, 02:30 may not exist that day. When they fall back, 01:30 happens twice. Schedule recurring jobs at times that are unlikely to fall in the switch, or handle the edge case your library reports.
  • Keep your tz data current. Governments change DST rules, sometimes at short notice. Update your OS packages, tzdata, or your runtime regularly.
  • Abbreviations are ambiguous. CST means different things in different countries. Use the full name in your logic and show abbreviations only to people.

How reliable is the IP time zone?

The time zone comes from the estimated location, so it's as reliable as that location. In most countries with a single time zone, a correct country means a correct time zone. In countries with several, such as the US, Canada, Russia, Brazil, Australia, Mexico and Indonesia, it depends on getting the region right, which is less certain. Mobile carriers and corporate networks that route traffic through a distant city can produce a time zone hours away. VPN users get the VPN's time zone. More in how accurate IP geolocation is.

That's why the browser value or the user's preference should win whenever you have one, and why showing the zone next to the time ("08:00 PST") is a good habit. It lets people notice when the guess is wrong.

FAQ

Is the timezone field affected by the user's device settings?

No. It comes from the IP address only. The device setting is what Intl.DateTimeFormat().resolvedOptions().timeZone returns in the browser.

Can I get the current UTC offset from the API?

The response contains the time zone name, not an offset. Compute the offset for any date with your date library. It's one line in every language above, and it stays correct across DST changes.

What does "No Data" mean for timezone?

No time zone is known for that address. Use your fallback: the user's saved preference, the browser value, or UTC.

Does it work for IPv6 addresses?

Yes. Pass the IPv6 address in the path as-is. See IPv6 geolocation for the details that differ.

Related: get a visitor's country from their IP address · localize currency and shipping country by IP.

Look up an IP address now

Free IP lookup: location, network and bot flags, no account needed.