How to Handle time_zone Offsets, epoch Fields, and DST Gaps Without Getting Burned

Weather APIs return timestamps in at least two representations: a human-readable local string like 2024-03-10 02:30 and a Unix epoch integer. Most developers grab whichever looks convenient and move on. That works until it doesn’t — and when it breaks, it breaks in ways that are genuinely hard to diagnose because the bad data looks plausible.

This post covers exactly where that goes wrong with WeatherAPI.com responses, and how to handle it correctly from the start.

What the Response Actually Contains

A forecast hour from the /forecast.json endpoint gives you two things for each hourly block:

  • time — a string in the format YYYY-MM-DD HH:MM, local to the location you queried
  • time_epoch — a Unix timestamp (seconds since 1970-01-01 00:00:00 UTC)

The current conditions block (current.last_updated and current.last_updated_epoch) follows the same pattern. The astronomy block for sunrise and sunset times does not — those come as human-readable strings only, no epoch companion. If you’re calculating solar windows programmatically, you’ll need to reconstruct a full datetime from them (more on that below).

The location object also gives you localtime, localtime_epoch, tz_id (an IANA timezone string like America/Chicago), and utc_offset (a fixed numeric offset in hours at the time of the request). That last one is the field that causes the most trouble in production.

The DST Problem You’re Probably Not Testing For

The US shifts clocks forward on the second Sunday of March — in 2024, that was March 10. At 2:00 AM local time in the Eastern timezone, clocks jump to 3:00 AM. The hour from 02:00–02:59 simply doesn’t exist in local time.

If you’re doing arithmetic using time strings — adding 3600 seconds to a parsed local datetime, or stepping through hours in a loop — you’ll either produce a timestamp that doesn’t exist or skip an hour silently. Neither will throw an exception in most runtimes. Python’s datetime without explicit tzinfo will happily create a datetime(2024, 3, 10, 2, 30) object. It just won’t mean anything coherent.

The reverse happens in autumn when clocks fall back: a local hour exists twice, and naive code picks one without telling you which.

time_epoch doesn’t have this problem. Unix time is continuous. 02:30 Eastern on March 10 2024 has no epoch value, so the API skips it. If you iterate over time_epoch values from the forecast array, you’ll see them jump by 7200 instead of 3600 right at the transition. That’s correct behavior — the gap is visible and testable rather than silently wrong.

The Fixed Offset Trap

The utc_offset field is tempting because it looks like everything you need. The problem is that it’s a snapshot of the offset at the moment you made the request. Store that value and reuse it to convert future timestamps, and you’ll be wrong for roughly half the year for any location that observes DST.

This bites hardest in scheduling applications. A system that pulls a 10-day forecast, stores it with a hardcoded offset conversion, then queries it later to generate push notifications will get the wrong hour for any timestamp that falls after a DST boundary — and the logs will show nothing obviously wrong, because all the UTC conversions look internally consistent. The offset was accurate when it was captured; it just stopped being accurate later.

The fix is to use tz_id for all timezone operations, never utc_offset. IANA timezone databases — Python’s zoneinfo or pytz, JavaScript’s Intl or luxon, .NET’s TimeZoneInfo with IANA support — understand historical and future DST transitions. A fixed offset does not.

# Python example using zoneinfo (3.9+)
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")  # from tz_id field
epoch = 1710054600  # time_epoch from API response

# Correct regardless of DST
local_dt = datetime.fromtimestamp(epoch, tz=tz)
print(local_dt)  # 2024-03-10 03:30:00-04:00

On Python 3.8 and earlier, use pytz instead of zoneinfo — but be careful with the API. Use pytz.timezone(tz_id).localize(naive_dt) rather than passing tzinfo directly to the datetime constructor, or DST fold handling will be wrong in ambiguous cases.

Epoch Is Your Source of Truth for Comparisons

Any time you need to compare two timestamps — is this forecast hour before or after some threshold, is the current reading fresher than N minutes, did sunrise already happen — do it in epoch space. Direct epoch subtraction gives you elapsed seconds with no timezone or DST involvement.

The time string is for display. The time_epoch is for logic. Mixing the two is where the bugs live.

A pattern that works well: store time_epoch as your primary sort column in the database, keep tz_id alongside it, and only derive a local display string at render time. In PostgreSQL, store time_epoch as a bigint, then use to_timestamp(time_epoch) AT TIME ZONE tz_id to get a proper timestamptz when you need local representation. The conversion happens at query time with a live-correct timezone rule, not at ingestion time with a stale offset.

Where the Astronomy Strings Make This Harder

The astronomy endpoint returns sunrise and sunset as strings: "06:42 AM". No epoch companion, no date embedded in the value. To compare against current time and determine whether it’s currently daylight, you need to reconstruct a full datetime by combining the astronomy date parameter you sent in the request with the time string, then localize using tz_id.

Skip any of those three steps and you’ll get wrong answers near midnight, around DST transitions, or when querying dates in a timezone different from the server making the call. This is the kind of thing that works fine during summer testing and breaks in October.

A Note on the Extended Forecast Window

The extended forecast plans give you hourly data up to 300 or 336 hours out. Across a two-week window, a query made in October in a US timezone will span both pre- and post-autumn DST transition. Code that walks the hourly array and counts hours from a starting point will be off by one past the transition. Walk by time_epoch values instead, or re-derive each hour’s local time from its own epoch value individually. Don’t accumulate offsets from a starting point.

Quick Reference: Which Field to Use When

  • Sorting or comparing timestamps: time_epoch always
  • Displaying local time to a user: derive from time_epoch + tz_id, or use the time string directly (it’s already local)
  • Timezone conversion: tz_id (IANA string), never utc_offset
  • Checking data freshness: compare current.last_updated_epoch to current Unix time
  • Astronomy rise/set comparisons: reconstruct full datetime from string + request date + tz_id

None of this is WeatherAPI-specific. Every weather API with local timestamps has these same failure modes. The epoch fields exist precisely so you don’t have to parse ambiguous local strings — they’re only useful if you actually use them.

If your schema isn’t storing tz_id alongside timestamps, fix that before the next clock change rather than after.

Scroll to Top