SNCB is the national railway company of Belgium. It is an autonomous government company.
It is responsible for the operation of the national railway system. It provides a GTFS
and GTFS-RT feed. The MobilityTwin.Brussels platform enriches these by providing estimated
positions of the trains based on the GTFS-RT feed.
The estimated positions of the trains based on the GTFS-RT feed combined with the GTFS feed.
Refresh: Every 20 seconds (derived from the GTFS-RT feed)
From
2024-08-21 14:54:59
To
2026-05-19 06:09:00
Records
2,590,312
import requests
import geopandas as gpd
from datetime import datetime, timedelta, timezone
end = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
start = end - timedelta(hours=1)
params = {
"start_timestamp": start.isoformat(), # ISO accepted; epoch also works
"end_timestamp": end.isoformat(),
}
url = "https://api.mobilitytwin.brussels/sncb/vehicle-position"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
gdf = gpd.GeoDataFrame.from_features(data["features"])
# Show all currently running trains with their route
for _, train in gdf.iterrows():
print(f"{train['name_start']} → {train['name_end']} ({train['trip_headsign']})")
# Plot train positions on a map
gdf.plot(figsize=(10, 8), color="#006ab3", markersize=10)
Trips
/sncb/trips
MF-JSONAPPLICATION/JSON
All the trips of SNCB for the specified period of time. This is an aggregate of the GeoJSON files returned by the vehicle-position endpoint of MobilityTwin.Brussels.
Refresh: On request — aggregated from vehicle-position over the queried interval
Availability depends on source data
import requests
import movingpandas as mpd
from datetime import datetime, timedelta, timezone
end = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
start = end - timedelta(hours=1)
params = {
"start_timestamp": start.isoformat(), # ISO accepted; epoch also works
"end_timestamp": end.isoformat(),
}
url = "https://api.mobilitytwin.brussels/sncb/trips"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
# Load as a MovingPandas TrajectoryCollection
tc = mpd.io.read_mf_dict(data, traj_id_property="trip_id")
# Print summary of each trajectory
for traj in tc.trajectories:
print(f"Trip {traj.id}: {traj.get_length():.0f}m, duration: {traj.get_duration()}")
# Plot all train trajectories on a map
tc.plot(figsize=(12, 8), linewidth=1)
Gtfs
/sncb/gtfs
GTFSAPPLICATION/ZIP
The GTFS zip file of SNCB/NMBS
Refresh: Daily
From
2024-08-21 14:52:00
To
2026-05-19 02:10:06
Records
601
import gtfs_kit as gk
import requests
import tempfile
from datetime import datetime, timedelta, timezone
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/sncb/gtfs"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).content
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f:
f.write(data)
feed = gk.read_feed(f.name, 'm')
# List all routes
print(feed.routes[['route_short_name', 'route_long_name']])
# Count trips per route
trips_per_route = feed.trips.groupby('route_id').size()
print(f"\nTotal routes: {len(feed.routes)}, Total trips: {len(feed.trips)}")
GTFS (Parquet)
/sncb/gtfs-parquet
GTFS-PARQUETAPPLICATION/ZIP
The GTFS feed of SNCB/NMBS converted to Apache Parquet format (zip archive of .parquet files). Parquet uses columnar storage with zstd compression and strong typing, resulting in 40-75% smaller files compared to the original GTFS zip. This format enables extremely efficient data transfer and near-zero RAM overhead when reading specific columns via Polars or DuckDB, making it ideal for analytical workloads and large-scale processing. Produced using gtfs-parquet v0.4.0.
Refresh: Daily (regenerated from the GTFS feed)
From
2024-08-21 14:52:00
To
2026-05-19 02:10:06
Records
584
# pip install gtfs-parquet>=0.4.0
import requests
import tempfile
from gtfs_parquet import read_parquet
from gtfs_parquet.ops.network import describe
from gtfs_parquet.ops.calendar import get_first_week, compute_busiest_date
from gtfs_parquet.ops.routes import compute_route_stats
from datetime import datetime, timedelta, timezone
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/sncb/gtfs-parquet"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).content
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f:
f.write(data)
feed = read_parquet(f.name)
# Quick summary of the feed
print(describe(feed))
# Find the busiest date in the first week
week = get_first_week(feed)
busiest = compute_busiest_date(feed, week)
print(f"Busiest date: {busiest}")
# Compute per-route statistics for that week
route_stats = compute_route_stats(feed, week)
print(route_stats.sort("num_trips", descending=True))
GTFS-RT
/sncb/gtfs-realtime
GTFS-RTAPPLICATION/OCTET-STREAM
The GTFS-RT binary file of SNCB/NMBS
Refresh: Every 20 seconds
From
2024-08-21 14:54:59
To
2026-05-19 06:09:21
Records
2,588,692
import requests
from google.transit import gtfs_realtime_pb2
from datetime import datetime, timedelta, timezone
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/sncb/gtfs-realtime"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).content
feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(data)
# List all vehicle positions with their current status
for entity in feed.entity:
v = entity.vehicle
pos = v.position
print(f"Trip {v.trip.trip_id} at ({pos.latitude:.4f}, {pos.longitude:.4f})")
GTFS-RT Alerts
/sncb/gtfs-rt-alert
GTFS-RTAPPLICATION/OCTET-STREAM
Real-time service alerts for SNCB/NMBS trains (delays, cancellations, disruptions).
Refresh: Every 15 minutes
From
2026-03-30 11:01:24
To
2026-05-19 06:05:27
Records
4,635
import requests
from google.transit import gtfs_realtime_pb2
from datetime import datetime, timedelta, timezone
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/sncb/gtfs-rt-alert"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).content
feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(data)
# Display all active service alerts
for entity in feed.entity:
alert = entity.alert
header = alert.header_text.translation[0].text if alert.header_text.translation else "N/A"
routes = [s.route_id for s in alert.informed_entity]
print(f"[{alert.cause}] {header} — Routes: {', '.join(routes)}")
GTFS-RT Trip updates
/sncb/gtfs-rt-trip-update
GTFS-RTAPPLICATION/OCTET-STREAM
Real-time trip updates for SNCB/NMBS trains including schedule deviations and cancellations.
Refresh: Every 20 seconds
From
2026-03-30 11:00:46
To
2026-05-19 06:09:20
Records
176,931
import requests
from google.transit import gtfs_realtime_pb2
from datetime import datetime, timedelta, timezone
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/sncb/gtfs-rt-trip-update"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).content
feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(data)
# Show delayed trains (delay > 5 minutes)
for entity in feed.entity:
tu = entity.trip_update
for stu in tu.stop_time_update:
delay = stu.arrival.delay if stu.HasField('arrival') else 0
if delay > 300:
print(f"Trip {tu.trip.trip_id} — {delay // 60}min late at stop {stu.stop_id}")
Punctuality
/sncb/punctuality
PARQUETAPPLICATION/OCTET-STREAM
Daily punctuality table derived from SNCB/NMBS's GTFS-RT trip-update stream over a full Brussels-day window. Each row represents one (trip, stop) observation with the latest known arrival/departure times, delays, and schedule_relationship — plus a synthetic row per cancelled trip when the feed signals cancellation without stop-time entries. Single Parquet file (zstd-compressed). Read with Polars, DuckDB or PyArrow. The data is consistent with the GTFS schedule: scheduled = actual_time − delay matches the static GTFS arrival_time for SNCB/NMBS feeds that publish absolute times.
Refresh: Daily (one file per Brussels-day, anchored to 00:00 Europe/Brussels)
From
2024-08-22 00:00:00
To
2025-08-16 00:00:00
Records
309
# pip install polars requests
import io
import requests
import polars as pl
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {"timestamp": yesterday_noon.isoformat()}
url = "https://api.mobilitytwin.brussels/sncb/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:,}")
# Note: SNCB's GTFS-RT feed does not populate stop_sequence (always null).
# Join to static GTFS on stop_id when needed.
not_cancelled = df.filter(~pl.col("cancelled") & pl.col("arrival_delay").is_not_null())
# Trains with the largest arrival delays, and where they were observed.
print("\nTop 20 worst delays of the day:")
print(not_cancelled.sort("arrival_delay", descending=True)
.select(["trip_id", "stop_id", "arrival_time", "arrival_delay"])
.head(20))
# Cancellation rate per route (one row per cancelled trip).
cancelled = df.filter(pl.col("cancelled"))
print(f"\ncancelled trip-instances: "
f"{cancelled.select(['trip_id', 'start_date', 'start_time']).unique().height}")
print("\nMost-cancelled routes:")
print(cancelled.group_by("route_id").len()
.sort("len", descending=True).head(10))