Punctuality

STIB: brussels metro, tram and bus operator

Format
Parquet
Updates
Daily (one file per Brussels service day)
Latest snapshot
Snapshots stored
about 909
curl -H "Authorization: Bearer $MOBILITYTWIN_TOKEN" -o punctuality.parquet "https://api.mobilitytwin.brussels/stib/punctuality"

Daily punctuality table for STIB/MIVB, reconstructed from the vehicle-distance feed.

The source is a set of positions every 20 seconds, each carrying only a line, a direction, the stop point last passed and the metres since. This table is therefore derived, not reported. Vehicles are recovered by aligning consecutive polls under the constraint that vehicles on a line do not overtake each other; stop calls are read where a vehicle's distance along the line crosses a stop; and GTFS trip ids are assigned by a second order-preserving alignment of the day's journeys against the day's timetable.

Columns match the other operators' punctuality tables, with seven appended: route_gtfs_id, journey_id, match_deviation_minutes, observed, inferred, missing_reason and dwell_share.

Two limits are structural and must be read before use. cancelled is always false — the feed carries no cancellation signal at all, and an unmatched trip is equally evidence that the tracker lost the vehicle, so no cancellation is claimed rather than guessed. Do not compute a STIB cancellation rate from this table. observed marks a measured time and inferred a derived one; they are mutually exclusive, and a consumer wanting measurement only should filter on observed. Rows with neither carry a missing_reason. trip_schedule_relationship is 0 for a timetabled trip and 1 (ADDED) for a run matched to none, whose trip_id starts with added-.

Typical quality, measured across 146 days spanning April 2024 to August 2026: about 92% of timetabled trips matched on days without a feed outage, 88% of scheduled stop calls carrying a measured time and 94% once inference is included, with a median match deviation of 1.3 minutes. Quality does not degrade with age. The denominator is the timetable, and since no cancellation signal exists, a trip that was short-turned or never ran keeps all of its scheduled calls.

A single Parquet file. Read it with Polars, DuckDB or PyArrow.

Which days have data

Data from 5 Apr 2024 to 1 Nov 2026, on 891 days. 30 of the last 30 days have data. A typical day holds 1 snapshot. Days are in UTC.

2026
JanFebMarAprMayJunJulAugSepOctNovDec
2025
JanFebMarAprMayJunJulAugSepOctNovDec
2024
JanFebMarAprMayJunJulAugSepOctNovDec
  • Full day
  • Partial, 50–90% of a typical day
  • Low, under 50%
  • No snapshot
  • Not expected

Click a day above, or pick a date, to get its requests.

Parameters

NameMeaning
timestampReturn the last snapshot at or before this moment, in Unix seconds or ISO 8601 (e.g. 2026-05-06T12:00:00Z). Without it, the latest snapshot is returned.

Python example

# pip install polars requests
import io
import requests
import polars as pl

from datetime import datetime, timedelta, timezone

# Yesterday's Brussels-day file (the harvester anchors windows to local midnight,
# so any timestamp inside that day returns the same parquet).
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
    hour=12, minute=0, second=0, microsecond=0
)
params = {"timestamp": yesterday_noon.isoformat()}  # ISO accepted; epoch also works

url = "https://api.mobilitytwin.brussels/stib/punctuality"
data = requests.get(url, headers={
    "Authorization": "Bearer [MY_API_KEY]"
}, params=params).content

df = pl.read_parquet(io.BytesIO(data))
print(f"rows: {df.height:,}, trips: {df['trip_id'].n_unique():,}")

# STIB publishes no realtime feed: every time is reconstructed from vehicle
# positions. Keep the measured ones (observed) of timetabled trips.
measured = df.filter(pl.col("observed") & (pl.col("trip_schedule_relationship") == 0))

delays = (
    measured.group_by("route_id")          # route_id is the line number
      .agg([
          pl.col("arrival_delay").median().alias("median_delay_s"),
          (pl.col("arrival_delay") > 300).mean().round(3).alias("share_over_5_min"),
          pl.len().alias("stop_calls"),
      ])
      .sort("median_delay_s", descending=True)
)
print("\nLines by median arrival delay:")
print(delays.head(10))