How to Measure Airport Congestion from Taxi-Out Times
On this page
Problem statement
A departure delay measures what happened at the gate. Once the aircraft pushes back, it can still sit in a queue for the runway for half an hour, and that time does not show up as a departure delay at all β the flight left "on time". The BTS on-time file records it separately as TaxiOut, minutes from gate departure to wheels off, for every flight. It is the most direct measure of airfield congestion in the public data.
This page ranks 2025 taxi-out times by airport, separates the queue from each airport's ordinary taxi distance, and looks at when the queues form: by hour, by season, and in relation to gate delays.
Quick answer
Per airport, take the median taxi-out time, its 90th percentile, and its 10th percentile as the "unimpeded" time β how long the taxi takes when nobody is in the way. The difference is the queue:
dep = flights[(flights.Cancelled == 0) & flights.TaxiOut.notna()]
by = dep.groupby("Origin").TaxiOut.agg(departures="size", median="median", p90=lambda t: t.quantile(0.9),
unimpeded=lambda t: t.quantile(0.1))
big = by[by.departures >= 20_000]
big = big.assign(excess=big["median"] - big.unimpeded)
print(big.sort_values("median", ascending=False).head(6).round(1).to_string())
print(big.sort_values("median").head(3).round(1).to_string())
departures median p90 unimpeded excess
Origin
JFK 102644 24.0 47.0 15.0 9.0
ORD 321372 23.0 41.0 14.0 9.0
SEA 163487 21.0 33.0 14.0 7.0
EWR 117258 21.0 42.0 14.0 7.0
LGA 131602 21.0 42.0 14.0 7.0
SAN 95816 20.0 33.0 13.0 7.0
departures median p90 unimpeded excess
Origin
OGG 25284 10.0 15.0 7.0 3.0
DAL 70315 11.0 19.0 8.0 3.0
OAK 34368 11.0 17.0 8.0 3.0
JFK has the longest median taxi-out of the large airports, 24 minutes, and O'Hare 23; one JFK departure in ten taxis for 47 minutes or more. At Kahului, Dallas Love Field and Oakland the median is 10β11 minutes. Subtracting the unimpeded time separates layout from congestion: at JFK and O'Hare, 9 minutes of the median is waiting, at the shortest airports 3.
From gate to wheels off
1. What taxi-out measures
print(dep.TaxiOut.describe(percentiles=[0.5, 0.9, 0.99, 0.999]).round(0).to_string())
print(f"departures taxiing over two hours: {(dep.TaxiOut > 120).sum():,}")
count 6898743.0
mean 19.0
std 11.0
min 1.0
50% 16.0
90% 30.0
99% 61.0
99.9% 110.0
max 1274.0
departures taxiing over two hours: 4,363
The median departure taxis for 16 minutes and one in a hundred for an hour or more. 4,363 taxied for over two hours β long ground holds, de-icing queues and tarmac delays, which have their own federal reporting rules.
2. Excess over the airport's own unimpeded time
dep = dep.assign(excess=dep.TaxiOut - dep.Origin.map(by.unimpeded))
total = dep[dep.Origin.isin(big.index)].groupby("Origin").excess.sum() / 60
print("hours of taxi time above each airport's 10th percentile, 2025:")
print(total.sort_values(ascending=False).head(6).round(0).astype(int).to_string())
hours of taxi time above each airport's 10th percentile, 2025:
Origin
ORD 64289
DEN 41557
DFW 39003
ATL 31041
LGA 26017
CLT 25949
Summed over the year, the time above each airport's own unimpeded taxi is large: O'Hare's departures spent 64,289 hours in queues, Denver's 41,557, DFW's 39,003. Ranking by total rather than per flight rewards size as much as congestion, so both views are worth keeping.
3. Queues by hour
hour = dep.CRSDepTime.str[:2].astype(int).clip(upper=23)
q = {}
for code in ["JFK", "ORD", "ATL", "DEN"]:
d = dep[dep.Origin == code]
h = hour[d.index]
q[code] = pd.DataFrame({"departures per day": d.groupby(h).size() / 365, "median taxi": d.groupby(h).TaxiOut.median()})
print(code, f"correlation of hourly departures and median taxi-out: {q[code].corr().iloc[0, 1]:.2f}")
print(q["JFK"].loc[[6, 8, 12, 15, 17, 19, 22]].round(1).to_string())
JFK correlation of hourly departures and median taxi-out: 0.44
ORD correlation of hourly departures and median taxi-out: 0.73
ATL correlation of hourly departures and median taxi-out: 0.19
DEN correlation of hourly departures and median taxi-out: 0.84
departures per day median taxi
CRSDepTime
6 18.8 19.0
8 24.9 26.0
12 12.3 19.0
15 20.0 25.0
17 19.2 29.0
19 17.0 33.0
22 6.0 25.0
Where the queue tracks the schedule, the correlation between departures per hour and median taxi-out is high: 0.84 at Denver, 0.73 at O'Hare. At JFK it is weaker (0.44) because the evening is worse than the schedule alone predicts: 19 departures an hour at 7 p.m. taxi a median 33 minutes, while 25 an hour at 8 a.m. taxi 26. The likeliest reason is JFK's evening international departures, which share the runways but are not in this domestic file. Atlanta's correlation is only 0.19: its queues barely follow its hourly schedule.
4. Summer queues, winter de-icing
m = dep.FlightDate.dt.month
season = dep[m.isin([1, 7])].groupby(["Origin", m[m.isin([1, 7])]]).TaxiOut.median().unstack()
season.columns = ["January", "July"]
season = season.loc[big.index].assign(diff=lambda d: d.January - d.July)
print(season.sort_values("diff").iloc[[0, 1, 2, -3, -2, -1]].to_string())
January July diff
Origin
JFK 21.0 27.0 -6.0
ORD 20.0 24.0 -4.0
SFO 17.0 20.0 -3.0
PIT 18.0 14.0 4.0
BUF 18.0 12.0 6.0
CLE 20.0 13.0 7.0
The expectation that winter is worse holds only at some airports. At Pittsburgh, Buffalo and Cleveland the January median is 4β7 minutes longer than July's: aircraft de-ice on the way to the runway. At JFK and O'Hare it is the other way round, by 6 and 4 minutes: summer schedules are denser and thunderstorms close departure routes, which keeps aircraft queued on the taxiway. The busiest airports' worst season for queues is summer.
Taxi time and punctuality
The data used on this page
The Bureau of Transportation Statistics Reporting Carrier On-Time Performance files for 2025, which carry taxi-out (gate departure to wheels off) and taxi-in times for every flight, 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: taxi-out against the departure delay
late_gate = dep.DepDelay >= 15
print(f"median taxi-out when the flight left the gate on time: {dep.TaxiOut[~late_gate].median():.0f} min; "
f"when it left 15+ min late: {dep.TaxiOut[late_gate].median():.0f} min")
on_time_gate = dep[(dep.DepDelay <= 0) & dep.ArrDelay.notna()]
print(f"flights that pushed back on time but arrived 15+ min late: {(on_time_gate.ArrDelay >= 15).mean():.1%}; "
f"their median taxi-out {on_time_gate.TaxiOut[on_time_gate.ArrDelay >= 15].median():.0f} min")
median taxi-out when the flight left the gate on time: 16 min; when it left 15+ min late: 16 min
flights that pushed back on time but arrived 15+ min late: 4.1%; their median taxi-out 35 min
Flights that left the gate late do not taxi longer β the median is 16 minutes either way. But taxiing is where an on-time departure can still become a late arrival: 4.1% of flights that pushed back on time arrived 15 or more minutes late, and their median taxi-out was 35 minutes, more than twice the usual.
Example 2: taxi-in at the arrival end
tin = flights[(flights.Cancelled == 0) & flights.TaxiIn.notna()].groupby("Dest").TaxiIn.agg(arrivals="size", median="median")
both = big.join(tin["median"].rename("taxi-in median"))
print(f"correlation of median taxi-out and taxi-in at large airports: {both['median'].corr(both['taxi-in median']):.2f}")
print(both.sort_values("taxi-in median", ascending=False).head(4)[["median", "taxi-in median"]].to_string())
correlation of median taxi-out and taxi-in at large airports: 0.64
median taxi-in median
Origin
ORD 23.0 16.0
CLT 19.0 12.0
DFW 17.0 12.0
SEA 21.0 10.0
Taxi-in times are shorter but correlate with taxi-out across large airports (0.64): the same sprawling airfields are slow in both directions. O'Hare is slowest at both ends, with a median 16 minutes from touchdown to gate.
The queue, measured by subtraction
A taxi-out time is the sum of two things: the distance from the gate to the runway, which is fixed by the airfield, and the time spent waiting, which is congestion. The file does not separate them, but the distribution does. The fastest taxis at an airport happen when nobody is in the way, so a low percentile approximates the unimpeded time, and everything above it is queue. The FAA's own unimpeded-taxi estimates are made in a more careful version of the same way.
Seen as a queue, taxi time responds to what queues respond to: the number of departures per hour, the number of usable runways, and anything that reduces the departure rate β thunderstorms on departure routes in summer, de-icing in winter. The airport-level medians on this page mix all of these; the hour-by-hour and month-by-month views separate them enough to show that the busiest airports are most congested in summer evenings, not in winter.
Reading taxi times
- Taxi-out starts at gate departure. A flight held at the gate shows the hold as departure delay, not taxi time.
- Use the airport's own baseline. Raw medians mix layout with congestion.
- Long holds are rare but real. 4,363 departures in 2025 taxied for more than two hours.
- The file covers domestic flights only. International departures share the runways and the queue.
- Totals reward size, rates reward congestion. Report both.
Internal links
- How to map flight delays by airport β delays at the gate
- How to measure where and when weather delays flights β the winter months in the delay file
- How to follow an aircraft through its day β what happens after landing
- How to load and clean a year of US flight data β the taxi columns
FAQ
Which US airport has the longest taxi-out times?
JFK, with a median of 24 minutes for domestic departures in 2025 and a 90th percentile of 47, followed by Chicago O'Hare at 23.
Is taxi time longer in winter?
At airports that de-ice routinely, such as Buffalo and Cleveland, yes. At the busiest airports, summer is worse: JFK's July median was 6 minutes longer than January's.