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.

The same route drawn as straight lines between stops, as the agency shape, and reconstructed on the street network. stop to stopcuts corners and blocksagency shapefollows the streetsrouted on the networkclose, if the network is rightIllustrative grid; the measured errors on TriMet routes are in the chart below spatialworkflow.io
Stop-to-stop lines, the real path, and a reconstruction from the street network.

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.

Maximum deviation of stop-to-stop lines from TriMet route shapes across 159 route-directions. worst point of each route, stop-to-stop vs realmedian route231 m90th percentile688 mworst (WES rail)2,059 m01000 m2000 mLengths are short too: the real path is a median 8% and up to 55% longer spatialworkflow.io
Half the route-directions stray more than 230 metres from the real path; the worst, a commuter rail line, by 2 km.

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.

Choosing how to get route geometry by what the feed has and the mode. where the route geometry comes fromDoes the trip have a shape_id withpoints?yesuse shapes.txtalways firstno, busroute on a drive networknot the walk oneno, railroute on railway waysroads never followA walk network lacked the Hawthorne Bridge roadway and added 6.7 km to route 14 spatialworkflow.io
The fix depends on the mode, because no single network carries every vehicle.

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.

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.