How it is built
Architecture overview
A transit trip planner answers "how do I get from here to there, leaving at 08:15?" using buses, trams, metros and trains plus walking. Its data comes in two kinds: a static timetable published by each agency as a GTFS feed (stops, routes, trips, stop times, calendars, transfers), and a realtime feed in GTFS Realtime (trip updates, vehicle positions, service alerts) that says what is actually happening now. A street network supplies the walking (and cycling) legs at both ends and between stops. The planner compiles these inputs into an in-memory timetable, finds the journeys, and returns itineraries.
The one idea that makes it work is that transit is not an ordinary road graph. An edge exists only when a vehicle leaves, so the cost of a link depends on the time you arrive at it, and the best answer is usually not one journey but a set: the earliest arrival, the one with fewer transfers, the one with less walking. Planners therefore skip general shortest-path search for the transit part and scan the timetable directly, either route by route in rounds (one round per transfer) or connection by connection in departure order. Road-style speedups such as contraction hierarchies apply to the walking part only. Realtime delays are applied as patches on the compiled timetable, not by rebuilding it.
Parts:
- Feed import and timetable — read GTFS, validate, merge agencies, compile routes, trips and stop times into sorted flat arrays — Radix sort
- Transit router — finds earliest-arrival and Pareto-optimal journeys —
raptor-transit-routing(no page yet), built on BFS and DFS andcalendar-date-arithmetic(no page yet);connection-scan-algorithm(no page yet);time-dependent-shortest-path(no page yet), built on d-ary heap - Street router — walking and cycling legs, access to and from stops, footpath transfers — Dijkstra's algorithm, built on d-ary heap; Contraction hierarchies; Geodesic distance, built on Newton root finding
- Spatial and name lookup — nearby stops and place search — R-tree; Inverted index, built on BM25 ranking, Sorted-list intersection, Varint (LEB128), Delta encoding, Bit packing and PFOR;
fuzzy-string-matching(no page yet) - Realtime overlay — applies delays, cancellations and added trips to the timetable —
protobuf-wire-format(no page yet) - Serving — API, result cache and snapshot swap — W-TinyLFU
GTFS zip (agencies) ─► validate ─► compile: routes, trips, stop_times sorted ─┐
OSM street extract ───────────► street graph + stop snapping (R-tree) ──────┤
▼
GTFS-Realtime (protobuf) ─► realtime overlay ─► timetable snapshot (immutable, + delay patch)
│
query: origin, destination, time ─► access legs ─► transit router (rounds / scan) ─► egress legs
│
Pareto set of itineraries ─► JSON API ─► cache
How the parts fit together
Feed import and timetable
A GTFS feed is a zip of CSV files. The reference describes stops.txt (with location_type and parent_station to model stations, platforms and entrances), routes.txt, trips.txt, stop_times.txt (arrival and departure per stop_sequence), calendar.txt and calendar_dates.txt for service days, frequencies.txt for headway-based service, transfers.txt (with transfer_type and min_transfer_time) and pathways.txt for station interiors. Import joins these into a compact model. Two details matter later: a trip can run past midnight so times may exceed 24:00:00, and the same stop_id values collide across agencies, so each feed gets a namespace.
Import validates before it compiles: unsorted stop_times, trips with a single stop, missing calendars and impossible speeds are rejected or repaired with a log entry, because the router assumes clean input. Radix sort is the practical choice for ordering millions of stop-time rows by (trip, sequence) or connections by departure time, since keys are fixed-size integers. Times in a compiled trip can be stored as differences from the previous stop, which are small numbers.
The compile step produces what the router reads. For a round-based router it groups trips that share the same stop pattern into routes and sorts the trips of a route by departure, so that "the first trip I can board at stop k" is a binary search; the arrays are contiguous per route, and a stop knows the list of routes that serve it. For a scan-based router it produces one flat array of connections (departure stop, arrival stop, departure time, arrival time, trip) sorted by departure time. Both are flat arrays of integers, which fit in memory and can be written to a file and memory-mapped so a restart does not re-parse the feeds. Service calendars are resolved to a day mask or a per-day bit set at compile time, not interpreted per query.
The contract is that a compiled snapshot is immutable. Queries running against it never see a half-applied update; a new feed version or a realtime patch produces a new snapshot (or a delta layer over the old one) that is swapped in atomically.
Transit router
This is the part with the real choices. Three blocks cover it; all three have no page yet.
raptor-transit-routing (no page yet) is RAPTOR (Delling, Pajor and Werneck). Each round k computes the best arrival at every stop using at most k trips: it scans each route that touches a stop improved in the previous round, once, riding the earliest boardable trip, and then relaxes footpath transfers. The result is a Pareto set over arrival time and number of transfers, one entry per round. The paper states that it is not Dijkstra-based, looks at each route at most once per round, needs no preprocessing, and so works in fully dynamic scenarios; it extends to further criteria such as fare zones or transfer reliability. Its rounds are breadth-first in the number of trips (BFS and DFS), and its service-day logic needs calendar-date-arithmetic (no page yet): time zones, daylight-saving changes and times past 24:00.
connection-scan-algorithm (no page yet) is CSA (Dibbelt, Pajor, Strasser, Wagner). It keeps an earliest-arrival label per stop and scans the departure-sorted connection array once from the query time: a connection is usable if you can be at its departure stop by its departure time, and then it may improve its arrival stop. It is very simple, needs no graph, and the profile variant answers "all good departures in a time window".
time-dependent-shortest-path (no page yet) is the graph formulation: nodes for stops (or events), edges whose travel time depends on the departure time, and a FIFO property (leaving later never gets you there earlier). It lets you use Dijkstra's algorithm and its speedups (the priority queue is a d-ary heap), but the timetable-native algorithms avoid its cost of modelling transfers in the graph.
Which one: RAPTOR when you want several criteria and an easy realtime update (the routes are arrays; a delay shifts times in one route); CSA when you want the smallest code path for earliest arrival or profile queries and the connection array already fits. Both can be parallelised across queries; RAPTOR also within a query across routes. Both consume a source stop set (with walking times) and a target set, which is the contract with the street router.
Street router
Walking from the origin to the first stop, from the last stop to the destination, and between nearby stops makes up much of a real journey. The street graph comes from OpenStreetMap; Dijkstra's algorithm (with a d-ary heap as its queue) from the origin, limited to a maximum walking time, gives the access legs to nearby stops. For repeated queries over a large area, Contraction hierarchies answer street distances with two small searches over a preprocessed graph. A timetable-level footpath table (from transfers.txt or computed between stops within a radius) is what the transit router uses between rounds. Geodesic distance (its ellipsoidal variants iterate with Newton root finding) gives the straight-line distance used for the cheap first filter (candidate stops) and as a fallback when no street path is available. The general road-routing design is in maps and routing.
Spatial and name lookup
Finding stops near a coordinate is a window or nearest-neighbour search over a few hundred thousand points: an R-tree (or a grid) built at import time. Finding the origin from text ("Hauptbahnhof", "Main St") is a name search: an Inverted index over stop and place names (posting lists intersected with Sorted-list intersection, stored with Varint (LEB128), Delta encoding and Bit packing and PFOR, ranked by BM25 ranking), with fuzzy-string-matching (no page yet) for typos and transliterations; address-level geocoding is a separate service, see geocoder.
Realtime overlay
GTFS Realtime messages are Protocol Buffers (protobuf-wire-format, no page yet). A feed message carries entities of three kinds: TripUpdate (predicted arrival and departure per stop through StopTimeUpdate, with a ScheduleRelationship such as scheduled, skipped or no data), VehiclePosition and Alert. A feed's incrementality is either full dataset or differential, so the consumer must handle both. The overlay matches each update to a compiled trip (by trip id and start date), copies the trip's times, applies the delays, and stores the patched trip in a layer consulted before the base timetable. Cancellations and skipped stops mark the trip or stop as unusable for boarding. An added trip needs a new route entry. Propagation is the sharp edge: a delay reported at one stop must be extended forward to later stops where the feed is silent, with a documented rule, or the planner will show impossible connections.
Serving
Queries are small and bursty at commute hours. Many origins and times repeat (home stations, popular destinations), so a result cache with an admission policy such as W-TinyLFU protects the router from the hot head of the distribution; entries are keyed by snapshot version so a realtime patch invalidates them cleanly.
Data and control flow
Static load (once per feed update):
- Download and validate each agency's GTFS zip; namespace ids; resolve calendars to service days.
- Group trips into routes by stop pattern, sort trips by time, compute footpaths between stops; build the connection array if CSA is used.
- Build the stop R-tree and the name index, snap stops to the street graph, and write the snapshot.
- Swap the snapshot into the serving process; drop cache entries keyed to the old version.
A journey query:
- Resolve text to coordinates; find nearby stops with the R-tree; run the street search to get walking times to each (access legs), and the same at the destination (egress legs).
- Run the transit router from the departure time over the snapshot plus the realtime layer: round 0 starts from the access stops, each round boards the earliest trips and relaxes footpaths.
- Collect the arrivals at the egress stops plus egress walking time; keep the Pareto set over arrival time, transfers (and walking if asked).
- Reconstruct each itinerary from the stored parent pointers, attach platform and line names, and return it.
A realtime update (every few seconds to a minute, set by the agency's feed):
- Fetch the protobuf message; decode it; for each
TripUpdate, find the compiled trip. - Apply delays, skips and cancellations into the patch layer; propagate delays forward by the documented rule.
- Publish the new patch version; new queries use it, in-flight queries finish on the old one.
Scaling
In the order the limits appear:
- Query latency in a single process. The router reads flat arrays, so the first levers are layout (contiguous routes, small integer types, times as deltas) and pruning rules; stay in memory.
- Concurrent queries. Queries are independent and read-only on an immutable snapshot, so run them on all cores and scale out by adding identical replicas.
- Large areas. A country or continent multiplies stops and trips; reduce the work per query with a target-pruning bound, a limit on rounds, a bound on walking, and a time window for profile searches. Order of: more rounds than a handful of transfers rarely helps users.
- Street search cost. Long access legs or cycling make plain Dijkstra expensive; use contraction hierarchies or a stop-to-stop footpath table with a radius limit.
- Realtime volume. Many agencies mean many feeds; process feeds in parallel into separate patch shards and merge by trip id, and reject feeds with stale timestamps.
- Snapshot build time. Re-importing all feeds is slow, so recompile only changed feeds and reuse the compiled form of the others.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Wrong service day | no trips found on holidays or weekends | calendar.txt / calendar_dates.txt exceptions not applied | resolve exceptions at compile time; test with known holidays |
| Times past midnight | night trips missing or on the wrong day | times above 24:00:00 treated as invalid or wrapped | keep times as seconds since the service day's start |
| Impossible transfer | itinerary shows a 1-minute change between distant platforms | missing transfers.txt / pathway data, default transfer time too small | per-station minimum transfer times, use pathways, buffer on long station walks |
| Phantom delay | late vehicle shown but later stops unchanged | delay not propagated to stops the feed omits | propagation rule; mark stale predictions and expire them |
| Cancelled trip still offered | user is sent to a trip that does not run | realtime Canceled/skipped status ignored or trip matching fails | match by trip id and start date; log unmatched updates and alarm on a rising rate |
| Duplicate stops across agencies | transfers between two agencies' stops not found | same station modelled as separate stops | station merge by distance and name; explicit transfer rows |
| Cache after update | stale routes served after a feed change | cache not keyed by snapshot | include snapshot/patch version in the key |
| Walking leg unrealistic | route through a motorway or private land | street graph snap to the wrong edge | snap with direction and access tags; verify with sample walks |
| Router too slow on long searches | timeouts on cross-country trips | unbounded rounds, no pruning | round limit, arrival-time bound from best known result, profile windows |
| Memory blowup | process killed on a large region | whole feeds held as text objects | compile to flat arrays, map the file, drop unused columns such as shapes |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| OpenTripPlanner | multi-modal trip planner; imports GTFS, GTFS Realtime and OpenStreetMap | opentripplanner/OpenTripPlanner | LGPL-3.0-or-later | Java; the repository's licence text is the GNU Lesser GPL v3 or later |
| MOTIS | multimodal routing, geocoding and map tiles | motis-project/motis | MIT | C++ |
| nigiri | MOTIS's timetable routing core ("Next Generation Routing") | motis-project/nigiri | MIT | C++ library |
| R5 | Conveyal's routing engine for multimodal networks, focused on public transit | conveyal/r5 | MIT | built for accessibility and scenario analysis |
| GTFS / GTFS Realtime | the data specifications | google/transit | Apache-2.0 | reference protos and docs |
| Transitous | community-run, free public transit routing service | — | — | transitous.org; only what the site says |
Sources
- GTFS Schedule reference. gtfs.org/documentation/schedule/reference
- GTFS Realtime reference (TripUpdate, VehiclePosition, Alert; incrementality). gtfs.org/documentation/realtime/reference
- D. Delling, T. Pajor, R. F. Werneck, Round-Based Public Transit Routing, Transportation Science 2015. Microsoft Research publication page
- J. Dibbelt, T. Pajor, B. Strasser, D. Wagner, Connection Scan Algorithm, ACM Journal of Experimental Algorithmics 2018. arxiv.org/abs/1703.05997
- OpenTripPlanner documentation. docs.opentripplanner.org
- MOTIS project. github.com/motis-project/motis