My Research Diary 02: Measuring Train Delay Propagation in Finland
How I worked out where a late train actually lost its time, by measuring the minutes it lost or recovered between every stop, first on one train from Helsinki to Rovaniemi, then across eight years of Finnish long-distance trains.
Entry #002 — October 2nd, 2026
On Wednesday 11 February 2026, the long-distance train IC 25 left Helsinki on time and reached Rovaniemi 32 minutes late. Nothing dramatic happened on the way. No row of its 240-row timetable lost more than nine minutes. Yet from Tampere onwards the train was at least six minutes late at every single row. I asked the data where this train was delayed and the first answer is simply “everywhere”. That answer is technically correct, but it tells us nothing useful.
Why understanding delays matters
A late train is a small daily frustration for the people on it. A missed connection, a long wait on the platform, a meeting that starts without you. For the people who run the railway it is a bigger problem, because on shared track one late train disturbs the ones behind it, and every delay has to be explained, managed and, if possible, prevented.
Prevention is where the way we measure delay starts to matter. The number everyone sees is how late the train is. It says that something went wrong, but not where, and without a place there is nothing to fix and nothing to learn from. Was the time lost at one station, on one slow stretch of track, or a few minutes at a time along the whole line? Each answer points to a different remedy, and the lateness at the end of the journey cannot tell them apart. Later in this entry I show how little it tells us. Across eight years of Finnish long-distance trains, only about 4 in every 100 late timetable rows were rows where the train actually lost that time. The other 96 were simply carrying a delay the train already had.
This entry shows a way to find the place. It explains two numbers that can be calculated for every row of a train’s timetable. The first is how late the train is, and the second is how many minutes it lost or recovered since the previous row. Section 2 shows both on a map of one real train, and the Python code for both is in the last section.
Same data, one step further
Digitraffic, Finland’s open railway data service, publishes the timetable of every train as a list of rows, one for each arrival at and departure from a station, with the scheduled time and the actual time. In my last post, How to Pull Finnish Railway Data with Python: A Practical Guide to Digitraffic Open Data, I showed how to retrieve every train of a given day, stop by stop. This entry uses that same data, so I will not repeat the retrieval here, and you do not need to have read that post to follow this one.
1. Two ways to measure delay
Every timetable row describes one event, a train arriving at or departing from one station. The delay at that row is the time the train actually arrived or departed, minus the time the timetable said it should, in minutes. For example, IC 25 was scheduled to leave Tampere at 14:02 and actually left at 14:09, so the delay at that row is 7 minutes.
As an equation, for the i-th timetable row of a train in route order, it reads as follows.
delay(i) = round( actual time(i) − scheduled time(i) ) in minutesThe delay is cumulative. It measures lateness against the timetable at that row, including everything inherited from earlier in the journey. A train that is 40 minutes late at Oulu did not necessarily lose 40 minutes at Oulu.
So I calculate a second number for every row, the per-stop change, which is how many minutes the train lost or recovered since the previous row, meaning the previous station in the timetable. If a train is 3 minutes late at one station and 5 minutes late at the next, it lost 2 minutes in between. Each station has an arrival row and a departure row, so a departure row is compared with the arrival at the same station. This includes pass-through stations, the stations a train drives through without stopping for passengers, which the timetable still records. Positive values mark where time was lost; negative values mark where it was recovered. As an equation, it reads as follows.
change(i) = delay(i) − delay(i−1) for every row after the first
change(1) = delay(1) for the first rowThe first row of a train has nothing before it, so its change is its own delay. A train that departs five minutes late has lost five minutes at the origin.
The delay is what the API reports, and the per-stop change is what I add. The Python code for it is in the last section.
2. One real journey, the IC 25 train from Helsinki to Rovaniemi
Here is the whole journey of IC 25 on a map. Both maps show the same route and the same 28 stations, picked out of the 121 the train passes so that the dots do not crowd. The six named cities are always shown.
IC 25 on 11 February 2026. On the left, the delay at each station, accumulated from Helsinki. On the right, the minutes lost (red) or recovered (blue) since the previous dot.
The left map is the delay the railway reports. It grows almost steadily, peaks at 40 minutes in Oulu and ends at 32 in Rovaniemi. The right map shows the second number from section 1, the minutes lost or recovered between stations. It is mostly pale, with a few minutes at a time. Only two stretches stand out in red, the +11 just before Hirvineva and the +10 at Kemi, and one stands out in blue, the 8 minutes recovered after Oulu. Oulu itself is the clearest example. The left map says 40 minutes, but the right map says the train added only 2 there.
The numbers on the right are larger than the per-row changes in the tables below, because each one covers every station between two dots, hidden ones included. The +11 contains the seven minutes lost at Ruukki and the +10 contains the nine lost at Maksniemi. Together, the numbers on the right add up to the 32 minutes the train arrived with.
The same journey in numbers. Here are eight of its 240 timetable rows as the API reports them, in Finnish local time (the API returns UTC).
Station Row Scheduled Actual Delay
-------------- --- ------------ ------------ -----
Helsinki DEP Wed 12:20:00 Wed 12:20:25 0
Tampere DEP Wed 14:02:00 Wed 14:09:17 +7
Parkano (pass) DEP Wed 14:34:00 Wed 14:47:08 +13
Seinäjoki ARR Wed 15:09:00 Wed 15:24:24 +15
Ylivieska ARR Wed 16:50:00 Wed 17:13:18 +23
Ruukki (pass) ARR Wed 17:26:00 Wed 18:02:47 +37
Oulu DEP Wed 17:53:00 Wed 18:32:51 +40
Rovaniemi ARR Wed 20:23:00 Wed 20:54:46 +32The times carry seconds, and the delay is rounded from them. At Ruukki, 18:02:47 is 36.8 minutes after 17:26:00, reported as +37. I checked the API’s delay against actual minus scheduled time on IC 25, and it matches, rounded to the nearest minute, on all 230 rows that have an actual time (a row with no actual time gets its estimated time instead).
Now the same train as increments. The table below shows timetable rows in order, so that each Change can be checked against the line directly above it, with Change = Delay − the Delay on the row above. It has two pieces. The first ten rows of the journey, out of Helsinki, and, after a gap, every row from the arrival at Ylivieska to the departure from Oulu. The first row after the gap compares with the last omitted row, also at 23. Times are Finnish local time. Most rows are pass-throughs, marked (pass), which is why the stations are so close together.
Station Row Scheduled Delay Change Cause
---------------------------- --- --------- ----- ------ -----
Helsinki DEP 12:20 0 0 —
Pasila ARR 12:25 0 0 —
Pasila DEP 12:26 1 +1 —
Helsinki Louhenpuisto (pass) ARR 12:26 1 0 —
Helsinki Louhenpuisto (pass) DEP 12:26 1 0 —
Käpylä (pass) ARR 12:28 1 0 —
Käpylä (pass) DEP 12:28 1 0 —
Oulunkylä (pass) ARR 12:29 1 0 —
Oulunkylä (pass) DEP 12:29 1 0 —
Helsinki Veräjälaakso (pass) ARR 12:29 1 0 —
… 171 rows omitted, delay 1 → 23
Ylivieska ARR 16:50 23 0 K3
Ylivieska DEP 16:52 23 0 —
Kangas (pass) ARR 16:59 22 −1 —
Kangas (pass) DEP 16:59 22 0 —
Oulainen (pass) ARR 17:07 23 +1 —
Oulainen (pass) DEP 17:07 23 0 —
Kilpua (pass) ARR 17:12 22 −1 —
Kilpua (pass) DEP 17:12 22 0 —
Vihanti (pass) ARR 17:18 25 +3 —
Vihanti (pass) DEP 17:18 27 +2 K2
Ahonpää (pass) ARR 17:20 29 +2 —
Ahonpää (pass) DEP 17:20 29 0 —
Tuomioja (pass) ARR 17:23 30 +1 —
Tuomioja (pass) DEP 17:23 30 0 —
Ruukki (pass) ARR 17:26 37 +7 —
Ruukki (pass) DEP 17:26 37 0 —
Hirvineva (pass) ARR 17:30 38 +1 —
Hirvineva (pass) DEP 17:30 38 0 —
Tikkaperä (pass) ARR 17:32 38 0 —
Tikkaperä (pass) DEP 17:32 38 0 —
Liminka (pass) ARR 17:35 39 +1 —
Liminka (pass) DEP 17:35 39 0 —
Kempele (pass) ARR 17:40 39 0 —
Kempele (pass) DEP 17:40 39 0 —
Oulunlahti (pass) ARR 17:43 39 0 —
Oulunlahti (pass) DEP 17:43 39 0 —
Oulu ARR 17:48 39 0 P105
Oulu DEP 17:53 40 +1 —The cause codes are K3 for a speed reduction due to rolling stock, K2 for a rolling-stock fault and P105 for a track circuit cause.
In this stretch the delay grows from 23 to 40 minutes without any jump. It gains two or three minutes at Vihanti, two at Ahonpää, one at Tuomioja, then seven at Ruukki, and a handful of one-minute steps, some in each direction. Only three rows in it carry a cause.
The remaining rows that the text below refers to are not consecutive, so here the Prev column gives the delay on the row just before each one in the full timetable, and Change = Delay − Previous row.
Station Row Scheduled Prev Delay Change Cause
------------------ --- --------- ---- ----- ------ -----
Hämeenlinna (pass) DEP 13:23 4 4 0 K3
Parola (pass) ARR 13:27 4 5 +1 —
Toijala (pass) DEP 13:39 7 7 0 K3
Tampere ARR 13:58 8 9 +1 K3
Karhejärvi (pass) ARR 14:20 9 11 +2 —
Parkano (pass) DEP 14:34 13 13 0 K3
Seinäjoki ARR 15:09 18 15 −3 K3
Pännäinen (pass) ARR 15:55 19 20 +1 —
Pännäinen (pass) DEP 15:55 20 20 0 K3
Kello (pass) ARR 18:06 39 35 −4 —
Lähessuo (pass) ARR 18:30 32 29 −3 —
Maksniemi (pass) DEP 18:55 29 38 +9 —
Tervola ARR 19:34 38 35 −3 —K3 is the same speed reduction cause as in the first table.
Get Vinicius Pozzobon Borin’s stories in your inbox
Join Medium for free to get updates from this writer.
Zoom out to the whole train, as in the maps above. Read the cumulative column alone and it drifted from on time to 40 minutes late, with nine recorded causes. Read the increments for all 240 rows and the picture is different from a breakdown. There is no single row that explains the delay. The train gained time in 47 of its 240 rows, 69 minutes in total. It recovered 37 minutes in 25 other rows and stayed level in the remaining 168. The net is the 32 minutes it arrived with. The largest single loss was nine minutes, at the departure from Maksniemi, and the next was seven, at Ruukki. The delay crossed five minutes at Parola, ten at Karhejärvi, twenty at Pännäinen and thirty at Tuomioja, so it was built in steady steps along the whole line.
The recorded causes do not point to where the time went either. K3, speed reduction due to rolling stock, is logged on seven rows between Hämeenlinna and Ylivieska (six of them are in the last table, the seventh opens the long one), but every one of those rows has a change between −3 and +1. A K2 rolling-stock fault is logged later, at Vihanti, with a change of +2. The two biggest losses, at Maksniemi and Ruukki, carry no cause at all. A slow train with a rolling-stock problem would fit this picture, but the increments are what let me see that it lost time a few minutes at a time.
The last stretch reads the opposite way. After Oulu the train recovered eight minutes net, through small negative changes at Kello, Lähessuo, Tervola and other rows, even after losing nine at Maksniemi.
3. The pitfall of dropping pass-through rows first
Five of IC 25’s nine recorded causes sit on pass-through rows, and so do both of its largest losses. It is tempting to clean the table before computing anything, by dropping the pass-through rows and keeping only the commercial stops, the stations where the train stops and passengers can board. Those are the rows a timetable reader would recognise. Don’t.
Keep only the commercial stops before computing increments and IC 25 seems to lose 16 minutes in one step, between the departure from Ylivieska and the arrival in Oulu. The table above shows something else. Oulu itself lost nothing (change 0). Those 16 minutes were spread over ten rows between the two stations, all of them pass-throughs, with three minutes at Vihanti’s arrival, two at its departure, two at Ahonpää, one at Tuomioja, seven at Ruukki, one each at Oulainen, Hirvineva and Liminka, minus two minutes recovered at Kangas and Kilpua. Dropping the pass-throughs first would blame Oulu for a loss that happened along the stretch before it.
The order matters. The change has to be computed on the full table, and only then filtered.
4. The same question at national scale
One train is only one example, so I checked whether the pattern holds more widely. I calculated both numbers for every long-distance train in the dataset and aggregated them per day. Over 2018–2025 that is 2,922 days and about 48 million timetable rows, using a threshold of five minutes to call a row delayed.
The table counts two kinds of timetable rows, using the notation from section 1. Rows with delay ≥ 5 are rows where the train is five minutes or more behind schedule. Rows with change ≥ 5 are rows where the train lost five minutes or more since the previous row. The last column is the percentage of the rows with delay ≥ 5 that are rows with change ≥ 5, which is the second count divided by the first. For 2018, 60,361 divided by 1,567,507 is 3.9%, which means that out of every 100 rows where the train was five minutes or more late, only about 4 were rows where it lost five minutes or more at that row. The other rows were simply carrying a delay the train already had.
Year delay ≥ 5 change ≥ 5 % with change ≥ 5
----- --------- ---------- -----------------
2018 1,567,507 60,361 3.9%
2019 1,638,101 57,132 3.5%
2020 722,466 44,340 6.1%
2021 832,321 48,718 5.9%
2022 1,085,660 48,174 4.4%
2023 1,024,717 37,620 3.7%
2024 1,595,512 44,689 2.8%
2025 878,581 35,177 4.0%
Total 9,344,865 376,211 4.0%Of the 9.3 million rows where a long-distance train was five minutes or more behind schedule, only 376,000, four percent, are rows where five minutes or more were actually lost. The other 96 percent are trains carrying a delay they already had.
The worst day in the dataset shows it most clearly. On 2 January 2019, 14,918 timetable rows were five minutes or more late, but only 551 of them were rows where that much time was lost. The largest cumulative delay that day was 346 minutes; the largest single-row loss was 193.
I also track a daily propagation ratio, which is the mean per-stop change divided by the mean cumulative delay, over rows with delay ≥ 5. It says how much of the average lateness was added at the row itself. Its median across the eight years is 0.038, so the typical late row added under four percent of the lateness it carried. By month it is lowest in January and February (about 0.03) and highest in April (0.048). I read that as an observation, not a finding. In deep winter, delays are more often carried along than freshly generated, but I have not yet tested why.
5. What this means for studying delays
The two numbers answer different questions.
- Cumulative delay describes the state of the train, meaning how late it is here.
- Per-stop change describes an event, meaning how much time was lost on this row.
If I counted every late row, one fault stretched over thirty stations would count as thirty separate delay events. Counting only the rows where time was actually lost gives far fewer, far more meaningful events.
It also exposes a second problem. As IC 25 showed, the recorded cause can sit on rows where nothing changed, while the rows that lost the most carry no cause at all. Labelling the delay with the nearest cause code is not enough; the cause has to be aligned with the rows where the time was actually lost. That is the next thing I want to get right.
6. How to calculate it in Python
The calculation is short. I start from a pandas table called rows, with one line per timetable row and the rows of each train kept in route order, as the API returns them. Here are the first rows of IC 25 in such a table, with times in UTC as the API returns them. The real table has more columns, such as the type of row (arrival or departure) and the scheduled time, which I leave out here to save space.
train_trainNumber stationShortCode actualTime differenceInMinutes
----------------- ---------------- ---------- -------------------
25 HKI 10:20:25 0
25 PSL 10:25:12 0
25 PSL 10:27:07 1
25 LOP none 1The last row has no actual time recorded, but the API still reports a delay of one minute for it, based on the estimated time. The per-stop change then comes from one group operation.
has = rows["differenceInMinutes"].notna()
by_train = rows[has].groupby("train_trainNumber")["differenceInMinutes"]
rows.loc[has, "differenceInMinutes_eachStation_offset"] = (
by_train.diff().fillna(rows.loc[has, "differenceInMinutes"]))differenceInMinutes is the API's own field for the delay, and differenceInMinutes_eachStation_offset is the name of the new column in my code. diff() subtracts each row's delay from the one before it, within each train, so [22, 25, 27] becomes [NaN, 3, 2]. It relies on route order, which the API preserves. The NaN at a train's first row is filled with that row's own delay, as in section 1.
Rows with no differenceInMinutes are skipped, so the "previous row" is the last one that has a value. Applied to the four rows above, the new column looks like this.
stationShortCode differenceInMinutes differenceInMinutes_eachStation_offset
---------------- ------------------- --------------------------------------
HKI 0 0
PSL 0 0
PSL 1 1
LOP 1 0One rule from section 3 applies here too. Compute the change on the full table, pass-through rows included, and filter only afterwards. My own implementation loops over every timetable row of every train.
I will continue bringing news about my research here. Stay tuned!
References
- Digitraffic railway traffic service, Fintraffic. Open data under CC BY 4.0, available at digitraffic.fi
- Previous post, How to Pull Finnish Railway Data with Python









