GTFS routes draw as straight lines between stops
On this page
Problem statement
You draw the routes from a GTFS feed and they come out as zig-zags: straight segments from stop to stop, cutting across blocks, rivers and parks. Route lengths computed from them are too short, and anything that depends on the path — which streets a route uses, which buildings it passes, how far a bus travels — is wrong.
This happens when the feed has no shapes.txt, or when trips have no shape_id, so the only geometry available is the sequence of stop locations. Shapes are optional in GTFS, and small operators often leave them out. On TriMet's feed, which does have shapes, the stop-to-stop lines are a median 8% shorter than the real paths and stray up to 2 km from them.
Quick answer
Use shapes.txt if it exists. If it does not, route between consecutive stops on the street network and join the pieces:
import networkx as nx
import osmnx as ox
from shapely.geometry import LineString
from shapely.ops import linemerge
def route_between_stops(G, stop_xy):
"""Stitch shortest paths between consecutive stops into one line (projected G)."""
nodes = ox.distance.nearest_nodes(G, [x for x, _ in stop_xy], [y for _, y in stop_xy])
coords = []
for a, b in zip(nodes[:-1], nodes[1:]):
if a == b:
continue
path = nx.shortest_path(G, a, b, weight="length")
coords.extend((G.nodes[n]["x"], G.nodes[n]["y"]) for n in path)
return LineString(coords)
Route on a network that matches the vehicle — the drive network for buses, not the walk network with its footpaths — or the line will take shortcuts no bus can.
Use shapes if they exist, otherwise route on streets
1. Check whether shapes exist
Look for shapes.txt and for non-blank shape_id values in trips.txt. Some feeds have the file but leave shape_id empty on some trips.
2. If they exist, use them
Build a line per shape_id ordered by shape_pt_sequence, as in the route-mapping guide.
3. If not, get a street network for the vehicle
OSMnx with network_type="drive" for buses; rail needs the railway ways from OpenStreetMap instead, which a road network will never follow.
4. Route between consecutive stops
Snap each stop, take the shortest path between each consecutive pair, and stitch the paths together. One-way streets matter: use a directed graph for buses.
5. Check the reconstruction
Compare its length with the straight-line version and look at it. A reconstruction that is much longer than the straight-line path usually took a detour around a missing link or a turn restriction.
6. Write the shapes back
Add the reconstructed lines as shapes.txt rows with new shape_ids and set them on the trips, so every later user of the feed benefits.
How wrong straight lines are, and a rebuilt route
The data used on this page
TriMet's GTFS feed for Portland, Oregon, downloaded from developer.trimet.org and unzipped to trimet_gtfs/. TriMet republishes it every few weeks, so counts of trips and stop times move between versions; the setup prints the version the numbers on this page came from. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import io
import os
import zipfile
import pandas as pd
import requests
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-gtfs-routes-draw-straight-lines-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
if not os.path.exists("trimet_gtfs"):
feed = requests.get("https://developer.trimet.org/schedule/gtfs.zip", timeout=600)
zipfile.ZipFile(io.BytesIO(feed.content)).extractall("trimet_gtfs")
info = pd.read_csv("trimet_gtfs/feed_info.txt", dtype=str).iloc[0]
print(f"TriMet feed {info.feed_version}, valid {info.feed_start_date} to {info.feed_end_date}")
TriMet feed 20260927-20261007-0900, valid 20260927 to 20270227
The street networks are walking and driving networks for inner Portland from OpenStreetMap, built with OSMnx and saved as GraphML. overpass-api.de refuses connections from here, so the query goes to the maps.mail.ru mirror of the same API; OpenStreetMap is edited continuously, so node counts move a little between downloads.
import os
import osmnx as ox
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
PDX = (-122.75, 45.46, -122.56, 45.57) # west, south, east, north: inner Portland
if not os.path.exists("pdx_walk.graphml"):
ox.save_graphml(ox.graph_from_bbox(PDX, network_type="walk"), "pdx_walk.graphml")
G_raw = ox.load_graphml("pdx_walk.graphml")
print(f"walk network: {len(G_raw):,} nodes, {G_raw.number_of_edges():,} edges")
if not os.path.exists("pdx_drive.graphml"):
ox.save_graphml(ox.graph_from_bbox(PDX, network_type="drive"), "pdx_drive.graphml")
walk network: 51,035 nodes, 148,310 edges
Example 1 — how wrong the straight lines are, on a feed that has shapes
import numpy as np
import pandas as pd
import geopandas as gpd
FEED = "trimet_gtfs"
trips = pd.read_csv(f"{FEED}/trips.txt", dtype=str)
stops = pd.read_csv(f"{FEED}/stops.txt", dtype=str)
st = pd.read_csv(f"{FEED}/stop_times.txt", usecols=["trip_id", "stop_id", "stop_sequence"],
dtype={"trip_id": str, "stop_id": str})
pts = pd.read_csv(f"{FEED}/shapes.txt", dtype={"shape_id": str}).sort_values(["shape_id", "shape_pt_sequence"])
shape_lines = pts.groupby("shape_id")[["shape_pt_lon", "shape_pt_lat"]].apply(lambda g: LineString(g.to_numpy()))
shapes = gpd.GeoSeries(shape_lines, crs=4326).to_crs(32610)
main = (trips.groupby(["route_id", "direction_id", "shape_id"]).size().rename("n").reset_index()
.sort_values("n", ascending=False).drop_duplicates(["route_id", "direction_id"]))
rep = trips[trips.shape_id.isin(main.shape_id)].drop_duplicates("shape_id")[["trip_id", "shape_id", "route_id"]]
seq = (st[st.trip_id.isin(rep.trip_id)].merge(stops[["stop_id", "stop_lat", "stop_lon"]], on="stop_id")
.sort_values(["trip_id", "stop_sequence"]))
straight = seq.groupby("trip_id")[["stop_lon", "stop_lat"]].apply(
lambda g: LineString(g.astype(float).to_numpy()))
straight = gpd.GeoSeries(straight, crs=4326).to_crs(32610)
cmp = rep.set_index("trip_id")
cmp["ratio"] = [shapes[s].length / straight[t].length for t, s in zip(cmp.index, cmp.shape_id)]
cmp["off_m"] = [shapes[s].hausdorff_distance(straight[t]) for t, s in zip(cmp.index, cmp.shape_id)]
print("real length / straight length:", cmp.ratio.quantile([0.5, 0.9, 1]).round(2).to_dict())
print("max deviation (m):", cmp.off_m.quantile([0.5, 0.9, 1]).round(0).to_dict())
real length / straight length: {0.5: 1.08, 0.9: 1.22, 1.0: 1.55}
max deviation (m): {0.5: 231.0, 0.9: 688.0, 1.0: 2059.0}
Example 2 — reconstruct one route from its stops, on two networks
from shapely.geometry import Point
from shapely.ops import substring
# a) the drivable-looking ways of a network built for walking
Gw = ox.project_graph(ox.load_graphml("pdx_walk.graphml"), to_crs=32610)
DRIVABLE = {"trunk", "primary", "secondary", "tertiary", "unclassified", "residential",
"primary_link", "secondary_link", "tertiary_link", "living_street", "busway"}
keep = [(u, v, k) for u, v, k, d in Gw.edges(keys=True, data=True)
if (d["highway"] if isinstance(d["highway"], str) else d["highway"][0]) in DRIVABLE]
G_from_walk = ox.truncate.largest_component(Gw.edge_subgraph(keep).copy()) # the subset is patchy
# b) a network built for vehicles: motor-vehicle ways, one-way streets kept directed
G_drive = ox.project_graph(ox.load_graphml("pdx_drive.graphml"), to_crs=32610)
trip = rep[rep.route_id == "14"].trip_id.iloc[0] # 14-Hawthorne
calls = seq[seq.trip_id == trip]
xy = gpd.GeoSeries(gpd.points_from_xy(calls.stop_lon.astype(float), calls.stop_lat.astype(float)),
crs=4326).to_crs(32610)
xmin, ymin, xmax, ymax = ox.graph_to_gdfs(G_drive, edges=False).total_bounds
inside = [(p.x, p.y) for p in xy if xmin + 500 < p.x < xmax - 500 and ymin + 500 < p.y < ymax - 500]
real = shapes[rep.set_index("trip_id").shape_id[trip]]
real_part = substring(real, real.project(Point(inside[0])), real.project(Point(inside[-1])))
print(f"real shape {real_part.length / 1000:5.2f} km ({len(inside)} stops)")
for name, line in [("straight lines", LineString(inside)),
("walk-network roads", route_between_stops(G_from_walk, inside)),
("drive network", route_between_stops(G_drive, inside))]:
print(f"{name:19} {line.length / 1000:5.2f} km max deviation {real_part.hausdorff_distance(line):5.0f} m")
real shape 10.86 km (33 stops)
straight lines 10.78 km max deviation 44 m
walk-network roads 17.53 km max deviation 1971 m
drive network 11.02 km max deviation 83 m
Hawthorne is a straight street, so here the straight lines are close — 44 metres at worst. The interesting row is the middle one. The roadway of the Hawthorne Bridge is tagged foot=no in OpenStreetMap, because pedestrians use separately mapped sidewalks, so a network built for walking does not contain it at all. Routed on the road-type ways of that network, the bus crosses the Willamette on a different bridge and the route grows by 6.7 km. The drive network has the bridge, and its reconstruction stays within 83 metres of the real shape.
Why feeds lack shapes
Why feeds lack shapes
shapes.txt is optional in the GTFS specification. Producing it needs a GIS step that schedule software does not always include, so small operators and some older exports leave it out, and some producers fill shape_id for most trips but not for extras and short-turns.
Why straight lines are shorter
A straight segment is the shortest path between two points; the street route between the same two stops can only be as long or longer. On a dense grid the difference is small — stops are close and streets are straight — but where a route loops, crosses a river at a bridge or runs on a freeway, the gap becomes large.
Why a reconstruction needs the right network
A walking network includes footpaths, steps and cut-throughs that no bus can use, and the shortest path will happily take them. A driving network includes one-way restrictions and excludes pedestrian ways. Rail and ferries need their own networks, because no road network follows a railway across a river.
Loops, express segments and turn rules
- Loops that return to the first stop need the direction of travel preserved; routing can take the loop backwards.
- Express segments with no stops for kilometres are the least reliable to reconstruct.
- Turn restrictions are in OSM as relations and are not applied by a plain shortest path.
- Tolerance. A reconstruction within 50–100 m of the true path is good enough for mapping, not for measuring bus lanes.
- Keep the stop-to-stop version for distance-along-route calculations if no shape exists; label it as such.
- Tell the agency. Missing shapes are often fixed when someone asks.
Internal links
- How to map transit routes and stops from GTFS shapes — when shapes exist
- GTFS explained — where shapes sit in the feed
- How to build a street network graph in Python with OSMnx — getting a drive network
- How to calculate the shortest path along a street network — the routing step
- How to map-match a GPS track to a street network — the same idea for dense points
- Street network routing returns absurd distances — when the reconstruction detours
FAQ
Why are my GTFS routes straight lines?
The feed has no shapes.txt, or the trips have no shape_id, so the only geometry is the stop sequence.
How do I get real route shapes?
Use shapes.txt if present; otherwise route between consecutive stops on a street network suited to the vehicle.
Which network should I route on?
The drive network for buses, railway ways for trains. Not the walk network.
How far off are straight lines?
On TriMet's routes, a median of about 230 m at the worst point of each route, and 8% shorter overall.
Can I add the shapes back into the feed?
Yes: write them as shapes.txt rows and set shape_id on the trips.
Are reconstructed shapes accurate?
Usually within tens of metres on ordinary streets; less so on express sections and around turn restrictions.