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.

Map of airports in the lower 48 coloured by IANA time zone: Chicago, New York, Denver and Los Angeles zones form broad bands, with Phoenix airports in a separate colour in Arizona and small groups for Detroit, Boise, Indianapolis, Louisville and Menominee. Map of airports in the lower 48 coloured by IANA time zone: Chicago, New York, Denver and Los Angeles zones form broad bands, with Phoenix airports in a separate colour in Arizona and small groups for Detroit, Boise, Indianapolis, Louisville and Menominee.
Time zones attached to the airports from their coordinates with timezonefinder. Counts in the legend are airports.

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.

Bar chart of the error in flight duration computed as ArrTime minus DepTime, in steps of 60 minutes: 3.35 million flights at zero, about a million each at minus and plus 60, smaller bars at 120 and 180, and a red bar near minus 1,440 for overnight flights. Bar chart of the error in flight duration computed as ArrTime minus DepTime, in steps of 60 minutes: 3.35 million flights at zero, about a million each at minus and plus 60, smaller bars at 120 and 180, and a red bar near minus 1,440 for overnight flights.
The naive subtraction is wrong by whole hours: time-zone crossings make the Β±60 to Β±180 bars, landing after midnight makes βˆ’1,440.

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.

Paired bars for O'Hare departures by local clock hour and by UTC hour. Local hours are empty overnight and peak at 7 to 8 a.m. and 6 p.m.; UTC hours are shifted by five or six and spread the morning peak across two bars. Paired bars for O'Hare departures by local clock hour and by UTC hour. Local hours are empty overnight and peak at 7 to 8 a.m. and 6 p.m.; UTC hours are shifted by five or six and spread the morning peak across two bars.
The same departures counted in local and in UTC hours. Daylight saving time splits each local hour across two UTC hours over the year.

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 ActualElapsedTime or AirTime, 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.

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.