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.
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.
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.
importpolarsasplBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensdf=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
importpandasaspdBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensdf=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/tokensdf<-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-fLobe-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-fLobe-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.
importpolarsasplBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensindex=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}")foruinurls],how="diagonal_relaxed")print(df.group_by("date").len().sort("date"))
pandas
importpandasaspdBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensindex=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}")foruinurls])print(df.groupby("date").size())
R
library(arrow)base<-"https://data.train-archive.eu"token<-"YOUR_TOKEN"# your token from /account/tokensindex<-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
fordayin2026-10-282026-10-292026-10-302026-10-312026-11-012026-11-022026-11-03;docurl-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.
importpolarsasplBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensindex=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 indexdf=pl.concat([pl.read_parquet(f"{url}?token={TOKEN}",columns=["trip_id"]).with_columns(provider=pl.lit(provider))forprovider,urlinfiles.select("provider","url").iter_rows()])print(df.group_by("provider").agg(pl.col("trip_id").n_unique().alias("trains")).sort("trains"))
pandas
importpandasaspdBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensindex=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 indexdf=pd.concat([pd.read_parquet(f"{url}?token={TOKEN}",columns=["trip_id"]).assign(provider=provider)forprovider,urlinzip(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/tokensindex<-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 indexdf<-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
foridin$(curl-shttps://www.train-archive.eu/api/sources.json|jq-r'.sources[] | select(.services.TripUpdate) | .id');docurl-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
INSTALLhttpfs;LOADhttpfs;CREATESECRETtrain_archive(TYPEhttp,SCOPE'https://data.train-archive.eu',EXTRA_HTTP_HEADERSMAP{'X-Api-Key':'YOUR_TOKEN'});-- The ten most delayed arrivals of the daySELECTroute,stop_name,scheduled_arrival,arrival_delay_min,delay_sourceFROM'https://data.train-archive.eu/daily/provider=be-sncb/service=TripUpdate/date=2026-11-03.parquet'WHEREtrip_status='scheduled'ANDarrival_delay_minISNOTNULLORDERBYarrival_delay_minDESCLIMIT10;
Polars
importpolarsasplBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensday=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
importpandasaspdBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensday=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/tokensday<-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-fLobe-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"
headbe-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').
importjson,urllib.requestimportpolarsasplBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokenslatest=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
importjson,urllib.requestimportpandasaspdBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokenslatest=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/tokenslatest<-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)"'\|whilereadtablepath;docurl-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.
importpolarsasplBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensindex=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}")foruinurls],how="diagonal_relaxed")print(df["fetchTime"].max(),df["trip_tripId"].n_unique(),"trains")
pandas
importpandasaspdBASE,TOKEN="https://data.train-archive.eu","YOUR_TOKEN"# your token from /account/tokensindex=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}")foruinurls])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/tokensindex<-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-fLobe-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.
INSTALLhttpfs;INSTALLiceberg;LOADhttpfs;LOADiceberg;CREATESECRETtrain_archive(TYPEhttp,SCOPE'https://data.train-archive.eu',EXTRA_HTTP_HEADERSMAP{'X-Api-Key':'YOUR_TOKEN'});-- provider and date are partitions: filtering on them reads only the matching filesSELECTprovider,count(*)ASrows,count(DISTINCTtrip_tripId)AStrainsFROMiceberg_scan('https://data.train-archive.eu/iceberg-public/TripUpdate')WHEREdate=DATE'2026-11-03'GROUPBYproviderORDERBYtrainsDESC;SELECTcount(*)ASrowsFROMiceberg_scan('https://data.train-archive.eu/iceberg-public/TripUpdate')WHEREprovider='be-sncb'ANDdateBETWEENDATE'2026-11-03'-6ANDDATE'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.
A 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).
A 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.json
The newest timetable: its version and one address per table.
When the timetable changes
source/static/version/table.parquet
One 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/service
An Iceberg table over the finished days’ files (how to read it).
As days are compacted
index.parquet
Every 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.