How to Follow an Aircraft Through Its Day and Measure Delay Propagation
On this page
Problem statement
A late flight is usually late because the aircraft was late arriving from somewhere else. The BTS on-time file records each flight's tail number β the registration of the aircraft that flew it β so the flights of one aircraft can be strung together into its day, and a delay can be followed from leg to leg as it is passed on, absorbed or made worse. That turns a table of independent flights into a set of movement tracks, one per aircraft.
The chaining has traps. Sorting an aircraft's flights by scheduled time gives the plan, not what happened, because airlines swap aircraft when one runs late. Arrival times can belong to the next day. This page builds the chains from 2025's 7 million flights, measures how much delay is handed on at each turn, and checks the result against the file's own late-aircraft column.
Quick answer
Build actual departure and arrival timestamps, sort by tail number and actual departure, and pair each flight with the next one when it leaves from the airport the first one landed at:
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")
f = flights[(flights.Cancelled == 0) & (flights.Diverted == 0) & flights.Tail_Number.notna()].copy()
f["dep_sched"] = clock(f.FlightDate, f.CRSDepTime)
arr = clock(f.FlightDate, f.CRSArrTime)
f["arr_sched"] = arr.where(arr >= f.dep_sched - pd.Timedelta("6h"), arr + pd.Timedelta("1D")) # landed after midnight
f["dep_actual"] = f.dep_sched + pd.to_timedelta(f.DepDelay, unit="min")
f["arr_actual"] = f.arr_sched + pd.to_timedelta(f.ArrDelay, unit="min")
f = f.sort_values(["Tail_Number", "dep_actual"]) # the order the aircraft actually flew
nxt = f.groupby("Tail_Number").shift(-1)
legs = f.assign(next_origin=nxt.Origin, next_dep_sched=nxt.dep_sched, next_dep_actual=nxt.dep_actual,
next_dep_delay=nxt.DepDelay, next_late_aircraft=nxt.LateAircraftDelay)
legs = legs[(legs.next_origin == legs.Dest) & (legs.next_dep_actual - legs.dep_actual < pd.Timedelta("18h"))]
legs["slack"] = (legs.next_dep_sched - legs.arr_sched).dt.total_seconds() / 60 # both clocks at the same airport
legs["ground"] = (legs.next_dep_actual - legs.arr_actual).dt.total_seconds() / 60
print(f"{len(legs):,} consecutive leg pairs on the same aircraft")
print(f"next departure delay vs this arrival delay: correlation {legs.ArrDelay.corr(legs.next_dep_delay):.2f}")
6,520,033 consecutive leg pairs on the same aircraft
next departure delay vs this arrival delay: correlation 0.38
6.5 million consecutive pairs of flights on the same aircraft. The correlation of 0.38 between one leg's arrival delay and the next leg's departure delay is the propagation in one number: substantial, but far from one, because the time on the ground between the two flights absorbs part of it.
Chains, turns and what gets passed on
1. Order each aircraft's flights
per_day = f.groupby(["Tail_Number", "FlightDate"]).size()
print(f"{f.Tail_Number.nunique():,} aircraft; legs per aircraft-day: median {per_day.median():.0f}, max {per_day.max()}")
chain = (nxt.Origin == f.Dest)
print(f"next leg leaves from where this one landed: {chain.mean():.1%} of legs with a successor")
6,127 aircraft; legs per aircraft-day: median 4, max 14
next leg leaves from where this one landed: 98.4% of legs with a successor
6,127 aircraft fly a median of four legs a day, and up to fourteen. In 98.4% of cases the next leg leaves from the airport where the previous one landed; the rest are gaps in the record, where the aircraft moved on a flight the file does not contain.
2. The scheduled turn is the buffer
print(pd.DataFrame({"scheduled turn": legs.slack, "actual time on the ground": legs.ground})
.describe(percentiles=[0.01, 0.1, 0.5, 0.9]).round(0).to_string())
print(f"next flight scheduled to leave before this one was scheduled to land: {(legs.slack < 0).sum():,} ({(legs.slack < 0).mean():.1%})")
scheduled turn actual time on the ground
count 6520033.0 6520033.0
mean 169.0 173.0
std 218.0 207.0
min -2384.0 -472.0
1% -15.0 27.0
10% 37.0 41.0
50% 65.0 72.0
90% 550.0 538.0
max 4454.0 1073.0
next flight scheduled to leave before this one was scheduled to land: 81,689 (1.3%)
The scheduled turn β time from the planned arrival to the next planned departure, both at the same airport so time zones cancel β has a median of 65 minutes. 81,689 pairs (1.3%) have a negative scheduled turn: the aircraft operated a flight that was supposed to leave before it landed. That is an aircraft swap, visible only because the tail number records the aircraft that actually flew. Sorting by scheduled time instead would put these legs in the wrong order. The first percentile of actual time on the ground is 27 minutes, roughly the shortest an airline can turn an aircraft round.
3. How much of an arrival delay is passed on
late = legs[legs.ArrDelay >= 15].copy()
late["absorbed"] = (late.ArrDelay - late.next_dep_delay.clip(lower=0)).clip(lower=0)
bins = pd.cut(late.ArrDelay, [15, 30, 60, 120, 240, 2000])
print(late.groupby(bins, observed=True).agg(legs=("ArrDelay", "size"), next_dep_delay=("next_dep_delay", "median"),
passed_on=("next_dep_delay", lambda d: (d >= 15).mean())).round(2).to_string())
legs next_dep_delay passed_on
ArrDelay
(15, 30] 463392 12.0 0.45
(30, 60] 420122 27.0 0.61
(60, 120] 304699 54.0 0.63
(120, 240] 155997 80.0 0.61
(240, 2000] 52547 44.0 0.58
Of flights arriving 15β30 minutes late, 45% pass a delay of 15 minutes or more on to the next departure; above an hour late, about 60β64% do. The median handed-on delay is a bit less than the arrival delay at every level β 54 minutes from an hour-or-two arrival delay β because the scheduled turn absorbs some. The longest delays are passed on less often, because by then the airline usually swaps in another aircraft.
4. Delay grows leg by leg
f["leg"] = f.groupby(["Tail_Number", "FlightDate"]).cumcount() + 1
print(f[f.leg <= 7].groupby("leg").DepDelay.agg(mean="mean", on_time=lambda d: (d < 15).mean(), flights="size").round(2).to_string())
mean on_time flights
leg
1 6.25 0.88 1730579
2 10.37 0.81 1592790
3 14.32 0.76 1350589
4 18.11 0.72 1058426
5 21.83 0.68 626182
6 23.10 0.67 367304
7 31.15 0.64 99918
The first flight of the day leaves on time 88% of the time with a mean delay of 6.25 minutes; by the seventh leg the on-time rate is 64% and the mean delay 31.15 minutes. Every turn adds a chance for a new delay and only partly absorbs the old ones, so delay accumulates through an aircraft's day.
5. Check against the file's own late-aircraft minutes
rep = legs[legs.next_late_aircraft > 0]
expected = (rep.ArrDelay - (rep.slack - rep.slack.clip(upper=rep.slack.quantile(0.1)))).clip(lower=0)
print(f"next legs with late-aircraft minutes: {len(rep):,}")
print(f"reported late-aircraft minutes vs this leg's arrival delay: correlation {rep.ArrDelay.corr(rep.next_late_aircraft):.2f}; "
f"reported minutes exceed the inbound delay on {(rep.next_late_aircraft > rep.ArrDelay + 1).mean():.1%}")
print(f"share of all reported delay minutes put down to a late inbound aircraft: "
f"{flights.LateAircraftDelay.sum() / flights[['CarrierDelay', 'WeatherDelay', 'NASDelay', 'SecurityDelay', 'LateAircraftDelay']].sum().sum():.1%}")
next legs with late-aircraft minutes: 738,911
reported late-aircraft minutes vs this leg's arrival delay: correlation 0.52; reported minutes exceed the inbound delay on 20.8%
share of all reported delay minutes put down to a late inbound aircraft: 39.2%
The file's own LateAircraftDelay agrees with the chains: on 738,911 legs that report it, it correlates at 0.52 with the inbound flight's arrival delay. On 20.8% it exceeds that delay β the late-aircraft minutes include knock-on effects such as crew from another late flight, which the tail number cannot see. Across the year, late aircraft account for 39.2% of all reported delay minutes, more than any other cause.
One aircraft, one day
The data used on this page
The Bureau of Transportation Statistics Reporting Carrier On-Time Performance files for 2025, which carry each flight's tail number, and airport coordinates from OurAirports in a copy saved on 15 October 2026.
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: one aircraft's worst day
day = f.groupby(["Tail_Number", "FlightDate"]).agg(legs=("leg", "max"), first_delay=("DepDelay", "first"),
last_delay=("DepDelay", "last"), worst=("DepDelay", "max"))
cand = day[(day.legs >= 6) & (day.first_delay <= 0) & day.last_delay.between(60, 240)].index
steady = lambda g: g.DepDelay.diff().dropna().between(1, 90).all() and g.CRSDepTime.is_monotonic_increasing
rising = f.set_index(["Tail_Number", "FlightDate"]).loc[cand].groupby(level=[0, 1]).apply(steady)
pick = day.loc[rising[rising].index].sort_values("last_delay").index[-1]
one = f.set_index(["Tail_Number", "FlightDate"]).loc[pick].reset_index()
print(pick[0], pick[1].date())
print(one[["leg", "Reporting_Airline", "Origin", "Dest", "CRSDepTime", "DepDelay", "ArrDelay", "LateAircraftDelay", "WeatherDelay", "NASDelay"]]
.to_string(index=False))
N8612K 2025-03-16
leg Reporting_Airline Origin Dest CRSDepTime DepDelay ArrDelay LateAircraftDelay WeatherDelay NASDelay
1 WN MDW FLL 0600 -4.0 10.0 NaN NaN NaN
2 WN FLL MCO 1045 31.0 16.0 7.0 0.0 0.0
3 WN MCO DAL 1250 33.0 115.0 18.0 0.0 82.0
4 WN DAL CLT 1540 118.0 115.0 115.0 0.0 0.0
5 WN CLT BWI 1945 208.0 190.0 109.0 81.0 0.0
6 WN BWI SAT 2125 234.0 192.0 192.0 0.0 0.0
Southwest's N8612K on 16 March 2025 shows the mechanism. It left Chicago Midway four minutes early, picked up 31 minutes at Fort Lauderdale, then 82 minutes of air-traffic-system delay on the leg into Dallas Love Field. From there every later departure inherited the delay β 118, 208, 234 minutes β with 81 minutes of weather added on the CharlotteβBaltimore leg. By its last flight, to San Antonio, all 192 minutes of arrival delay were put down to a late aircraft: the original cause was hours and three airports behind.
Example 2: first flights of the day
first = f[f.leg == 1]
print(f"first departure of the day on time: {(first.DepDelay < 15).mean():.1%}; all later departures: {(f[f.leg > 1].DepDelay < 15).mean():.1%}")
print(f"first departures before 07:00: {(first.CRSDepTime < '0700').mean():.1%}")
first departure of the day on time: 88.2%; all later departures: 75.0%
first departures before 07:00: 39.4%
First departures leave on time 88.2% of the time against 75.0% for all later ones, and 39.4% of first departures are scheduled before 7 a.m. That is why travel advice to take the first flight of the day works: the aircraft has spent the night at the gate and has no inbound delay to inherit.
Flights as a trajectory
The file is organised by flight, but delay is organised by aircraft. Re-sorting by tail number and actual time converts it into trajectories β the same operation as turning GPS fixes into tracks β and the questions change from "how late was this flight" to "where did its lateness come from". The scheduled turn is the buffer the airline built in; anything beyond it propagates.
Two things make the chains imperfect. The tail number links aircraft, not crews, and crews also run late and swap; part of what the file calls late aircraft is a late crew. And the chains break where an aircraft flies for an airline that does not report, or where a tail number is missing. Both make the measured propagation a lower bound: delay travels further through the system than the tail numbers alone can show.
Getting the chains right
- Sort by actual departure, not scheduled. Aircraft swaps put 1.3% of pairs out of scheduled order.
- Check the next leg starts where this one ended. 98.4% do; the rest are gaps.
- Compute turns at one airport. Both times are then in the same time zone.
- Roll late arrivals into the next day before subtracting.
- Tail numbers can be missing. Those flights cannot be chained.
Internal links
- How to load and clean a year of US flight data β the file and its tail numbers
- Flight delay causes explained β what "late aircraft" means as a cause
- Flights that arrive before they depart β why turn times are computed at one airport
- How to map flight delays by airport β where the morning's delays start
FAQ
How much of a flight delay is passed on to the aircraft's next flight?
In 2025, of flights arriving 30β60 minutes late, 61% caused a departure delay of 15 minutes or more on the next leg; the median delay passed on was 27 minutes.
Are early-morning flights really more punctual?
Yes. The first departure of an aircraft's day left on time 88.2% of the time, against 75.0% for its later departures.