Back to Blog
/geospatial engineering/8 min read

From Overpass to Timetables: Railway Stations on the EDK Route Map

How I extracted railway stations from OpenStreetMap in 1 degree tiles, reconciled imperfect identifiers, and added reliable timetable and map links to the EDK route map.

Sebastian Tekieli

Chief AI Agents Officer and software engineer

A useful route map should answer the question that appears immediately after choosing a route: how will I get there and return home? For the Ekstremalna Droga Krzyżowa route map, that often means finding the nearest railway station, checking a timetable, and deciding whether the last connection leaves enough margin for a night walk.

I wanted to add those answers without maintaining a private list of stations by hand. The resulting job combined OpenStreetMap extraction, a shared public query service, three different kinds of identifiers, and links into systems that I do not control. The interesting part was not drawing another marker. It was deciding what a marker represents and how much confidence to place in every outbound link.

The first nationwide query was the wrong unit of work

My first attempt asked Overpass for all likely railway stations in Poland. The query was valid, but a valid country-wide query can still be a poor request to send to a shared public service. It timed out or was shed under load, and retrying the same expensive request only repeated the mistake.

The Overpass public-instance guidance explains the constraint clearly: public servers share finite capacity among many users, apply quotas, and may shed load. Overpass QL gives us precise spatial queries, but it does not make an unbounded extraction cheap.

I changed the unit of work to a 1 degree by 1 degree tile. For each tile intersecting Poland I sent the same bounded query, cached the raw response, and recorded the tile coordinates, attempt count, response status, duration, and element count.

[out:json][timeout:60];
(
  nwr["railway"~"^(station|halt)$"](49.0,19.0,50.0,20.0);
  nwr["public_transport"="station"]["train"="yes"](49.0,19.0,50.0,20.0);
);
out center;

This is one tile, not the complete country loop. The production worker generates the grid, skips water-only or out-of-bounds tiles, and limits concurrency. Failed tiles enter exponential backoff with jitter. A cached successful response is reused until the extraction snapshot changes, so a restart does not punish Overpass or discard completed work.

Tiling adds one new obligation: objects near tile edges can appear more than once. I deduplicate first by the stable OpenStreetMap tuple (type, id), never by name. A node, way, and relation can have numerically equal IDs and still be distinct objects, so the type belongs in that key.

An OSM object is not automatically a physical station

The project export contained 6,205 OpenStreetMap objects. That number is a measurement from this extraction, not an official count of active railway stations in Poland.

The distinction matters. OpenStreetMap can represent a railway place with a node or an area, while a public transport relation can group stop positions, platforms, entrances, and station objects. The documentation for railway=station also distinguishes railway=halt and the public transport schema. Existing data can contain nodes, ways, and relations using railway=station, railway=halt, or public_transport=station, sometimes for overlapping parts of one place.

I therefore kept two levels:

  1. The source-object layer preserves OSM type, ID, tags, coordinates or center, tile provenance, and extraction timestamp.
  2. The application-place layer groups likely representations of one passenger location and carries match confidence.

The first layer is reproducible. The second is a product decision and can be recalculated as rules improve. Collapsing both during ingestion would make it impossible to explain whether a surprising marker came from source data or from my resolver.

I excluded obvious non-passenger records when tags supplied that evidence, but I did not infer current service merely from the word station. A mapped object may be closed, freight-only, historical, incorrectly tagged, or simply lack the detail needed for an automatic decision.

Linking Koleo requires a slug, not wishful string replacement

The matching Koleo snapshot held 5,624 station records. Again, this is a project snapshot, not a claim about the current official inventory on Koleo. Koleo station pages use readable path segments, so I needed a deterministic normalizer that handled Polish diacritics, punctuation, repeated separators, empty names, and Unicode input consistently.

export function normalizeStationSlug(name: string, fallbackId: string | number): string {
  const slug = name
    .normalize("NFKD")
    .replace(/\p{M}/gu, "")
    .toLowerCase()
    .replaceAll("ł", "l")
    .replace(/['’]/gu, "")
    .replace(/[^\p{L}\p{N}]+/gu, "-")
    .replace(/^-+|-+$/gu, "");

  const fallback = String(fallbackId)
    .toLowerCase()
    .replace(/[^\p{L}\p{N}]+/gu, "-")
    .replace(/^-+|-+$/gu, "");

  return slug || `station-${fallback || "unknown"}`;
}

export function koleoStationUrl(name: string, fallbackId: string | number): string {
  const slug = normalizeStationSlug(name, fallbackId);
  return new URL(`/dworzec-pkp/${slug}`, "https://koleo.pl/").toString();
}

This function makes a candidate URL, not proof that the destination exists. I validate generated links against the snapshot, store the provider record that justified the link, and keep a fallback search path in the interface. If a provider changes a slug, the route marker should remain useful rather than becoming a confident 404.

Name normalization is only the first pass in entity resolution. My resolver considers normalized official and alternate names, geographic distance, station type, and available reference tags. Exact names far apart do not match. Near coordinates with different but plausible names become review candidates. Ambiguous cases stay unmatched until stronger evidence appears.

UIC-shaped identifiers are evidence, not arithmetic

Railway datasets contain several families of location and company codes. UIC ATLAS describes managed station codes used in operations and transport documents, while the UIC country-code documentation describes codes used for international data exchange.

In this project, a familiar country prefix was a useful matching signal when two records already agreed on name and location. It was not a safe recipe for calculating a missing station identifier. Code families have different scopes, legacy forms exist, leading zeroes matter, and a value that looks structurally correct can still identify the wrong thing.

The matching pipeline enriched 2,922 of the 5,624 Koleo records with a Bilkom/HAFAS identifier. That is 52.0 percent coverage relative to the Koleo snapshot. It does not mean that 52.0 percent of Polish stations are active, complete, or supported by every timetable service.

I use the phrase Bilkom/HAFAS deliberately. Bilkom is the public timetable destination in this workflow, while HAFAS is a family of passenger-information systems. HaCon was founded in 1984, and its current information and ticketing portfolio describes HAFAS products. That history does not imply that today’s HAFAS product or any particular identifier has operated unchanged since 1984.

For every match I store the evidence and method: exact provider ID, normalized name plus distance, compatible UIC signal, or manual override. I also store the source snapshot dates. This makes an enrichment explainable and allows it to expire when a provider dataset changes.

Names are useful labels but weak global locators. Two places can share a name, names change, and punctuation varies. Once a station place has validated coordinates, I generate a Google Maps search URL from latitude and longitude.

export function googleMapsStationUrl(latitude: number, longitude: number): string {
  if (
    !Number.isFinite(latitude) ||
    !Number.isFinite(longitude) ||
    Math.abs(latitude) > 90 ||
    Math.abs(longitude) > 180
  ) {
    throw new RangeError("Invalid station coordinates");
  }

  const url = new URL("https://www.google.com/maps/search/");
  url.searchParams.set("api", "1");
  url.searchParams.set("query", `${latitude.toFixed(6)},${longitude.toFixed(6)}`);
  return url.toString();
}

The official Google Maps URLs documentation requires api=1, accepts a comma-separated latitude and longitude in query, and recommends normal URL encoding. Using URL and URLSearchParams avoids hand-built escaping. A Place ID would be stronger when one has been verified, but coordinates provide a provider-independent fallback and do not require me to guess a listing from a station name.

I retain the original OSM position beside any calculated center. For a way or relation, out center is convenient for display but does not necessarily describe a platform entrance. The UI should say “station area” when precision is coarse rather than promise a doorway.

The repeatable country-scale alternative is a local PBF

Tiled Overpass queries work well for an occasional refresh and are easy to inspect. For frequent country-wide rebuilds, the more considerate and repeatable option is a local extract. Geofabrik publishes a Poland extract, and osmium tags-filter filters nodes, ways, and relations by tags.

curl -L https://download.geofabrik.de/europe/poland-latest.osm.pbf \
  -o poland-latest.osm.pbf

osmium tags-filter poland-latest.osm.pbf \
  nwr/railway=station,halt \
  nwr/public_transport=station \
  -o railway-stations.osm.pbf

osmium export railway-stations.osm.pbf -o railway-stations.geojson

The local filter intentionally produces a broad candidate set. I apply the same passenger-service checks, geometry handling, and entity resolver afterward. This keeps the semantics aligned with the Overpass path instead of pretending that the shell command has solved deduplication.

A PBF snapshot also gives me a durable input hash and makes regression runs independent of public API availability. I still record its retrieval time and source. OpenStreetMap data changes continuously, so reproducibility means knowing which snapshot produced an answer, not expecting all future runs to return the same list.

Production rules that mattered more than the query

The finished pipeline follows a few rules that now seem more important than the initial Overpass syntax:

  • Bound every remote unit of work and cap concurrency.
  • Retry only transient failures, with backoff and jitter.
  • Cache raw responses before transformation.
  • Preserve source identity and provenance before resolving places.
  • Deduplicate OSM objects by type and ID, then resolve physical places separately.
  • Store match confidence and evidence, not just the winning foreign key.
  • Validate outbound timetable links and degrade to search when uncertain.
  • Measure per-tile latency, retries, element counts, cache hits, match coverage, and unresolved clusters.
  • Alert on large count changes rather than silently publishing them.

The map also credits its data source. OpenStreetMap data is distributed under the ODbL, and the OpenStreetMap Foundation’s attribution guidelines explain how attribution should be presented. Attribution is part of the feature, not a footer task to remember after launch.

The practical outcome is simple for a walker: nearby railway places, a timetable route where the match is defensible, and a coordinate-based map link. Underneath that simple interaction is a pipeline designed to admit uncertainty. That is the main lesson I would reuse: open data gives excellent building blocks, but production quality comes from bounded extraction, explicit identity evidence, and honest fallbacks.

Want to discuss this topic?

Let us turn the idea into a practical architecture and implementation plan.

Book a Consultation