Read the archive from code

Every day of every source is a file with its own web address. Open it from DuckDB, Polars, pandas, R or curl with a free API token.

No code needed for a few days of one source: on Get the data, pick a source and download its days as a spreadsheet.

How it works

What there is

66 sources, one per operator feed, in 34 countries. Each keeps live delays (GTFS-RT TripUpdate), train positions (VehiclePosition), timetables (GTFS), or some of these. Live data comes as one file per source and day.

Where it is

Every file has an address on https://data.train-archive.eu. Tools that read Parquet over the web fetch only the columns and rows they need, so you rarely download a whole file.

How to get in

Send your API token with each request: ?token=… at the end of the address, or an X-Api-Key header. The list of sources needs no token.

The same paths can also be read without a token from the storage bucket itself, https://nbg1.your-objectstorage.com/gtfs-rt-storage/…. That works, but nothing counts your use and errors are bare: the token is the supported way.

Tokens come with an account. Accounts are free: you sign in with a code sent to your email, no password.

Create an account Sign in

Your first query

Paste this into DuckDB, with your token in place of YOUR_TOKEN. Nothing to install: run SQL in your browser instead.

INSTALL httpfs; LOAD httpfs;
CREATE SECRET train_archive (TYPE http, SCOPE 'https://data.train-archive.eu',
    EXTRA_HTTP_HEADERS MAP {'X-Api-Key': 'YOUR_TOKEN'});

SELECT * FROM 'https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet' LIMIT 10;
One address, piece by piece

https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet?token=YOUR_TOKEN

be-sncb
the source, from the list of sources
TripUpdate
delays; VehiclePosition for positions
2026-11-03
the day, in the source’s local time
parquet
or csv.gz: one row per train and stop
YOUR_TOKEN
from your tokens

Find what exists

Two lists, both refreshed every 15 minutes: one entry per source, and one row per file.

The sources: /api/sources.json

For each source: operator, country, what it keeps, its first and last day, how many finished days, the newest timetable, the licence where known, and address templates to fill with a date. No token needed.

curl -s https://www.train-archive.eu/api/sources.json \
  | jq -r '.sources[] | select(.country.code == "de") | .id'
The entry for be-sncb
{
  "id": "be-sncb",
  "operator": "SNCB / NMBS",
  "country": {
    "code": "be",
    "name": "Belgium"
  },
  "timezone": "Europe/Brussels",
  "community": false,
  "credits": [],
  "licence": null,
  "page": "https://www.train-archive.eu/providers/be-sncb",
  "downloadable": true,
  "services": {
    "TripUpdate": {
      "label": "Live delays",
      "first_day": "2026-09-28",
      "last_day": "2026-11-04",
      "ready_days": 37,
      "first_ready_day": "2026-09-28",
      "last_ready_day": "2026-11-03",
      "missing_hours": 7,
      "urls": {
        "daily_parquet": "https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date={date}.parquet",
        "daily_csv": "https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date={date}.csv.gz",
        "compacted": "https://data.train-archive.eu/provider=be-sncb/service=TripUpdate/date={date}/day.parquet",
        "hourly": "https://data.train-archive.eu/provider=be-sncb/service=TripUpdate/date={date}/{start}_to_{end}.parquet"
      }
    }
  },
  "timetable": {
    "latest_version": "2026-11-04_00-00-51Z",
    "versions": 13,
    "latest_json": "https://data.train-archive.eu/be-sncb/static/latest.json",
    "tables": {
      "agency": "https://data.train-archive.eu/be-sncb/static/2026-09-28_16-44-31Z/agency.parquet",
      "calendar": "https://data.train-archive.eu/be-sncb/static/2026-11-04_00-00-51Z/calendar.parquet",
      "calendar_dates": "https://data.train-archive.eu/be-sncb/static/2026-11-04_00-00-51Z/calendar_dates.parquet",
      "feed_info": "https://data.train-archive.eu/be-sncb/static/2026-11-04_00-00-51Z/feed_info.parquet",
      "routes": "https://data.train-archive.eu/be-sncb/static/2026-11-04_00-00-51Z/routes.parquet",
      "stop_times": "https://data.train-archive.eu/be-sncb/static/2026-11-04_00-00-51Z/stop_times.parquet",
      "stops": "https://data.train-archive.eu/be-sncb/static/2026-11-02_00-00-51Z/stops.parquet",
      "transfers": "https://data.train-archive.eu/be-sncb/static/2026-11-02_00-00-51Z/transfers.parquet",
      "translations": "https://data.train-archive.eu/be-sncb/static/2026-11-03_00-00-51Z/translations.parquet",
      "trips": "https://data.train-archive.eu/be-sncb/static/2026-11-04_00-00-51Z/trips.parquet"
    },
    "table_url": "https://data.train-archive.eu/be-sncb/static/{version}/{table}.parquet"
  },
  "estimated": {
    "ready_days": 37,
    "first_ready_day": "2026-09-28",
    "last_ready_day": "2026-11-03",
    "estimated_parquet": "https://data.train-archive.eu/estimated/provider=be-sncb/date={date}.parquet"
  }
}

Every file: index.parquet

https://data.train-archive.eu/index.parquet has one row per file, with its address in url. Filter it like any table, then read the files you picked. It needs your token, like the files.

provider
The source.
kind
  • daily (delays): a finished service day, one row per train and planned stop with its final delay. Start here.
  • hourly: today so far, one file per 15 minutes (per hour before 2 Oct 2026), every version of every forecast.
  • compacted: a finished day’s 15-minute files merged into one (for positions, the day’s file).
  • static: one table of one timetable version.
  • estimated: a finished day of estimated positions, for sources that send delays but no positions: where each train was every minute, worked out from its delays along the timetable (not measured; about 650 m to 1 km off at the median).
service
TripUpdate, VehiclePosition or static.
date
The day of the data, in the source’s local time (text, YYYY-MM-DD).
start_hour, end_hour
Hourly files only: local hours, end excluded.
version, table
Timetables only: stops, trips, stop_times…
size_bytes, url
The file’s size and address.

daily is the final state of each train. For every forecast as it was sent, read hourly (today) and compacted (finished days).

Examples

Each one runs as shown once YOUR_TOKEN is replaced. Pick a source and a day and the examples use them.

Show code for

One source, one day

A finished day of be-sncb is one file: the final delay of every train at every stop. This counts its rows and trains.

DuckDB

INSTALL httpfs; LOAD httpfs;
CREATE SECRET train_archive (TYPE http, SCOPE 'https://data.train-archive.eu',
    EXTRA_HTTP_HEADERS MAP {'X-Api-Key': 'YOUR_TOKEN'});

SELECT count(*) AS rows, count(DISTINCT trip_id) AS trains
FROM 'https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet';

Polars

import polars as pl

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

df = pl.read_parquet(f"{BASE}/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet?token={TOKEN}")
print(df.height, "rows,", df["trip_id"].n_unique(), "trains")

pandas

import pandas as pd

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

df = pd.read_parquet(f"{BASE}/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet?token={TOKEN}")
print(len(df), "rows,", df["trip_id"].nunique(), "trains")

R

library(arrow)

base <- "https://data.train-archive.eu"
token <- "YOUR_TOKEN"  # your token from /account/tokens

df <- read_parquet(paste0(base, "/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet?token=", token))
cat(nrow(df), "rows,", length(unique(df$trip_id)), "trains\n")

curl

# Parquet (every field the feed sent) and CSV (one row per train and stop)
curl -fLo be-sncb-2026-11-03.parquet "https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet?token=YOUR_TOKEN"
curl -fLo be-sncb-2026-11-03.csv.gz "https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.csv.gz?token=YOUR_TOKEN"

A range of days

The index lists the days that exist, so a missing day never breaks the query.

DuckDB

INSTALL httpfs; LOAD httpfs;
CREATE SECRET train_archive (TYPE http, SCOPE 'https://data.train-archive.eu',
    EXTRA_HTTP_HEADERS MAP {'X-Api-Key': 'YOUR_TOKEN'});

SET VARIABLE files = (
    SELECT list(url) FROM 'https://data.train-archive.eu/index.parquet'
    WHERE provider = 'be-sncb' AND service = 'TripUpdate' AND kind = 'daily'
      AND date BETWEEN '2026-10-28' AND '2026-11-03'
);
SELECT date, count(*) AS rows, count(DISTINCT trip_id) AS trains
FROM read_parquet(getvariable('files'), union_by_name = true)
GROUP BY date ORDER BY date;

Polars

import polars as pl

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

index = pl.read_parquet(f"{BASE}/index.parquet?token={TOKEN}")
urls = index.filter(
    (pl.col("provider") == "be-sncb") & (pl.col("service") == "TripUpdate")
    & (pl.col("kind") == "daily") & pl.col("date").is_between(pl.lit("2026-10-28"), pl.lit("2026-11-03"))
)["url"]
df = pl.concat([pl.read_parquet(f"{u}?token={TOKEN}") for u in urls], how="diagonal_relaxed")
print(df.group_by("date").len().sort("date"))

pandas

import pandas as pd

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

index = pd.read_parquet(f"{BASE}/index.parquet?token={TOKEN}")
urls = index.query("provider == 'be-sncb' and service == 'TripUpdate' and kind == 'daily'"
                   " and '2026-10-28' <= date <= '2026-11-03'")["url"]
df = pd.concat([pd.read_parquet(f"{u}?token={TOKEN}") for u in urls])
print(df.groupby("date").size())

R

library(arrow)

base <- "https://data.train-archive.eu"
token <- "YOUR_TOKEN"  # your token from /account/tokens

index <- read_parquet(paste0(base, "/index.parquet?token=", token))
urls <- with(index, url[provider == "be-sncb" & service == "TripUpdate" & kind == "daily" &
                        date >= "2026-10-28" & date <= "2026-11-03"])
tables <- lapply(paste0(urls, "?token=", token), read_parquet, as_data_frame = FALSE)
df <- as.data.frame(do.call(concat_tables, c(tables, unify_schemas = TRUE)))
print(table(df$date))

curl

for day in 2026-10-28 2026-10-29 2026-10-30 2026-10-31 2026-11-01 2026-11-02 2026-11-03; do
  curl -fsLo "be-sncb-$day.parquet" \
    "https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date=$day.parquet?token=YOUR_TOKEN" \
    || echo "no file for $day"
done

Every source, one day

All the delays of 2026-11-03 across Europe, one file per source.

DuckDB

INSTALL httpfs; LOAD httpfs;
CREATE SECRET train_archive (TYPE http, SCOPE 'https://data.train-archive.eu',
    EXTRA_HTTP_HEADERS MAP {'X-Api-Key': 'YOUR_TOKEN'});

SET VARIABLE files = (
    SELECT list(url) FROM 'https://data.train-archive.eu/index.parquet'
    WHERE kind = 'daily' AND service = 'TripUpdate' AND date = '2026-11-03'
);
SELECT provider, count(*) AS rows, count(DISTINCT trip_id) AS trains
FROM read_parquet(getvariable('files'), union_by_name = true)
GROUP BY provider ORDER BY trains DESC;

Polars

import polars as pl

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

index = pl.read_parquet(f"{BASE}/index.parquet?token={TOKEN}")
files = index.filter((pl.col("kind") == "daily") & (pl.col("service") == "TripUpdate")
                     & (pl.col("date") == "2026-11-03"))
# One column is enough to count trains; the source comes from the index
df = pl.concat([
    pl.read_parquet(f"{url}?token={TOKEN}", columns=["trip_id"]).with_columns(provider=pl.lit(provider))
    for provider, url in files.select("provider", "url").iter_rows()
])
print(df.group_by("provider").agg(pl.col("trip_id").n_unique().alias("trains")).sort("trains"))

pandas

import pandas as pd

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

index = pd.read_parquet(f"{BASE}/index.parquet?token={TOKEN}")
files = index.query("kind == 'daily' and service == 'TripUpdate' and date == '2026-11-03'")
# One column is enough to count trains; the source comes from the index
df = pd.concat([
    pd.read_parquet(f"{url}?token={TOKEN}", columns=["trip_id"]).assign(provider=provider)
    for provider, url in zip(files.provider, files.url)
])
print(df.groupby("provider")["trip_id"].nunique().sort_values())

R

library(arrow)

base <- "https://data.train-archive.eu"
token <- "YOUR_TOKEN"  # your token from /account/tokens

index <- read_parquet(paste0(base, "/index.parquet?token=", token))
files <- index[index$kind == "daily" & index$service == "TripUpdate" & index$date == "2026-11-03", ]
# One column is enough to count trains; the source comes from the index
df <- do.call(rbind, Map(function(provider, url) {
  data.frame(provider, trip_id = read_parquet(paste0(url, "?token=", token))$trip_id)
}, files$provider, files$url))
print(sort(tapply(df$trip_id, df$provider, function(x) length(unique(x)))))

curl

day=2026-11-03
for id in $(curl -s https://www.train-archive.eu/api/sources.json | jq -r '.sources[] | select(.services.TripUpdate) | .id'); do
  curl -fsLo "$id-$day.parquet" \
    "https://data.train-archive.eu/daily/provider=$id/service=TripUpdate/date=$day.parquet?token=YOUR_TOKEN" \
    || echo "$id: no file for $day"
done

Delays with stop names

The daily table has one row per train and planned stop, with its final delay, the stop name and the scheduled time. Stops the feed did not list take the previous stop’s delay (delay_source = 'propagated').

DuckDB

INSTALL httpfs; LOAD httpfs;
CREATE SECRET train_archive (TYPE http, SCOPE 'https://data.train-archive.eu',
    EXTRA_HTTP_HEADERS MAP {'X-Api-Key': 'YOUR_TOKEN'});

-- The ten most delayed arrivals of the day
SELECT route, stop_name, scheduled_arrival, arrival_delay_min, delay_source
FROM 'https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet'
WHERE trip_status = 'scheduled' AND arrival_delay_min IS NOT NULL
ORDER BY arrival_delay_min DESC LIMIT 10;

Polars

import polars as pl

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

day = pl.read_parquet(f"{BASE}/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet?token={TOKEN}")
print(day.filter(pl.col("trip_status") == "scheduled")
      .select("route", "stop_name", "scheduled_arrival", "arrival_delay_min", "delay_source")
      .sort("arrival_delay_min", descending=True, nulls_last=True).head(10))

pandas

import pandas as pd

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

day = pd.read_parquet(f"{BASE}/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet?token={TOKEN}")
day = day[day.trip_status == "scheduled"]
print(day.nlargest(10, "arrival_delay_min")[["route", "stop_name", "scheduled_arrival", "arrival_delay_min"]])

R

library(arrow)

base <- "https://data.train-archive.eu"
token <- "YOUR_TOKEN"  # your token from /account/tokens

day <- read_parquet(paste0(base, "/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet?token=", token))
day <- day[day$trip_status == "scheduled" & !is.na(day$arrival_delay_min), ]
head(day[order(-day$arrival_delay_min), c("route", "stop_name", "scheduled_arrival", "arrival_delay_min")], 10)

curl

# The same table as a spreadsheet: one row per train and stop, delays in minutes
curl --compressed -fLo be-sncb-2026-11-03.csv "https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.csv.gz?token=YOUR_TOKEN"
head be-sncb-2026-11-03.csv

Every version of every forecast, as the feed sent it, is in the day’s <code>day.parquet</code> (the Today example reads it).

The timetable

be-sncb/static/latest.json names the newest timetable and gives one Parquet file per GTFS table. Older versions stay available (in the index, kind = 'static').

DuckDB

INSTALL httpfs; LOAD httpfs;
CREATE SECRET train_archive (TYPE http, SCOPE 'https://data.train-archive.eu',
    EXTRA_HTTP_HEADERS MAP {'X-Api-Key': 'YOUR_TOKEN'});

SET VARIABLE stops = (SELECT tables.stops FROM read_json('https://data.train-archive.eu/be-sncb/static/latest.json'));
SELECT stop_id, stop_name, stop_lat, stop_lon
FROM read_parquet('https://data.train-archive.eu/' || getvariable('stops'))
LIMIT 10;

Polars

import json, urllib.request
import polars as pl

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

latest = json.load(urllib.request.urlopen(f"{BASE}/be-sncb/static/latest.json?token={TOKEN}"))
print(latest["version"], sorted(latest["tables"]))
stops = pl.read_parquet(f"{BASE}/{latest['tables']['stops']}?token={TOKEN}")
print(stops.select("stop_id", "stop_name", "stop_lat", "stop_lon"))

pandas

import json, urllib.request
import pandas as pd

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

latest = json.load(urllib.request.urlopen(f"{BASE}/be-sncb/static/latest.json?token={TOKEN}"))
print(latest["version"], sorted(latest["tables"]))
stops = pd.read_parquet(f"{BASE}/{latest['tables']['stops']}?token={TOKEN}")
print(stops[["stop_id", "stop_name", "stop_lat", "stop_lon"]])

R

library(arrow)

base <- "https://data.train-archive.eu"
token <- "YOUR_TOKEN"  # your token from /account/tokens

latest <- jsonlite::fromJSON(paste0(base, "/be-sncb/static/latest.json?token=", token))
stops <- read_parquet(paste0(base, "/", latest$tables$stops, "?token=", token))
head(stops[, c("stop_id", "stop_name", "stop_lat", "stop_lon")])

curl

# Every table of the current timetable, one Parquet file each
curl -s "https://data.train-archive.eu/be-sncb/static/latest.json?token=YOUR_TOKEN" \
  | jq -r '.tables | to_entries[] | "\(.key) \(.value)"' \
  | while read table path; do curl -fsSLo "$table.parquet" "https://data.train-archive.eu/$path?token=YOUR_TOKEN"; done
# As a standard GTFS zip, for any GTFS tool: pip install gtfs-parquet, then
python -c 'import gtfs_parquet as g; g.write_gtfs(g.read_parquet("."), "gtfs.zip")'

Today, as it comes in

Until the nightly file is built, a day is a set of 15-minute files (or one compacted file once it ends). New rows arrive every 15 minutes.

DuckDB

INSTALL httpfs; LOAD httpfs;
CREATE SECRET train_archive (TYPE http, SCOPE 'https://data.train-archive.eu',
    EXTRA_HTTP_HEADERS MAP {'X-Api-Key': 'YOUR_TOKEN'});

SET VARIABLE files = (
    SELECT list(url) FROM 'https://data.train-archive.eu/index.parquet'
    WHERE provider = 'be-sncb' AND service = 'TripUpdate' AND date = '2026-11-04'
      AND kind IN ('hourly', 'compacted')
);
SELECT max(fetchTime) AS newest, count(DISTINCT trip_tripId) AS trains
FROM read_parquet(getvariable('files'), union_by_name = true);

Polars

import polars as pl

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

index = pl.read_parquet(f"{BASE}/index.parquet?token={TOKEN}")
urls = index.filter((pl.col("provider") == "be-sncb") & (pl.col("service") == "TripUpdate")
                    & (pl.col("date") == "2026-11-04") & pl.col("kind").is_in(["hourly", "compacted"]))["url"]
df = pl.concat([pl.read_parquet(f"{u}?token={TOKEN}") for u in urls], how="diagonal_relaxed")
print(df["fetchTime"].max(), df["trip_tripId"].n_unique(), "trains")

pandas

import pandas as pd

BASE, TOKEN = "https://data.train-archive.eu", "YOUR_TOKEN"  # your token from /account/tokens

index = pd.read_parquet(f"{BASE}/index.parquet?token={TOKEN}")
urls = index.query("provider == 'be-sncb' and service == 'TripUpdate' and date == '2026-11-04'"
                   " and kind in ('hourly', 'compacted')")["url"]
df = pd.concat([pd.read_parquet(f"{u}?token={TOKEN}") for u in urls])
print(df["fetchTime"].max(), df["trip_tripId"].nunique(), "trains")

R

library(arrow)

base <- "https://data.train-archive.eu"
token <- "YOUR_TOKEN"  # your token from /account/tokens

index <- read_parquet(paste0(base, "/index.parquet?token=", token))
urls <- with(index, url[provider == "be-sncb" & service == "TripUpdate" & date == "2026-11-04" &
                        kind %in% c("hourly", "compacted")])
tables <- lapply(paste0(urls, "?token=", token), read_parquet, as_data_frame = FALSE)
df <- as.data.frame(do.call(concat_tables, c(tables, unify_schemas = TRUE)))
cat(format(max(df$fetchTime)), length(unique(df$trip_tripId)), "trains\n")

curl

# Live files are named by local time: 08-15-00_to_08-30-00.parquet holds 08:15 to 08:30
curl -fLo be-sncb-2026-11-04-18-30-00_to_18-45-00.parquet \
  "https://data.train-archive.eu/provider=be-sncb/service=TripUpdate/date=2026-11-04/18-30-00_to_18-45-00.parquet?token=YOUR_TOKEN"

Hourly and compacted files hold every version of every forecast as the feed sent it. The daily file is a different table: the final delay per train and planned stop.

Every day as one table: Iceberg

Two Apache Iceberg tables, TripUpdate and VehiclePosition, hold every finished day of every source. An engine that reads Iceberg sees the whole archive as one table: filter on provider and date and it reads only the matching files. The tables grow as days are compacted. Today’s data is not in them: read it from the 15-minute files.

INSTALL httpfs; INSTALL iceberg; LOAD httpfs; LOAD iceberg;
CREATE SECRET train_archive (TYPE http, SCOPE 'https://data.train-archive.eu',
    EXTRA_HTTP_HEADERS MAP {'X-Api-Key': 'YOUR_TOKEN'});

-- provider and date are partitions: filtering on them reads only the matching files
SELECT provider, count(*) AS rows, count(DISTINCT trip_tripId) AS trains
FROM iceberg_scan('https://data.train-archive.eu/iceberg-public/TripUpdate')
WHERE date = DATE '2026-11-03'
GROUP BY provider ORDER BY trains DESC;

SELECT count(*) AS rows FROM iceberg_scan('https://data.train-archive.eu/iceberg-public/TripUpdate')
WHERE provider = 'be-sncb' AND date BETWEEN DATE '2026-11-03' - 6 AND DATE '2026-11-03';

Here the token must be the X-Api-Key header, as in the secret above, not ?token=: the table finds its files by their addresses, which cannot carry your token. DuckDB is the tested way to read these tables.

File layout

Every address starts with https://data.train-archive.eu/. source is an id from the list of sources, service is TripUpdate or VehiclePosition, day is YYYY-MM-DD.

AddressWhat it holdsWhen it appears
daily/provider=source/service=TripUpdate/date=day.parquetA finished service day, flat: one row per train and planned stop with its final delay, scheduled and predicted times, stop and route names. Stops the feed did not update take the previous stop’s delay (delay_source says which).Each night, from 02:15 UTC
daily/provider=source/service=service/date=day.csv.gzThe same table as a spreadsheet (delays); one row per position (positions).Same
provider=source/service=service/date=day/HH-MM-00_to_HH-MM-00.parquet15 minutes of today, local time (e.g. 08-15-00_to_08-30-00; one file per hour before 2 Oct 2026).About 20 minutes after the window ends
provider=source/service=service/date=day/day.parquetA finished day’s live files, merged: every version of every forecast or position, every field the feed sent (nested, as in GTFS-RT).Once the day ends
source/static/latest.jsonThe newest timetable: its version and one address per table.When the timetable changes
source/static/version/table.parquetOne GTFS table of one timetable version. Every version is kept. For a standard GTFS zip: gtfs_parquet.write_gtfs(gtfs_parquet.read_parquet(folder), "gtfs.zip") (pip install gtfs-parquet).When the timetable changes
iceberg-public/serviceAn Iceberg table over the finished days’ files (how to read it).As days are compacted
index.parquetEvery Parquet file above, one row each.Every 15 minutes

Reading the rows

Time zones
Days and hours in addresses are the source’s local time. fetchTime, feedTimestamp, firstSeen and lastSeen are UTC timestamps; arrival.time, departure.time and timestamp are Unix seconds. Times in the CSV are local. A timetable version is named after the UTC time it was stored, like 2026-09-28_16-44-31Z.
Units
Delays are in seconds in Parquet (arrival.delay, departure.delay) and in minutes in the CSV. Negative means early.
One row
One trip update (or one position) as the feed sent it. While nothing changes, the archive keeps a single row and moves lastSeen forward.
Delay at a stop
Unnest stopTimeUpdate and keep the last row for each train and stop: the last delay announced, which is what happened once the train has passed.
Timetable
staticVersion names the timetable that was current. Join its stop_times on trip_id and stop_sequence, or on stop_id where the feed sends no sequence.
Day boundaries
A day’s file holds everything seen during that local day, so a train just after midnight can appear the day before. Filter on trip_startDate (Parquet) or service_date (CSV) for one timetable day.
As published
Nothing is cleaned: expect a few odd values, like trains hours early, and filter them if they matter to you.

Other ways in

SQL in your browser
Query any day with DuckDB running in the page. Nothing to install.
With an AI assistant
Connect Claude, ChatGPT or another MCP client: it finds sources, previews files and writes the code for you.
Downloads, no code
A source’s calendar, and a CSV or Parquet file for each day.
Iceberg tables
Every finished day of every source as one table, for engines that read Apache Iceberg.