Skip to content

Public transport fares

MaartenHilferink edited this page Nov 9, 2026 · 18 revisions

This page describes where the model gets the price of a single ride: which fare tables exist, how the model matches a ride to a fare, how it couples the fare year to the GTFS feed, and how it checks the fare tables. How the model combines the prices of the rides into the price of a chain (the continuation of the regional boarding fare and the NS through fare) is described on the Public transport page.

All fares are defined in SourceData/Infrastructure/OVprijzen (cfg/main/SourceData/OVprijzen.dms). The lookup of the price of each ride is in NetworkSetup/PublicTransport_Prep/OD_extra_attributen.dms and MakeODs.dms.

Contents


Overview

The Dutch fare system has two different structures, and the model follows that split. It divides the network into regional transport and the NS fare network; see Two fare groups. Each has its own price source:

Network Fare structure Source in the model Ride price attributes
Regional transport (OD_R) boarding fare + price per km, on some lines plus a surcharge per ride, per concession, operator and line, or a fixed fare per ride OVprijzen/FareTable (either FareTable_<year> or FareTable_DOVA) Price_I + Price_O + Price_S for a surcharge, or Price_S alone for a fixed fare
NS fare network (OD_L) price per number of tariff units between two NS stations, plus a surcharge per ride on some trains OVprijzen/NS (tariff unit network NS_tariefnet_<year>.csv + NS tarieven <year>.csv) Price_L + Price_S

The model computes the price of every ride in every run, because the chain joiner selects the chains on price at every join, whatever MinimiseCriteria says (Classifications/ChainJoinerParetoAttr). The price checks, the prices in the chain cache and the export of the price information follow ModelParameters/Impl/PriceExportsAndIntegrityChecksNeeded, which is true when MinimiseCriteria contains a price criterion or Export_PriceInformation is TRUE.


Coupling the fare year to the GTFS feed

The agency names in GTFS change from feed to feed (mergers, splits, new names), and the model matches the rows of a fare table on those names. A fare table is therefore only valid for the feed it was aligned with. To prevent mismatches, the fare year is not set by hand: the model derives it from the loaded feed. All derived parameters are in ModelParameters/Impl:

Parameter Rule Reason
FareTable_year substr(GTFS_file_date, 0, 4) The fare table always belongs to the loaded feed.
PriceMethod_effective 'DOVA' if FareTable_year < 2023, otherwise PriceMethod Detailed fare tables only exist for 2023 to 2026.
DOVA_year FareTable_year, clamped to Advanced/FareData_FirstYear to FareData_LastYear (2013 to 2026) The DOVA overview has the columns y2013 … y2026.
NS_prices_year FareTable_year, clamped to Advanced/FareData_FirstYear to FareData_LastYear (2013 to 2026) NS price tables exist for 2013 to 2026.
NS_TariffNetwork_year '2013' if NS_prices_year < 2016, otherwise '2016' NS abolished its joint tariff points with the fares of 2016 (#121).
ConcessionArea_year 'Y2020' if FareTable_year < 2020, otherwise 'Y'+FareTable_year Concession area shapes exist for 2020 to 2026.

For example, the model prices the analysis moment Y2015, with the feed 20151007, with the DOVA method: with the regional fares of 2015, the NS fares of 2015 on the tariff unit network of 2013, and the concession areas of 2020. The detailed fare tables are aligned with these feeds:

Fare table GTFS feed
FareTable_2023 20231003
FareTable_2024 20241001
FareTable_2025 20251008
FareTable_2026 20260316

Another feed of the same year uses the same table, such as the feeds 20250930 of Y2025 and 20260925 of Y2026. The check of the fare tables shows whether its agency names match (see Price checks).

ConcessionArea_year has no upper clamp: a feed from 2027 or later needs a new concession shape before either method can price it (see Adding a new fare year).


Which rides are priced as regional and which as NS

The split is defined per agency in Classifications/Agencies:

  • IsNS is true for ns, ns_international / ns_int and eu_sleeper / european_sleeper. Both name variants are in the list, because the agency name differs between feeds: NS International is called NS Int from feed 20251008, and Eu Sleeper is called European Sleeper from feed 20260316.
  • IsRO := not(IsNS): every other agency belongs to regional transport and gets its price from the fare table. This includes foreign carriers such as NMBS, De Lijn, VIAS, DB and Eurobahn.
  • IsLnet is true for ns, and for NS International when Advanced/IsNSInternationalInLnet is TRUE (the default).
  • IsForeign is true for the agencies that run abroad: De Lijn, DB, Eurobahn, NIAG, NMBS, VIAS, TCS OV, GoVolta, NS International and European Sleeper. It decides which regional rides lie abroad (see The concession area of a ride).

DB and Eurobahn are deliberately not in IsNS. They run regional cross-border stopping trains (Enschede to Dortmund, Hengelo to Bielefeld, Venlo to Hamm), which the model prices as regional rides. If they were in IsNS, their rides would drop out of the network altogether.

Set_L selects the links of the IsLnet agencies and the waiting links at NS stations. It leaves out the trains on which an NS ticket is never valid (OVprijzen/NS/TrainsWithoutNSTicket: Eurostar, Thalys, Nightjet and European Sleeper). NS International thus runs in the NS fare network, with the NS fare for domestic rides. A ride of NS International to or from a station abroad has no NS price, and the model leaves it out of OD_L/Result (IsDomesticRide in MakeODs). European Sleeper is in neither network and has no price. The same holds for NS International when IsNSInternationalInLnet is FALSE.


Regional transport: two price methods

The parameter ModelParameters/Advanced/PriceMethod selects which fare table OVprijzen/FareTable refers to:

unit<uint8> FareTable := =PriceMethod_effective == 'DOVA'
                          ? 'FareTable_DOVA'
                          : 'FareTable_'+FareTable_year;
Detailed DOVA
Years 2023 to 2026 2013 to 2026
Granularity mode × operator × concession × line mode class (bus/tram/metro, rail, ferry) × concession
Boarding fare per row (such as flat fares, Qliner, islands) one national boarding fare per year
Fixed fare per ride per row with a km fare of 0 (ferries, island buses, De Lijn) none
Surcharge per ride per row (Parkshuttle Rivium) none
Source primary operator sources per row DOVA overview of km fares

Choosing a price method

Use DOVA when results for different years must be comparable, for example in a time series of accessibility from 2013 onwards. It is the only method for every feed year from 2013 to 2026, and it prices every year with the same concession-wide structure. Differences between years then reflect changes in the timetable and the fare level, not changes in how detailed the fare table is. Use Detailed for an analysis of a single year from 2023 onwards, when the price level of individual rides matters. It includes line-specific fares, flat fares (for example on the Wadden islands and the Keukenhof lines), fixed fares per ride on ferries, boarding fares per operator and foreign carriers; DOVA approximates these with the km fare of the concession. The default of PriceMethod is 'Detailed'; for feeds before 2023 the model uses DOVA, whatever this setting is.

The concession area of a ride

Both methods look up the fare of a ride in the concession area of its line, Route_Concessiegebied_rel in MakeODs.dms (#150). A concession is granted for lines, not for stops: at a stop such as Leiden Centraal, buses of two concessions call. The model counts per line in which concession area each of its rides boards (Route_CG, with Start_Concessiegebied_rel, the point_in_polygon of the boarding stop in OD_extra_attributen.dms), and gives the line the area with the most boardings. With Detailed it counts only the areas in which the fare table has a row for the mode and agency of the line (Route_CG/HasRule), unless no ride of the line boards in such an area; then it counts all areas. Every ride of the line then looks up its row and its DOVA fare in that area, also where the line runs through another area.

A line none of whose rides boards in a concession area has no concession area. Its rides find only a row without a concession, or the fare of their agency where that is the same in all its concessions (see Method 1), and with DOVA the key Buitenland (see Method 2). That holds for a line abroad, and for a ferry whose piers lie outside the concession polygons, such as the Westerschelde Ferry and the ferry of Wagenborg to Schiermonnikoog.

The model leaves the rides abroad out of OD_R/Result (IsOD_completely_foreign): the rides of an agency that runs abroad (IsForeign) whose boarding and alighting stops both lie outside all concession areas. A ride of a Dutch agency stays, also the crossing of a ferry whose piers lie outside the areas. The model still computes and checks the prices of the rides abroad, because Price_I, Price_O and Price_S are defined on the full OD_R, before that filter.

The concession areas come from RegioIndelingen/Concessiegebieden/Y<year> (shapes Concessie_<year>.shp for 2020 to 2026).

Method 1: Detailed (FareTable per year)

FareTable_2023, FareTable_2024, FareTable_2025 and FareTable_2026 are inline tables in OVprijzen.dms, one per year, so that every price change is visible in a git diff. The rows differ per year because concessions change operator or are merged. Each row has nine columns:

Column Content
Mode GTFS mode name (Bus, Rail, Tram, Metro, Ferry)
Vervoerder agency name as it appears in the GTFS feed of that year
Provincie province, a note only: the lookup does not use it (#173)
Concessiegebied concession area name as in the concession shape (empty = all concessions)
Lijn route short name (empty = all lines)
Ritprijs boarding fare in euro (FixedPrice), or the fixed fare per ride when Kmprijs and Ritopslag are 0
Kmprijs price per km in euro (VariablePrice)
Ritopslag surcharge per ride in euro on top of the boarding fare and the price per km (RideSurcharge); 0 for most rows
Opmerking source of the price for this row

Each price comes from a primary source (operator fare sheets, provincial fare decisions, operator websites through the Wayback Machine) and is checked against DOVA (#112). The column Opmerking holds the source of each row. A row for which no public source was found is marked GEEN BRON GEVONDEN and holds an approximation. The coverage report FareTable_dekkingsrapport.md is kept with the source data, next to the operator fare sheets it refers to.

Some row types need explanation:

  • The NS rows (Rail and Bus under NS) have no price of their own (empty or 0). The model prices NS rides with tariff units (see below).
  • A row for one line replaces the concession fare for that line, for example for the Qliner lines, neighbourhood buses and Keukenhof lines.
  • A row with a Kmprijs of 0 and a Ritopslag of 0 is a fixed fare per ride (#55): the flat fares on the islands, the ferries, De Lijn and the KeukenhofBuzz. See From fare table row to ride price.
  • A row with a Ritopslag charges a fixed amount per ride on top of the boarding fare, and its ride stays part of the OV-chip journey (#164). The Parkshuttle Rivium (line 500 of Connexxion) works this way: it charges the national boarding fare plus a flat fare per ride, so a traveller who transfers from the metro at Kralingse Zoom pays only the flat fare. Its row has the boarding fare in Ritprijs, a Kmprijs of 0 and the flat fare of the DOVA overview in Ritopslag.
  • A general row without a concession area is the fare of the agency in every concession area without a row of its own, such as the row Alle of Connexxion in 2025 and 2026, the median of the Connexxion concession fares of that year. A row for one region therefore names its concession area; its province alone does not count (#173).
  • Rail replacement buses (Bus mode under a rail agency, "bus instead of train") get the fare of the train service they replace. They carry [dekkingscheck feed <date>] in Opmerking: the feed whose coverage check needs them.

For every ride, the lookup (FareTable_rel_Detailed in OD_extra_attributen.dms) builds identifiers from the mode, the agency name (AsItemName(lowercase(agency_name))), the concession area of the line and the line. It takes the first of these that matches a row of the fare table:

  1. A row for the line in its concession area, with the line recognised by its name (FareLine). This is for lines that the feed does not name in route_short_name, such as the island buses (Classifications/FareLineTerms) and the Opstapper in Fryslân (Classifications/FareLineRanges).
  2. A row for the line in its concession area, by route_short_name.
  3. A row for the line without a concession area.
  4. The general row of the agency in the concession area.
  5. The general row of the agency without a concession area.
  6. The fare of the agency where that is the same in all its concessions (FareTable_AgencyWide).

In the identifiers of the fare table a row without a concession has an empty concession: <mode>_<agency>__<line> and <mode>_<agency>_. An identifier with an empty part matches nothing, so a line without a concession area finds only steps 3, 5 and 6, and a row for one line never prices another line. The identifier has no province, so rows that differ only in Provincie have the same identifier, and only the first of them in the table applies. A ride that matches none of the six steps gets the DOVA fare of the concession area of its line and its mode class (FareTable_DOVA_rel). OD_R/impl/DOVAFallback/UniqueCombos lists these rides per combination of mode, agency, concession and line, with their number. The check of the fare tables reports every combination of mode and agency of the feed that has no row at all.

Method 2: DOVA (concession-wide km fares)

The DOVA method uses the overview of km fares that DOVA publishes for all concessions, from 2013 onwards. The model reads data/DOVA_Overzicht km-tarieven 2013-2026_edit.xlsx, which has two sheets:

  • kmtarief: one row per concession and operator, with the km fares in the columns y2013 … y2026. Three columns are added to align it with the model: Concessiegebied_model (the concession name exactly as in the concession shape), Mode_klasse (BTM, Rail or Ferry) and use_for_price. use_for_price is 1 for the one canonical row per concession and mode class, and 0 for flat fares, seasonal fares, services with the NS fare structure and duplicates. The model uses 38 rows: 31 BTM, 6 Rail and 1 Ferry.
  • Basistarief: the national boarding fare per year (€0.86 in 2013 to €1.16 in 2026).

FareTable_DOVA combines all concession areas with all mode classes (FareTable_DOVA_NL, combine_uint8(Concessiegebieden, ModeClass)), with the key <mode class>_<concession>. The model finds the km fare of each combination with a fallback chain:

  1. the DOVA fare for this concession and mode class;
  2. the BTM fare of the same concession (for example for a regional train in a concession without its own rail fare);
  3. the national average fare of the mode class (for example for a concession without any DOVA row).

In addition, the model adds one safety row per mode class with the key <mode class>_Buitenland, for the rides of a line without a concession area, at the national average of the class. Every row gets the national boarding fare of the year as FixedPrice.

OD_extra_attributen.dms builds the key of each ride from the mode class (Rail → Rail, Ferry → Ferry, anything else → BTM) and the name of the concession area of its line, or Buitenland if the line has none. The DOVA method has no prices per operator or per line, so flat fares, Qliners, island services and ferries get the km fare of the concession.

From fare table row to ride price

The matched row of the fare table gives the price of the ride in three parts, which the model stores separately on each OD_R ride (MakeODs.dms):

  • Price_I: the boarding fare (FixedPrice), converted to euro cents (Ct); 0 for a fixed fare per ride.
  • Price_O: the part that depends on the distance, VariablePrice * PT_TravelledDistance; the distance is the accumulated alt_imp (the scheduled route length) in km. On a flex line it also holds the call costs per boarding (Advanced/FlexCallCosts).
  • Price_S: the fixed fare of a row with a Kmprijs and a Ritopslag of 0 (IsFixedFareRide), or the surcharge per ride of a row with a Ritopslag (RideSurcharge); 0 for other rides. With DOVA no ride has a fixed fare or a surcharge.

The model keeps them apart, because the chain joiner treats them differently; see The price of a chain. Within an OV-chip journey it takes the highest Price_I, and it adds Price_O and Price_S for every ride. A fixed fare is therefore in Price_S and not in Price_I: in Price_I, a ferry after a bus would cost only the higher of the two fares. A ride on a fixed fare is not part of an OV-chip journey either, because the ferries and De Lijn are paid with a ticket of their own; the island buses, which are paid with the OV-chip, are treated the same. The chain joiner keeps the last alighting time of the regional ride before it, as across a ride with NS, so a bus after a ferry continues the OV-chip journey of the bus before the ferry when it departs within MaxTransferTimeForBoardingSurcharge of that alighting. A surcharge per ride is in Price_S too, but its ride keeps its boarding fare in Price_I and belongs to the OV-chip journey, so a traveller who transfers to it within MaxTransferTimeForBoardingSurcharge pays the boarding fare once and the surcharge on top.


NS: tariff units

An NS fare depends on the number of tariff units between the boarding station and the alighting station. The model derives them in OVprijzen/NS:

  1. The tariff unit network data/Tarieven_NS/NS_tariefnet_<NS_TariffNetwork_year>.csv is a list of edges between adjacent stations, with their tariff units. The network of 2013 is tariefnet.dat from the NS data of NDOV. It has the joint tariff points of NS: edges of 0 tariff units between stations close to each other, such as Hilversum and Hilversum Sportpark. NS abolished these points with the fares of 2016. In the network of 2016, which follows the tariff unit map of NS, every station pair is therefore at least 1 tariff unit apart (#121). NS_stations.csv lists the station codes, which get their location from the NS station shape.
  2. NS counts the tariff units of a station pair over the classic route, not over the HSL-Zuid. A ride over the HSL between Schiphol Airport and Rotterdam Centraal pays a trip surcharge instead; between Rotterdam and Breda the HSL costs no surcharge. The model therefore leaves the two HSL edges, Hoofddorp to Rotterdam Centraal and Rotterdam Lombardijen to Breda-Prinsenbeek, out of the network (TariffNetwork_ExcludedEdges, IsExcluded).
  3. NS_TariffUnits_matrix/ODMatrix runs a bidirectional impedance_matrix over the remaining network (ConnectedNetwork), with the tariff units as impedance. This gives the number of tariff units for every pair of NS stations. The rest of the model calls this matrix TariffUnitsMatrix.
  4. NS_PricePerTariffUnit reads the price per number of tariff units from data/Tarieven_NS/NS tarieven <NS_prices_year>.csv: for 0 to 200 tariff units, the full fare and the fares with 20% and 40% discount (2nd class, including VAT). A station pair of 200 tariff units or more gets the maximum price.
  5. ModelParameters/Impl/NS_FareClass selects the fare class. In a part of the day in the off-peak hours of NS, it is ModelParameters/Advanced/NS_TariffChoice: the default 'Price_FullFare' (#169), 'Price_20pct_Discount' or 'Price_40pct_Discount'. In a part of the day in the peak hours, where PartsOfDay/NS_OffPeak is FALSE (the morning rush hour), it is the full fare, because NS gives its 40% discount (Dal Voordeel) only off-peak. The signature of the chain cache holds the fare class, so every run in the morning rush hour reads the chain cache with the full fare (#119). The matrix stores the result as Price_ct.

Stops/NS_Stations_rel links the GTFS stops to NS stations (see Create routable network from GTFS files). For each OD_L ride, the model looks up Price_L in TariffUnitsMatrix/Price_ct with the key of the station pair, c_NS_start_end_rel. The chain joiner uses the same lookup to compute the NS through fare.

The NS price tables cover 2013 to 2026. The years 2013 to 2019 come from the official NS fare lists (through the Wayback Machine and the NDOV loket), 2020 to 2022 from the official HRN price tables, and 2023 onwards from the NS fare lists. For 2013 to 2018, the discount columns are exactly 0.8× and 0.6× the full fare (the NS rule). Apart from the joint tariff points, the tariff units only change when stations are added, so the change from year to year is in the price per number of tariff units.

Trip surcharge

Some trains cost a surcharge per ride on top of the NS fare (OVprijzen/NS/TripSurchargeTrains, #3): the Intercity direct and the Eurocity Direct between Schiphol Airport and Rotterdam Centraal, and the ICE on every domestic ride. The model recognises a route of such a product by its route_short_name (OVprijzen/NS/TripSurchargeRouteNames).

The surcharge depends on the ride, not on the station pair. A ride on the Intercity direct or the Eurocity Direct pays it when it passes both Schiphol Airport and Rotterdam Centraal between boarding and alighting, also when it does not stop there. A ride on the ICE always pays it (IsTripSurchargeRide in MakeODs). The amount is the NS surcharge ICE / IC direct of NS_prices_year at full fare (OVprijzen/NS/TripSurcharge/PerYear).

The model stores the surcharge per ride in Price_S, apart from Price_L, so that the chain joiner keeps it when it recomputes Price_L as a through fare. Advanced/IncludeTripSurcharge (default TRUE) switches it on or off.

OVprijzen/TripSurchargeReport checks this part of the fares and writes its results to %LocalDataProjDir%/Output/ritopslag_*. Report is the cheap part: it compares the tariff units of a sample of station pairs with those of NS, and writes the train routes and their trips. Net/Report needs the public transport network: it counts the rides of OD_L and OD_R, among them the rides with a surcharge per product, the rides of NS abroad and the rides on which boarding or alighting is not allowed. Chain/Report tests on the first stop block that the price of every chain with NS is the sum of its parts, the surcharge included.


Price checks

A missing price becomes a null price, which without a check would only come to light after hours of network building. The model therefore checks the fare tables before the run, and the price of every ride once the rides are built. When the run needs no prices (PriceExportsAndIntegrityChecksNeeded is false), the checks pass without testing anything.

Check_PriceCoverage, before the run

ChecksBeforeRunning/Check_PriceCoverage checks the fare tables in seconds, for a variant with public transport and once the GTFS feed is right (#153). batch\UpdateAll.cmd writes it with the other checks to batch\log\checks.txt after the sources step, and stops at a FAIL. In the GUI, MakeUnlinkedData/Step3_PT_Chains/Check_PriceCoverage shows the same line (OVprijzen/PriceCoverage/Status_gated). With Detailed it gives:

Result When Effect
FAIL a row of regional transport has no price or a negative price, or rows with the same identifier have different prices, so that the order of the table decides the price (#173) the run stops
WARN a combination of mode and agency of the regional routes in the feed has no row in FareTable_<year>, a row has a price above the limits, rows with the same identifier have the same price, or a row names a concession area that the shapes of the fare year do not have, so that it matches no ride the run goes on; the line names the combinations, identifiers and rows
PASS every combination has a row, with prices within the limits, every row has an identifier of its own and every concession area is in the shapes the run goes on

A combination without a row gets the DOVA fare of the concession area of its line, so the check reports it and does not refuse it. The limits are in ModelParameters/Advanced: PlausibleMaxFixedPrice (10 euro) for a boarding fare or fixed fare plus the surcharge per ride, PlausibleMaxFixedPrice_Ferry (50 euro) for a ferry, and PlausibleMaxVariablePrice (0.60 euro per km). A higher price is reported and not refused, because some fares are that high, such as the ferry to Terschelling. The detailed method also needs the DOVA table for the rides without a row, so when that table has empty prices or prices above the limits, the check gives the FAIL of the DOVA table. With DOVA, the check tests that all prices of the chosen year are filled and within the limits; the coverage is then complete by construction.

The check counts the routes of the whole feed, also those without trips on Analysis_date, so it is slightly stricter than PT_CheckPrices. The chain step of UpdateAll.cmd tests the DOVA table again before it computes the chains (OVprijzen/PriceCoverage/Table_OK_DOVA).

CoverageReport, on demand

OVprijzen/PriceCoverage/CoverageReport writes the coverage per combination of mode and agency to %LocalDataProjDir%/Output/Checks/Prijsdekking_feed<date>_FareTable<year>.csv as an audit trail.

PT_CheckPrices, after building the OD sets

/ChecksBeforeRunning/PT_CheckPrices tests that every OD_R ride has a filled boarding fare and km fare (Price_IntegrityCheck), from its row or from the DOVA table. It also tests that every ride of NS has a price, that is, that its station pair is in the tariff unit matrix (NS_Station_Price_IntegrityCheck). Without this check, a ride without a price would drop out of every Pareto step unnoticed, because pareto_optimal_eps never keeps a row with a null criterion.

Unpriced/UniqueCombos, when PT_CheckPrices fails

PublicTransport_Prep/Impl/OD_R/impl/Unpriced/UniqueCombos lists every combination of mode, agency, concession and line without a price once, with the number of rides it affects.


Adding a new fare year

For a new GTFS feed year, follow these steps before building the network:

  1. Add the concession areas Concessie_<year>.shp to the source data and extend Concession_years in RegioIndelingen.dms.
  2. For the Detailed method, add FareTable_<year> to OVprijzen.dms: start from the previous year, align the Vervoerder names with agency.txt of the new feed, update the prices and give every row a source in Opmerking. Give a row for one region its concession area, because the column Provincie does not count. For the DOVA method, add a y<year> column to both sheets of the DOVA overview.
  3. Add the NS fares data/Tarieven_NS/NS tarieven <year>.csv in the same format as the existing years, and the trip surcharge of that year to OVprijzen/NS/TripSurcharge/PerYear.
  4. Raise ModelParameters/Advanced/FareData_LastYear, the last year of the DOVA overview and of the NS fares, which bounds DOVA_year and NS_prices_year.
  5. Add the analysis moment with the new feed to ModelParameters/AnalysisMomentPresets, and evaluate /ChecksBeforeRunning/PT_CheckPriceCoverage. Add a row for every combination that its WARN names, unless the DOVA fare of the concession is good enough for it, and check every row with an unusual price, until only the warnings remain that you accept.
  6. Build the network and confirm that /ChecksBeforeRunning/PT_CheckPrices reports PASS.

Clone this wiki locally