Flights That Arrive Before They Depart: Local Times Across Time Zones
On this page
Problem statement
Subtract DepTime from ArrTime in the BTS on-time file to get each flight's duration and 324,660 flights in 2025 arrive before they leave. Half the rest come out wrong by an hour or more without looking wrong at all. The times are local clock times at each airport, with no time zone and no date of their own, so a flight from Chicago to New York gains an hour in the subtraction, one from Denver to Los Angeles loses one, and an evening flight that lands after midnight loses a day.
This page attaches an IANA time zone to each of the 352 airports from its coordinates, converts every departure and arrival to UTC, and checks the result against the file's own elapsed times.
Quick answer
Look up each airport's time zone from its latitude and longitude with timezonefinder, localize departure times at the origin, add the elapsed time, and convert to the destination's zone:
from zoneinfo import ZoneInfo
from timezonefinder import TimezoneFinder
def clock(date, hhmm):
hhmm = hhmm.str.zfill(4)
return date + pd.to_timedelta(hhmm.str[:2].astype(float) * 60 + hhmm.str[2:].astype(float), unit="min")
def utc(local_times, codes):
out = pd.Series(pd.NaT, index=local_times.index, dtype="datetime64[us, UTC]")
for code, idx in local_times.groupby(codes).groups.items():
out.loc[idx] = (local_times.loc[idx].dt.tz_localize(airports.tz[code], ambiguous="NaT", nonexistent="NaT")
.dt.tz_convert("UTC"))
return out
def local(utc_times, codes):
out = pd.Series(pd.NaT, index=utc_times.index, dtype="datetime64[us]")
for code, idx in utc_times.groupby(codes).groups.items():
out.loc[idx] = utc_times.loc[idx].dt.tz_convert(airports.tz[code]).dt.tz_localize(None)
return out
tf = TimezoneFinder()
airports["tz"] = [tf.timezone_at(lng=lon, lat=lat) for lat, lon in zip(airports.lat, airports.lon)]
f = flights[(flights.Cancelled == 0) & (flights.Diverted == 0)].copy()
dep_local = clock(f.FlightDate, f.CRSDepTime) + pd.to_timedelta(f.DepDelay, unit="min")
f["dep_utc"] = utc(dep_local, f.Origin)
f["arr_utc"] = f.dep_utc + pd.to_timedelta(f.ActualElapsedTime, unit="min")
f["arr_local"] = local(f.arr_utc, f.Dest)
hhmm = f.ArrTime.str.zfill(4)
recorded = (hhmm.str[:2].astype(float) * 60 + hhmm.str[2:].astype(float)) % 1440
ok = recorded.notna() & f.arr_local.notna()
check = (f.arr_local.dt.hour * 60 + f.arr_local.dt.minute)[ok] == recorded[ok]
print(f"{airports.tz.nunique()} time zones; arrival clock time reproduced for {check.mean():.3%} of {ok.sum():,} flights "
f"({(~ok).sum()} without an arrival time or in a repeated hour)")
22 time zones; arrival clock time reproduced for 99.998% of 6,879,474 flights (11 without an arrival time or in a repeated hour)
The 352 airports fall in 22 IANA time zones. Departure at the origin's local time, plus the recorded gate-to-gate time, converted to the destination's zone, reproduces the recorded arrival clock time for 99.998% of flights: the zones are right, and the times in the file are what they claim to be β local clock readings.
Clock times, zones and offsets
1. The symptom
naive = (clock(f.FlightDate, f.ArrTime) - clock(f.FlightDate, f.DepTime)).dt.total_seconds() / 60
print(f"ArrTime - DepTime negative: {(naive < 0).sum():,} ({(naive < 0).mean():.1%})")
print(f"ArrTime - DepTime differs from ActualElapsedTime by more than a minute: {((naive - f.ActualElapsedTime).abs() > 1).mean():.1%}")
ArrTime - DepTime negative: 324,660 (4.7%)
ArrTime - DepTime differs from ActualElapsedTime by more than a minute: 51.3%
4.7% of flights come out negative, and 51.3% differ from the file's ActualElapsedTime by more than a minute. The negatives are the visible symptom; the wrong positive durations are the larger problem, because nothing flags them.
2. Two causes, one subtraction
err = (naive - f.ActualElapsedTime).round()
print(err.value_counts().head(8).to_string())
0.0 3352635
-60.0 1075774
60.0 1054369
-120.0 356843
120.0 322153
-180.0 234990
180.0 163521
-1440.0 93687
The errors are whole hours: Β±60 minutes for about two million flights between neighbouring zones, Β±120 and Β±180 for longer crossings, and β1,440 β a full day β for 93,687 flights that landed after midnight. Westbound flights come out too short, eastbound ones too long. The correct duration is the 3,352,635 flights with zero error only by luck of staying in one zone and one day.
3. A time zone for every airport
print(airports.tz.value_counts().head(8).to_string())
print("Arizona and Hawaii keep standard time all year:", airports.loc[["PHX", "TUS", "HNL"], "tz"].to_dict())
tz
America/Chicago 116
America/New_York 98
America/Denver 39
America/Los_Angeles 37
America/Detroit 13
America/Anchorage 9
America/Phoenix 6
America/Boise 5
Arizona and Hawaii keep standard time all year: {'PHX': 'America/Phoenix', 'TUS': 'America/Phoenix', 'HNL': 'Pacific/Honolulu'}
Chicago's zone covers 116 of the airports and New York's 98. Several are legal subdivisions with their own history: America/Detroit, America/Boise, and America/Phoenix, which does not observe daylight saving time; nor does Pacific/Honolulu. A simple "Eastern, Central, Mountain, Pacific" lookup by state would get Arizona wrong for half the year.
4. The offset changes with the date
for d in ["2025-01-15", "2025-07-15"]:
t = pd.Timestamp(f"{d} 12:00")
print(d, {a: t.tz_localize(airports.tz[a]).strftime("%z") for a in ["JFK", "ORD", "DEN", "PHX", "LAX", "HNL"]})
2025-01-15 {'JFK': '-0500', 'ORD': '-0600', 'DEN': '-0700', 'PHX': '-0700', 'LAX': '-0800', 'HNL': '-1000'}
2025-07-15 {'JFK': '-0400', 'ORD': '-0500', 'DEN': '-0600', 'PHX': '-0700', 'LAX': '-0700', 'HNL': '-1000'}
In January, Phoenix shares Denver's offset (β07:00); in July it shares Los Angeles'. Hawaii is β10:00 all year, so its gap with the mainland changes by an hour twice a year. Offsets must therefore be looked up for each date, which is what localizing with a named zone does; a fixed offset per airport would be wrong for part of the year.
Elapsed time and the hour of day
The data used on this page
The Bureau of Transportation Statistics Reporting Carrier On-Time Performance files for 2025, whose times are local clock times at each airport, and airport coordinates from OurAirports in a copy saved on 15 October 2026. Time zones come from timezonefinder (pip install timezonefinder), which looks a point up in the IANA time-zone boundaries.
from pathlib import Path
import zipfile
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
BTS = "https://transtats.bts.gov/PREZIP/On_Time_Reporting_Carrier_On_Time_Performance_1987_present_2025_{}.zip"
COLS = ["FlightDate", "Reporting_Airline", "Tail_Number", "Flight_Number_Reporting_Airline", "Origin", "Dest",
"CRSDepTime", "DepTime", "DepDelay", "TaxiOut", "WheelsOff", "WheelsOn", "TaxiIn", "CRSArrTime", "ArrTime",
"ArrDelay", "Cancelled", "CancellationCode", "Diverted", "CRSElapsedTime", "ActualElapsedTime", "AirTime",
"Distance", "CarrierDelay", "WeatherDelay", "NASDelay", "SecurityDelay", "LateAircraftDelay"]
TEXT = {c: str for c in ["CRSDepTime", "DepTime", "WheelsOff", "WheelsOn", "CRSArrTime", "ArrTime", "CancellationCode",
"Tail_Number"]}
def read_month(m):
f = Path(f"data/On_Time_Reporting_Carrier_On_Time_Performance_1987_present_2025_{m}.zip")
if not f.exists():
f.write_bytes(requests.get(BTS.format(m), timeout=900).content)
with zipfile.ZipFile(f) as z:
name = next(n for n in z.namelist() if n.endswith(".csv"))
return pd.read_csv(z.open(name), usecols=COLS, dtype=TEXT, parse_dates=["FlightDate"], low_memory=False)
flights = pd.concat([read_month(m) for m in range(1, 13)], ignore_index=True)
# OurAirports is edited daily; this is the copy of 15 October 2026
# (live: https://davidmegginson.github.io/ourairports-data/airports.csv).
AIRPORTS = "ourairports-airports-2026-10-15.csv"
if not Path(f"data/{AIRPORTS}").exists():
Path(f"data/{AIRPORTS}").write_bytes(requests.get(f"https://data.spatialworkflow.io/{AIRPORTS}", timeout=300).content)
ap = pd.read_csv(f"data/{AIRPORTS}", usecols=["ident", "iata_code", "name", "latitude_deg", "longitude_deg"])
by_iata = ap.dropna(subset=["iata_code"]).drop_duplicates("iata_code").set_index("iata_code")
by_ident = ap.set_index("ident")
codes = pd.Index(sorted(set(flights.Origin) | set(flights.Dest)))
KEEP = ["name", "latitude_deg", "longitude_deg"]
airports = by_iata.reindex(codes)[KEEP]
gap = airports.latitude_deg.isna().values
airports.loc[gap, KEEP] = by_ident.reindex("K" + codes[gap])[KEEP].values # fall back to the US ident, K + code
airports = airports.rename(columns={"latitude_deg": "lat", "longitude_deg": "lon"})
print(f"{len(flights):,} flights, {len(codes)} airports, {airports.lat.isna().sum()} without coordinates")
7,001,619 flights, 352 airports, 0 without coordinates
Example 1: elapsed time from clock times, done right
arr_local = clock(f.FlightDate, f.CRSArrTime) + pd.to_timedelta(f.ArrDelay, unit="min")
arr_local = arr_local.where(arr_local >= dep_local - pd.Timedelta("8h"), arr_local + pd.Timedelta("1D"))
elapsed = (utc(arr_local, f.Dest) - f.dep_utc).dt.total_seconds() / 60
print(f"elapsed from localized clock times equals ActualElapsedTime: {((elapsed - f.ActualElapsedTime).abs() <= 1).mean():.3%}")
miss = f[(elapsed - f.ActualElapsedTime).abs() > 1]
print("where it does not:", miss.Origin.value_counts().head(4).to_dict(), miss.FlightDate.dt.strftime("%d %b").value_counts().head(3).to_dict())
elapsed from localized clock times equals ActualElapsedTime: 99.986%
where it does not: {'HNL': 406, 'GUM': 360, 'ATL': 13, 'LAX': 11} {'01 Nov': 45, '08 Mar': 26, '20 Nov': 6}
Going the other way β from the two local clock times to an elapsed time β works for 99.986% of flights once each end is localized and arrivals that land after midnight are moved to the next day. The misses are concentrated in two places: flights to and from Guam, across the date line, where "after midnight" means the previous day; and Honolulu, where long eastbound overnight flights break the eight-hour rollover rule. The remaining dates are the two daylight-saving nights, when one local hour is ambiguous or missing.
Example 2: clock time against true time across the country
f["dep_hour_utc"] = f.dep_utc.dt.hour
by_zone = f.assign(tz=f.Origin.map(airports.tz)).groupby("tz").agg(
flights=("dep_utc", "size"), local_peak=("CRSDepTime", lambda s: s.str[:2].astype(int).mode()[0]),
utc_peak=("dep_hour_utc", lambda h: int(h.mode()[0])))
print(by_zone.sort_values("flights", ascending=False).head(6).to_string())
flights local_peak utc_peak
tz
America/New_York 2528567 6 12
America/Chicago 1859440 7 23
America/Los_Angeles 1215215 7 15
America/Denver 573221 11 17
America/Phoenix 225386 8 15
America/Detroit 158250 8 12
Most departures leave at 6 or 7 a.m. local time, but the busiest UTC hour differs by zone, and not only by the zone's offset. One local hour maps to two UTC hours over a year because of daylight saving time, which splits the morning peak in UTC; the most frequent UTC hour can then be an evening one. Comparing airports' activity by true time means converting first and binning second.
Local time is a label
A clock time names a moment only together with the place it was read and the rule for that place on that date. The on-time file keeps the place (the airport) but drops the rule, which is fine for the question it was designed for β was this flight late by its own timetable? β and wrong for any question about elapsed time or simultaneity.
Putting the rule back is a spatial join: coordinates to a time-zone polygon, which is what timezonefinder does with the IANA boundaries. From then on pandas handles daylight saving time per date. Two cases still need care: overnight flights, whose arrival date is not in the file, and the date line. The file's own elapsed-time columns are the safest durations of all; the reason to localize is to get instants β for joining to weather, to radar, to another airport's queue at the same moment.
Time-zone traps in flight data
- Never subtract local clock times across airports. Use
ActualElapsedTimeorAirTime, or localize first. - Look up zones from coordinates, not from state: Arizona, parts of Indiana and the territories differ.
- Offsets change with the date. Localize with a named zone, not a fixed offset.
- Arrival dates are not in the file. Infer them, and check the result against the elapsed time.
- Guam and the date line need their own handling.
- Daylight-saving nights have an ambiguous or missing hour at every airport that observes it.
Internal links
- How to load and clean a year of US flight data β the departure-date trap
- Bike-share trips with negative durations β the same problem on the daylight-saving night
- How to see the jet stream in flight times β using AirTime instead of clock times
- How to follow an aircraft through its day β turn times computed at one airport
FAQ
Are BTS flight times in local time or UTC?
Local time at each airport, as four-digit text with no time zone. Subtracting departure from arrival gave a negative duration for 324,660 flights in 2025 and a wrong one for 51.3%.
How do I get the time zone of an airport in Python?
Look it up from the airport's coordinates with timezonefinder, which returns an IANA zone name such as America/Chicago; then localize times with pandas tz_localize.