# US Airline On-Time & Flight Delay Statistics by Route (BTS) (`malonestar/bts-airline-ontime-delay-climatology`) Actor

Historical US flight delay, cancellation and on-time statistics from official BTS data. Rollups by route, airline or airport and month with full delay-cause breakdowns — carrier, weather, NAS, security, late aircraft — honest denominators, cancellation causes and data-vintage on every row.

- **URL**: https://apify.com/malonestar/bts-airline-ontime-delay-climatology.md
- **Developed by:** [Kyle Maloney](https://apify.com/malonestar) (community)
- **Categories:** Travel, Developer tools, Agents
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.40 / 1,000 route climatology results

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Airline On-Time & Delay-Cause Climatology (BTS)

Historical **US airline on-time performance, flight delay statistics and delay-cause
decomposition** by route, airline or airport — computed straight from the US
Department of Transportation's official **BTS TranStats "Reporting Carrier On-Time
Performance (1987-present)"** monthly bulk files. Keyless, no scraping, no API key.

Real-time trackers tell you a flight is late *today*. This actor answers the question
the money actually rides on: **how often is this route/airline/airport late, how bad
is it when it is late, and WHY** — air-carrier problems vs extreme weather vs
National Aviation System congestion vs security vs late-inbound aircraft — month by
month, from the government dataset the airlines themselves are legally required to
file. Nobody else on the Store sells the historical, cause-decomposed rollup.

### Who this is for

- **Air-passenger-rights claim firms (EU261 / ANAC / DOT refunds):** establish
  whether a delay pattern on a route is habitual and carrier-caused (compensable
  operational issues) or weather/NAS-driven — with the official CancellationCode
  (A=carrier, B=weather, C=NAS, D=security) counted per month.
- **Travel insurers and underwriting analysts:** price delay/cancellation cover
  per route and season from actual disruption base rates, not anecdotes.
- **Corporate travel managers and TMCs:** pick the reliably-on-time carrier and
  connection airport per city pair before signing preferred-carrier deals.
- **Journalists and analysts:** "which airline blames weather the most", "worst
  day at EWR in June", "cancellation causes at DEN" — one run, official numbers.
- **Airport / airline ops and consultants:** benchmark on-time performance and
  the cause mix against competitors, monthly.
- **AI agents (MCP):** a clean, typed, chainable rollup tool over a 39-year
  federal dataset that agents cannot practically download and parse themselves
  (each month is a ~30 MB zip holding a ~277 MB CSV).

### What one run costs — read this first

Pricing is **pay-per-result**: one dataset row = one (group x month) rollup.
At **$8 per 1,000 rows** a row costs **$0.008**.

| Run | Typical rows | Typical cost |
|---|---|---|
| One airline, one month, by route (`airlines:["WN"]`) | ~600 routes | ~$4.80 (cap with `maxResults`) |
| All airlines, 2 months, by airline | ~26 | ~$0.21 |
| One airport's routes, 2 months (`origins:["DEN"]`) | ~200-400 | ~$1.60-$3.20 |
| One route, 12 months (`origins:["DEN"], dests:["LGA"]`) | 12 | ~$0.10 |
| Default prefill (WN routes, 2 months, maxResults 500) | 500 | $4.00 |

`maxResults` (default **500**) is a **hard billing cap** enforced in one audited
code path for every grouping mode. When it bites, rows are dropped
smallest-groups-first and **every emitted row carries `results_truncated: true`** —
a capped answer is never passed off as a complete one. A run that cannot answer
(bad input, BTS outage, missing month without `allowPartial`) **fails loudly and
bills nothing**.

A single run is capped at **12 months** (each month is a ~30 MB download from
BTS). Split longer histories across runs.

### Example input

```json
{
  "sinceMonth": "2026-05",
  "untilMonth": "2026-06",
  "airlines": ["WN"],
  "groupBy": "route",
  "minFlights": 30,
  "maxResults": 500
}
```

Or a single route across a year, all carriers:

```json
{
  "sinceMonth": "2025-07",
  "untilMonth": "2026-06",
  "origins": ["DEN"],
  "dests": ["LGA"],
  "groupBy": "airline_route",
  "minFlights": 10,
  "maxResults": 100
}
```

Leave `sinceMonth`/`untilMonth` blank and the run defaults to the two most
recent published months — **discovered live**: BTS publishes with a ~2-3 month
lag, and the actor probes backward from the current month until a file answers.
The month it lands on is reported on every row as `latest_month_available`.

### Grouping modes

| `groupBy` | One row per | Use it for |
|---|---|---|
| `route` (default) | origin-dest x month, across airlines | "How is DEN-LGA overall?" |
| `airline` | reporting carrier x month | League tables, carrier benchmarking |
| `origin_airport` | departure airport x month | Airport ops, connection choice |
| `dest_airport` | arrival airport x month | Inbound reliability |
| `airline_route` | carrier x origin-dest x month | Claims and carrier-level route analysis |

### Output fields (the important ones)

Every row is one (group x month). All fields are nullable and documented in the
dataset schema; the key ones:

- **Identity:** `group_by`, `month`, `airline`, `origin`, `dest`, `route`,
  `airlines_included`
- **Volumes:** `total_flights` (scheduled, incl. cancelled/diverted),
  `completed_flights`, `cancelled_flights`, `diverted_flights`,
  `arr_assessed_flights` (the arrival-performance denominator)
- **Rates:** `on_time_rate_pct`, `arr_delay_rate_pct` (>=15 min, over assessed
  flights), `cancellation_rate_pct`, `disrupted_rate_pct` (late + cancelled +
  diverted over ALL scheduled flights — the passenger's-eye number)
- **Severity:** `avg_arr_delay_all_min` (early arrivals count negative),
  `avg_arr_delay_delayed_only_min`, `avg_dep_delay_all_min`
- **Cancellation causes:** `cancelled_carrier_count` (code A),
  `cancelled_weather_count` (B), `cancelled_nas_count` (C),
  `cancelled_security_count` (D), `cancelled_unknown_code_count`
- **Delay-cause decomposition:** `carrier_delay_minutes`,
  `weather_delay_minutes`, `nas_delay_minutes`, `security_delay_minutes`,
  `late_aircraft_delay_minutes`, `total_cause_delay_minutes`, and the five
  `*_share_pct` columns — **with the denominator published on the row**
  (`delayed_flights_with_cause_data` / `delayed_flights_without_cause_data`,
  see the honesty section below)
- **Worst day:** `worst_day` (YYYY-MM-DD) and `worst_day_disrupted_flights`
- **Provenance & completeness:** `months_requested`, `months_fetched`,
  `months_missing`, `data_complete`, `month_rows_scanned`,
  `groups_suppressed_below_min_flights`, `results_truncated`,
  `latest_month_available`, `data_source` (the exact BTS zip URL),
  `data_vintage` (the file's HTTP Last-Modified — BTS revises files in place),
  `retrieved_at`

### How the numbers stay honest

This dataset has traps, and the actor is built around them rather than on top
of them:

- **Blank is not zero.** BTS populates the five delay-cause columns ONLY on
  flights that arrived 15+ minutes late. Measured on the live May-2026 file:
  exactly the 129,511 delayed flights of 611,735 carry all five; every other
  row carries five blanks. A naive `Number(x) || 0` would fabricate ~482,000
  zero-cause flights per month. Here a blank is `null`, cause columns are
  `null` (never 0) for a group with no cause data, and every row publishes its
  own cause denominator.
- **Cancelled and diverted flights carry NO arrival-delay figures** (measured:
  all 5,655 cancelled and all 1,734 diverted rows have blank ArrDel15). They
  are counted in their own columns and excluded from arrival-performance
  denominators — `disrupted_rate_pct` is the all-in view.
- **Delay-cause data only exists from June 2003.** A pre-2003 month still rolls
  up delays and cancellations correctly; its cause columns read `null` and
  `delayed_flights_without_cause_data` says why.
- **A live drift gate runs before anything billable**, per month: the real
  header must contain every column this actor reads (a renamed column fails
  the run, never emits nulls); the file must hold >=50,000 rows, >=5 carriers
  and >=100 origin airports; at least one pinned mega-hub (ATL/ORD/DFW/DEN)
  must appear as an origin; the file's own Year/Month columns must match the
  month requested (a stale or mislabeled file fails instead of billing); and a
  negative-control carrier code must match zero rows.
- **A missing month never silently vanishes.** If BTS hasn't published a
  requested month (or the download fails), the run FAILS by default with the
  per-month status. Opt in with `allowPartial: true` and every row then
  reports `months_missing` and `data_complete: false`.
- **Cheap trap coverage:** the BTS 404 page is HTML at a real 404; a
  maintenance page at HTTP 200 is detected by byte shape (a zip must start
  with `PK`) and treated as an outage, never parsed. The zip's month is
  probed with a ranged GET, not HEAD. The zip's CSV entry is selected by
  extension because the archive also contains a `readme.html`.

### Use as an MCP tool

This actor works as a chainable MCP tool via `https://mcp.apify.com` — the
input schema is fully described, month strings are validated with actionable
error messages (an agent sending `"Jan 2026"` or `"2026-13"` gets a named
reason, not a silent empty dataset), and a filter that matches nothing fails
with the list of carrier codes actually present in the window. Billing is
unchanged when called as a tool; a run that cannot answer fails without
billing.

Example agent prompt: *"Using the BTS on-time climatology tool, compare
Southwest and United on Denver routes for the last two published months,
grouped by airline\_route, and tell me which routes have the worst
late-aircraft share."*

### FAQ

**How fresh is the data?** BTS publishes with a ~2-3 month lag (measured
2026-08-31: latest file is June 2026, published mid-August). The run discovers
the latest month live and reports it on every row. BTS also revises files in
place — `data_vintage` carries each file's Last-Modified.

**Which airlines are covered?** The carriers required to report on-time data —
13 as of 2026-05: AA, AS, B6, DL, F9, G4, MQ, NK, OH, OO, UA, WN, YX.
Regional partners report under their own codes (OO = SkyWest, MQ = Envoy,
OH = PSA, YX = Republic).

**Is this per-flight data?** No — this is the *climatology* rollup (one row
per group per month). If you need raw per-flight records, download the BTS
PREZIP file directly; it is free.

**Can I get 5 years of history?** Yes, 12 months per run (each month is a
\~30 MB federal download). Loop runs across windows; months back to 1987-10
work, with cause columns starting June 2003.

**Why do on\_time\_rate + delay\_rate not include cancellations?** They are
arrival-performance rates over flights that actually arrived, matching how
DOT reports them. `disrupted_rate_pct` gives the all-in probability a
scheduled flight was late, cancelled or diverted.

**What do the delay-cause shares mean?** Shares of cause-attributed delay
minutes on flights that arrived 15+ min late, as filed by the carriers under
DOT rules: carrier (maintenance, crew, ops), extreme weather, NAS (airspace
congestion, non-extreme weather, airport ops), security, late inbound
aircraft. The denominator is on the row.

### Data source

US DOT Bureau of Transportation Statistics, TranStats "Reporting Carrier
On-Time Performance (1987-present)", monthly PREZIP bulk files at
`transtats.bts.gov/PREZIP/` — public domain, keyless. Each output row names
the exact source zip (`data_source`) and its Last-Modified (`data_vintage`).

# Actor input Schema

## `sinceMonth` (type: `string`):

First month of BTS on-time data to include, as YYYY-MM (e.g. "2026-05"). Leave blank to default to the month before the latest published month. The BTS series begins 1987-10 and is published with a ~2-3 month lag; the run discovers the latest available month live and reports it on every row. An unusable value (slash dates, bare years, month 13) FAILS the run loudly — it is never silently ignored.

## `untilMonth` (type: `string`):

Last month of BTS on-time data to include, as YYYY-MM. Leave blank to default to the latest published month. A window spanning more than 12 months is rejected (each month is a ~30 MB BTS download). A month BTS has not yet published is reported as missing — with allowPartial false (the default) the run fails loudly rather than quietly narrowing your window.

## `airlines` (type: `array`):

Optional. BTS reporting-carrier codes to include, e.g. \["WN","AA","DL","UA","B6"]. Leave empty for all reporting carriers (13 in 2026-05: AA, AS, B6, DL, F9, G4, MQ, NK, OH, OO, UA, WN, YX). A code matching no flights fails the run with the list of carriers actually present — never a silent empty dataset.

## `origins` (type: `array`):

Optional. IATA origin airport codes, e.g. \["DEN","MDW"]. Leave empty for all origins.

## `dests` (type: `array`):

Optional. IATA destination airport codes, e.g. \["LGA","DCA"]. Leave empty for all destinations.

## `groupBy` (type: `string`):

How to roll flights up. "route" = one row per (origin-dest x month) across airlines; "airline" = one row per (carrier x month); "origin\_airport" / "dest\_airport" = one row per (airport x month); "airline\_route" = one row per (carrier x origin-dest x month). Omitted = "route".

## `minFlights` (type: `integer`):

Suppress groups with fewer than this many scheduled flights in a month (noise control — a 2-flight 'route' produces meaningless rates). Suppressed-group count is disclosed on every emitted row as groups\_suppressed\_below\_min\_flights. Set 0 to keep everything. Omitted = 30.

## `maxResults` (type: `integer`):

Hard cap on emitted rows — each row is one billed result. Enforced by a single audited code path for every groupBy mode; when the cap bites, rows are dropped smallest-groups-first and every emitted row carries results\_truncated=true. Omitted = 500.

## `allowPartial` (type: `boolean`):

If a requested month cannot be retrieved (not yet published by BTS, or the download failed), the run FAILS loudly by default so a partial window is never mistaken for a complete one. Set true to proceed with the months that did arrive — every row then reports months\_missing and data\_complete=false.

## Actor input object example

```json
{
  "sinceMonth": "2026-05",
  "untilMonth": "2026-06",
  "airlines": [
    "WN"
  ],
  "groupBy": "route",
  "minFlights": 30,
  "maxResults": 500,
  "allowPartial": false
}
```

# Actor output Schema

## `results` (type: `string`):

The default dataset.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "sinceMonth": "2026-05",
    "untilMonth": "2026-06",
    "airlines": [
        "WN"
    ],
    "groupBy": "route",
    "minFlights": 30,
    "maxResults": 500
};

// Run the Actor and wait for it to finish
const run = await client.actor("malonestar/bts-airline-ontime-delay-climatology").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "sinceMonth": "2026-05",
    "untilMonth": "2026-06",
    "airlines": ["WN"],
    "groupBy": "route",
    "minFlights": 30,
    "maxResults": 500,
}

# Run the Actor and wait for it to finish
run = client.actor("malonestar/bts-airline-ontime-delay-climatology").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "sinceMonth": "2026-05",
  "untilMonth": "2026-06",
  "airlines": [
    "WN"
  ],
  "groupBy": "route",
  "minFlights": 30,
  "maxResults": 500
}' |
apify call malonestar/bts-airline-ontime-delay-climatology --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,malonestar/bts-airline-ontime-delay-climatology"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/i3r0fViGePWclAv9O/builds/QtsB6hSY1bOk6ehWd/openapi.json
