English · Magyar

Build log

Mapping the whole UK rail network, including the stations that no longer exist

A vanilla-JavaScript map of every railway line and station in the United Kingdom, built entirely on open data. Here's how it came together, the data problems behind it, and the communication that drove it.

By optional.org.uk · 31 May 2026 · trainmap.co.uk ☕ Buy me a coffee

I wanted a single map that showed the whole British railway network, not just the bits a journey planner cares about, but the heritage lines, the freight-only curves, the named tunnels and viaducts, and the thousands of stations that used to exist and don't anymore. Nothing quite did that, so I built trainmap.co.uk. No app, no sign-up, no account. It loads quickly and you can search it by postcode, station name, three-letter station code, or line name.

There's more to building something like this than the code. The interesting parts are the benefits of being able to visualise the data, the people who use it, and the dozen ways that real-world railway data quietly disagrees with itself. But what actually made the map good was staying in the loop with those people. I shared it early, and let the ones who know the railway best tell me what was wrong. More on that at the end.

An honest review

I've decided to colour code this article so you can see the reality of what decisions were more influenced by me, AI agent assistance or the community.

The colour coding throughout is as follows:

The data problem

Every line and station comes from OpenStreetMap, pulled through the Overpass API. OSM's railway coverage in the UK is excellent, far better than most people assume, because it's been lovingly maintained by people who really care about sidings.

But scoping a query to "the UK" is a trap in itself. A bounding box around Great Britain drags in Dublin, the Isle of Man, and a worrying amount of northern France. I noticed those stray foreign lines; the agent's fix was to ask Overpass for the GB administrative area directly (ISO3166-1=GB) and clip to it. That gives you Northern Ireland (it is a UK map) without the Republic, the IoM, or Calais, and with no hand-tuned latitude fudging that breaks the moment someone tags a new line near a border.

The lines are fetched from Overpass in tiles - a coarse grid of about thirty cells over Great Britain, each asking only for railway ways inside the GB boundary, because a single query for the whole country would simply time out. The fetch is resumable. Any cell already saved and valid is skipped, so a run that dies halfway just fills the gaps. A build step then dedups the ways by OSM id (the cells overlap at their seams), sorts each into one of three buckets (core heavy rail, light rail, or disused) by its railway tag, trims every coordinate to five decimal places (about a metre, a big size saving for no visible loss), and writes out the GeoJSON the map loads, plus a small name-to-bounding-box index so you can search for a line by name. All taken care of by a few hundred lines of Python.

The stations that lied

I started with NaPTAN, the UK's official public-transport stops dataset. It's great for the national network, but it has a quirk; it files a lot of heritage-railway stations under tram or metro categories. So when I filtered to "railway stations," whole preserved lines came out half-empty, which is exactly what a forum member meant when they noticed the Swanage Railway was down to a single station.

Stations moved to OSM as well, which then created a different problem: OSM's station tags are inconsistent, so I had to decide what counts as a heavy-rail or heritage station, and the agent worked out the rules:

Tellingly, the obvious filter, the three-letter ref:crs code, was useless here, because London Underground stations carry TfL "Z"-prefixed codes in exactly the same field.

Heritage lines and the Swanage problem

Preserved lines get their own colour on the map. OSM tags them with railway:preserved or usage=tourism, but again, not consistently. The Swanage Railway's running line, for example, is tagged as an ordinary branch on some segments, so it first showed up as a normal National Rail line, until someone on the forum gently pointed out that I'd "upgraded the branch line to Swanage from heritage to NR."

The fix is a small heuristic that mirrors how a person reads the map: propagate the heritage flag across every segment that shares a line name. If any part of "Swanage Railway" is flagged as preserved, the whole named line goes purple. Crude, but correct far more often than trusting each segment's tags in isolation.

A recurring theme: open data is rarely wrong, but it's frequently inconsistent. Most of the work is writing small, legible rules that reconcile the inconsistency the way a human would.

Lines that snapped straight when you zoomed out

A subtle bug and a good example of the back-and-forth with users. A member reported that the track around Northallerton looked "broken": gentle curves became straight chords at certain zoom levels, and he'd already ruled out his own setup by testing six different browsers. His description (it corrects itself when you zoom into the north end, breaks again when you zoom out, and the south junction won't correct at all) was enough to pin it down.

The cause is that MapLibre simplifies GeoJSON geometry per zoom level by default (Douglas–Peucker), to keep big vector layers cheap. For most data that's a sensible default. For a railway map it's exactly backwards: the precise shape of the track is the entire point. The whole fix was one property on the source: tolerance: 0. Curves stay true at every zoom. The user confirmed it fixed their example and a second one he'd spotted near Low Fell. The fix was one line but more important, was the conversation needed to find the issue.

Removing one bug causes another

That tolerance: 0 is a neat example of how one fix quietly sets up the next. Disabling simplification keeps every vertex at every zoom, which is exactly what you want for accuracy and exactly what you don't want for memory. The whole network is about 28 MB of GeoJSON, roughly 720,000 vertices, and it was all being held in memory at once. On a laptop, fine. On a mid-range phone the browser tab would simply give up, with the dreaded "Aw, Snap," especially once you switched on another heavy layer.

The honest fix isn't to throw detail away; it's to stop loading data you can't see. By moving the geometry-heavy core rail network to vector-tile-based rendering (PMTiles), the geometry is pre-cut into tiles per zoom level at build time, and the browser only fetches the few tiles currently on screen, via HTTP range requests. Memory now tracks the viewport, not the country. It's a single static file on the same bucket (no tile server), and the tiling step (tippecanoe) does the per-zoom simplification properly, so the lines stay honest at every zoom and the chord-snapping bug doesn't come back.

The GeoJSON is still there as a fallback, but tiles are the default now. It's the sort of change you don't notice until it dies on you, which can be really annoying.

The stations that no longer exist

This is my favourite piece. A friend pointed out that Box station, in Wiltshire, was missing. He was right: Box closed in 1965, and it simply isn't in OpenStreetMap at all, neither as a current station nor as a disused one.

So where do you get stations that stopped existing decades ago? Wikidata (CC0). The catch is that its closure data is patchy. Box's Wikidata entry has no closure date at all: the field that should hold one is simply empty, as it is for many closed stations, even though its Wikipedia page will happily tell you it shut on 4 January 1965. (Wikidata does tag it with a vague "decommissioned" status, just without a date.) So filtering on "has a closure date" would miss exactly the stations you're after.

The approach that worked was a subtraction. Pull every UK railway station Wikidata knows about (about 9,600 of them) and then remove the ones that still exist, defined as sitting within 250 m of a current OpenStreetMap station. What's left, just over 6,100 stations, is the set that used to exist and doesn't anymore. Box included. It's now an optional layer you can switch on, drawn as hollow markers, and you can click any of them through to its Wikipedia article.

(A footnote for anyone who might be interested: the Wikidata queries were run against the QLever mirror, because the official query endpoint is rate limited too heavily.)

The joins are interesting

Step back, and the pipeline is really a handful of datasets stitched together, and the catch is that none of them share an identifier. OpenStreetMap supplies the geometry and the live stations, but tags them inconsistently and knows nothing about stations that have closed. The closed ones come from Wikidata, often (as we saw) without even a closure date. Postcode lookups, at search time, come from postcodes.io (ONS data underneath). NaPTAN, the official stops register, was where the stations started, until it turned out to file too many heritage ones under tram and metro, which is exactly why they moved to OSM. Each source is authoritative for something and useless for the rest.

With no shared key to join on, almost every meaningful decision in the script is made by geography or by name instead:

A couple of those are frankly inferences rather than facts: the heritage colour and the "former station" label are derived, not stated by any source. Nothing is invented (every position and name still traces back to OpenStreetMap, Wikidata or ONS), but they're heuristics, and a heuristic is only as good as the cases you test it against.

Which is the real reason the forum mattered. I could write the rules; I had no reliable way of knowing whether they were right: whether the Swanage propagation caught every segment, whether the narrow-gauge test was too tight, whether a "covered" tag at Ardwick should really count as a tunnel. The people on that thread did. Every correction was, in effect, a failing test case for the pipeline, written by someone who knew the territory far better than I could. I only got the reconciliation right because I put it in front of enthusiasts and they told me what was wrong, or could be improved.

Finishing touches

Making it move

For months the map was a picture of infrastructure. The obvious question, asked by almost everyone I showed it to, was: so where are the trains now? The answer is Darwin, National Rail's real-time train information system, the same thing that drives the departure boards at stations, available to anyone through the Rail Data Marketplace after a short registration and approval dance. I'd braced for the old-style feed people write about (a message queue speaking a protocol from 2005, pushing gzipped XML); what I actually got was a modern Kafka stream serving JSON, delivering some 1,300 messages a minute at eleven at night. The consumer, the per-train state tracking, and the engine that turns "passed this timing point at this second" into a dot moving along the right bit of track were built and running the same evening.

Then, the familiar problem: another dataset, another identifier nobody else uses. Darwin names locations by TIPLOC (timing point location), and nothing in the map speaks TIPLOC. The fix came from an unexpected direction: NaPTAN, the stops register the stations had abandoned earlier for filing heritage lines under trams, encodes the TIPLOC inside each rail stop's identifier: the code is literally 9100 followed by the TIPLOC. The dataset that lied about heritage stations repaid its keep as a lookup table, and 2,700 timing points fell into place.

The positions themselves are derived, not tracked. Trains don't broadcast GPS; Darwin reports when a train arrived, departed or is expected at each calling point. The routable graph built for the earlier "can I route between stations?" experiment turned out to be the production engine: place each train between its last and next calling points, in proportion to the clock, along the actual track. A small worker on a server I already had consumes the stream, does exactly that for every active train in the country, and writes one static file every fifteen seconds. The site stays what it always was, files on a shelf; the map simply polls the one file that changes.

Making it look right mattered as much as making it work. A dot that teleports every fifteen seconds reads as broken, so each train also carries the next kilometre of its own route, and the browser slides it along that geometry between updates: trains follow curves and point in their direction of travel, and where the feed reports a formation, the carriages trail behind the arrow as a string of dots, an eight-coach express visibly snaking round a bend while a two-coach stopper stays stubby. Where no data says how long a train is, the map shows no carriages: freight lengths aren't public, so freight gets no invented wagons. The no-inventing rule doesn't bend for prettiness.

Then I posted it to a railway subreddit, and the loop that built the rest of the map started again, at twice the speed. Within a day: the code in the popup turned out to be Darwin's internal schedule ID, not a headcode ("what does L78543 mean?"); the "next stop" was sometimes a station the train passes at 90 mph; a mid-journey train showed only the part of its route the feed had mentioned so far; trains sat invisible at their origin stations; and one report had a train into Moorgate "reversing up the Canonbury Curve when it was nowhere near". Every one of those was fixed within hours, and the deep fix was structural: wiring in the daily timetable files, so every train knows its real headcode, operator and complete journey from the moment it appears, which is also what put "Class 802", linked to Wikipedia, in the popup.

Darwin is the passenger railway, and I wanted the freight too. That lives in Network Rail's own open data feeds: movement reports from a system called TRUST, plus the national schedule including every freight working. A second consumer matches those reports to the day's freight schedules and puts the intermodals, the quarry trains and the light engines on the same map, drawn blunter and dark-edged, with tick-boxes to watch passenger, freight, or both.

The most railway-flavoured upgrade came last. Commenters kept pointing at the train describer feed (the signalling system's own record of trains stepping from berth to berth) while warning, fairly, that the mapping from its tens of thousands of berths to real places is "vast". It mostly isn't any more: Network Rail publish the reference data that ties berth steps to timing points, and a morning of plumbing turned a third of all live signalling events into hard position fixes. Where that coverage exists, trains sit where the signals last saw them rather than where the timetable says they should be, and the popup shows a measured speed ("~125 mph"), with a little "i" explaining exactly how the number is derived, because a figure you can't interrogate is a figure you shouldn't trust.

It is still honest about being an estimate wherever estimating is all it can do, and the page still says so. But watching a late-running amber train thread its way out of a terminus between green ones, at a speed the signals vouch for, on track you know is real. That is the most alive the map has ever felt.

What actually made it good

I shared the map on a UK railway-enthusiast forum early and rough, not looking for anything, just to see if anyone liked it, then spent the next couple of days shipping fixes while the thread was still live. There was no plan and no roadmap; the roadmap was whatever the last person had spotted, and most of it went live within hours of being raised.

None of that is novel; it's the oldest idea in software, the one the Agile Manifesto wrote down before "agile" came to mean a calendar full of meetings: a short, honest loop between the people using a thing and the person able to change it, and a bias to act on what they say now rather than later. Process makes that easy to lose. A public thread full of people who genuinely cared handed it straight back to me, and it's the part of building this I'd most recommend copying, whatever you happen to be making.


Open the map →

trainmap.co.uk is free and ad-free.
If it's useful, please consider

☕ Buy me a coffee