Mastering stops.txt and stop_times.txt Relationships
The stops.txt and stop_times.txt tables form the spatial-temporal backbone of every GTFS dataset. Every routing engine, schedule visualizer, and real-time prediction model depends on a clean, referentially intact join between these two files. When that join breaks — due to orphaned foreign keys, sequence violations, or silent type coercion — failures cascade silently: trips disappear from network graphs, vehicles are matched to phantom locations, and schedule APIs return stale or incorrect data.
The pages in this section provide the Python-specific patterns for loading, joining, validating, and remediating these files at production scale. For the underlying GTFS specification context, see Mastering stops.txt and stop_times.txt Relationships (Architecture) in the architecture fundamentals section.
Prerequisites
- Python 3.9+ with
pandas >= 2.0,numpy, andpyarrow - A raw GTFS feed (ZIP or directory) containing
stops.txtandstop_times.txt pip install pandas numpy pyarrow- Basic familiarity with pandas merge operations and groupby aggregations
Key Spec Constraints
| Constraint | Rule | Consequence of violation |
|---|---|---|
stop_id FK in stop_times.txt |
Must exist in stops.txt |
Orphaned schedule record; routing graph edge missing |
stop_sequence per trip_id |
Positive integers, monotonically increasing | Routing order undefined |
arrival_time / departure_time |
Non-decreasing within a trip; arrival ≤ departure |
Schedule timeline breaks |
stop_lat / stop_lon |
WGS84: lat in [−90, 90], lon in [−180, 180] | Distance calculations corrupt |
Times > 24:00:00 |
Allowed — represents service past midnight on same service day | Standard parsers reject these |
Step 1 — Ingest with Explicit dtypes
The most common silent failure in GTFS pipelines is stop_id coercion. When pandas auto-infers stop_id as numeric, zero-padded identifiers like "0042" lose their leading zeros and every downstream join silently returns zero matches.
import pandas as pd
import numpy as np
stops = pd.read_csv(
"stops.txt",
dtype={"stop_id": str},
usecols=["stop_id", "stop_lat", "stop_lon", "stop_name", "location_type"],
)
stop_times = pd.read_csv(
"stop_times.txt",
dtype={"stop_id": str, "trip_id": str},
usecols=["trip_id", "stop_id", "stop_sequence", "arrival_time", "departure_time"],
)
print(f"Loaded {len(stops):,} stops and {len(stop_times):,} stop_times records.")
Step 2 — Orphan Detection via Left Join
A left join from stop_times onto stops isolates records referencing missing stop_id values. This happens when agencies delete or rename stops mid-cycle without regenerating the full feed export.
merged = stop_times.merge(
stops[["stop_id", "stop_lat", "stop_lon"]],
on="stop_id",
how="left",
indicator=True,
)
orphans = merged[merged["_merge"] == "left_only"].copy()
orphan_rate = len(orphans) / len(stop_times)
print(f"Orphaned stop_times: {len(orphans):,} ({orphan_rate:.2%})")
if orphan_rate > 0.005:
raise RuntimeError(
f"Orphan rate {orphan_rate:.2%} exceeds 0.5% threshold — halting ingestion."
)
# Stops present in stops.txt but never scheduled
used_stop_ids = stop_times["stop_id"].unique()
unused_stops = stops[~stops["stop_id"].isin(used_stop_ids)]
print(f"Unused stops (no scheduled visits): {len(unused_stops):,}")
Step 3 — Sequence and Temporal Validation
GTFS permits times exceeding 24:00:00 for overnight service. Standard datetime.strptime() rejects these values — use pd.to_timedelta() instead, which models elapsed duration rather than wall-clock time.
def validate_trip(group: pd.DataFrame) -> pd.Series:
seq = group["stop_sequence"].reset_index(drop=True)
arr = pd.to_timedelta(group["arrival_time"].reset_index(drop=True))
dep = pd.to_timedelta(group["departure_time"].reset_index(drop=True))
seq_monotonic = seq.is_monotonic_increasing
dwell_valid = (arr <= dep).all()
continuity_valid = bool((arr.shift(-1).dropna() >= dep.iloc[:-1]).all())
return pd.Series({
"seq_monotonic": seq_monotonic,
"dwell_valid": dwell_valid,
"continuity_valid": continuity_valid,
})
trip_validation = stop_times.groupby("trip_id").apply(validate_trip)
invalid_trips = trip_validation[~trip_validation.all(axis=1)]
print(f"Trips failing validation: {len(invalid_trips):,}")
Step 4 — Coordinate Bounds and Duplicate Detection
lat_ok = stops["stop_lat"].between(-90, 90)
lon_ok = stops["stop_lon"].between(-180, 180)
coord_violations = stops[~(lat_ok & lon_ok)]
print(f"Coordinate bound violations: {len(coord_violations):,}")
# Near-duplicate detection (stops within ~10 m of each other)
stops["lat_r"] = stops["stop_lat"].round(4)
stops["lon_r"] = stops["stop_lon"].round(4)
near_dupes = stops[stops.duplicated(subset=["lat_r", "lon_r"], keep=False)]
print(f"Near-duplicate stop coordinates: {len(near_dupes):,}")
Common Failure Modes
-
Leading-zero truncation on
stop_id. Always usedtype=str— never let pandas infer numeric types for ID columns. -
Frequency-governed trips in
stop_times.txt. When atrip_idappears infrequencies.txt, itsstop_times.txtrows define relative offsets, not absolute departure times. Validate these trips with a separate code path. See Handling Frequency-Based vs Timetable Schedules. -
Parent station
stop_idreuse. Some feeds use the samestop_idfor a parent station (location_type=1) and a child platform (location_type=0). This is a spec violation but appears in real feeds. Deduplicate by sortinglocation_typeascending and keeping the first occurrence. -
Missing
stop_times.txtrecords for a trip. Atrip_idintrips.txtwith zero rows instop_times.txtproduces an invisible trip. For remediation patterns, see Fixing Missing stop_times.txt Records in Python.
Validation and Verification
A stop-and-stop-time pipeline is easy to write and easy to write wrongly, because a feed that fails every check below still loads without complaint. These five assertions are the ones worth running on every ingest, in roughly this order — each is cheap, and each catches a defect the ones above it cannot see.
def verify_stop_relationships(stops, stop_times) -> None:
# 1. Every call resolves to a defined place.
orphans = set(stop_times["stop_id"]) - set(stops["stop_id"])
assert not orphans, f"{len(orphans)} stop_id(s) called at but never defined"
# 2. Sequence numbers strictly increase within each trip. Gaps are legal;
# repeats and reversals are not, because they leave the order undefined.
ordered = stop_times.sort_values(["trip_id", "stop_sequence"])
rising = ordered.groupby("trip_id")["stop_sequence"].apply(
lambda s: s.is_monotonic_increasing and s.is_unique)
assert rising.all(), f"{int((~rising).sum())} trip(s) have unusable stop_sequence"
# 3. Times never run backwards within a trip.
monotonic = ordered.groupby("trip_id")["departure_s"].apply(
lambda s: s.dropna().is_monotonic_increasing)
assert monotonic.all(), f"{int((~monotonic).sum())} trip(s) run backwards in time"
# 4. Every trip calls at least twice — a trip with one call goes nowhere.
calls = stop_times.groupby("trip_id").size()
assert (calls >= 2).all(), f"{int((calls < 2).sum())} trip(s) have a single call"
# 5. Coordinates are inside the WGS84 envelope and not at the null island.
bad = stops[(stops["stop_lat"].abs() > 90) | (stops["stop_lon"].abs() > 180)
| ((stops["stop_lat"] == 0) & (stops["stop_lon"] == 0))]
assert bad.empty, f"{len(bad)} stop(s) with impossible coordinates"
The fifth check deserves a note. A coordinate of exactly zero, zero is syntactically valid and lands in the Gulf of Guinea, several thousand kilometres from any transit network. It is how a missing coordinate is written when a producer fills nulls with zeros, and it wrecks any bounding box, any nearest-stop query and any spatial join computed over the feed. Treating it as a defect rather than as data is almost always right.
The second check is worth separating from the third. A trip whose stop_sequence values
repeat has no defined call order at all, so the time check that follows it is testing an
arbitrary ordering. Running them in this order means the diagnostic names the real problem
rather than a symptom of it.
Performance and Scale Notes
stop_times.txt holds roughly 87% of a feed’s rows — around 1.8 million for a metropolitan
network against 31,000 trips and 8,900 stops — so every decision about it dominates the
pipeline’s cost. Three of them account for nearly all the difference.
Read fewer columns. The orphan, sequence and time checks need five columns:
trip_id, stop_id, stop_sequence, arrival_time and departure_time. Passing usecols
at read time means the rest are never parsed or allocated, which on a wide feed roughly halves
both time and memory before anything else is tried.
Type the identifiers deliberately. Read as text — which is
mandatory for correctness,
not merely tidy — trip_id and stop_id are the two most expensive columns in the table. Cast
them to category after validation and the frame typically shrinks by half again, because
each distinct value is stored once rather than per row.
Do the joins with indexes, not with scans. The orphan check is a set membership test
against a few thousand identifiers, which pandas performs in well under a second across 1.8
million rows. Written as a per-row lookup inside apply, the same check takes minutes — and
that difference is the usual reason integrity checking gets quietly dropped from a pipeline.
Beyond about a gigabyte the right move stops being optimisation and starts being a change of approach: chunked reading where the computation reduces, and a columnar store where it does not. Converting once to partitioned Parquet turns every subsequent question about the table into a scan of the few columns it needs, which is why any pipeline that queries the same feed more than a handful of times should do it.
Frequently Asked Questions
Do gaps in stop_sequence mean the feed is broken?
No. Only the relative order matters, so a trip whose calls are numbered 1, 2, 5, 6 is perfectly valid — agencies commonly leave gaps so a stop can be inserted later without renumbering. What is not valid is a repeated or decreasing value, because that leaves the call order undefined.
Why does my join to stops.txt return no rows at all?
Almost always a dtype mismatch. If stop_id is read as an integer in one frame and as text
in the other, no value ever compares equal and the join returns nothing. Read every
identifier as text in both frames, and the problem disappears permanently.
Can two rows in stop_times.txt share a trip_id and stop_id?
Yes, and it is normal on a loop route that calls at the same stop twice. The composite key is
trip_id plus stop_sequence, never trip_id plus stop_id, which is exactly why a
realtime join on the two-column key
doubles every prediction
on a circular service.
What should I do with a trip that has no stop_times rows at all?
Remove the trip and report it. A trip with no calls cannot be presented to a rider, cannot be
matched by a realtime feed, and inflates every count taken from trips.txt. Inventing a
schedule for it is worse than dropping it.
Why the composite key is not negotiable
Almost every defect on this page traces back to one modelling fact: a row in stop_times.txt
is identified by trip_id plus stop_sequence, and never by trip_id plus stop_id. A
circular route calls at the same stop twice on the same trip, so the second key is not unique
— and a join built on it silently produces two rows where one belongs, doubling every count
taken downstream and every prediction served from it.
The consequence runs further than joins. Deduplication, interpolation between timepoints,
delay propagation and block reconstruction all operate on a trip’s ordered calls, and the
order comes from stop_sequence alone. A pipeline that sorts by stop_id, by arrival time,
or by file order will be correct on the majority of trips and wrong on exactly the ones — loops,
short turns, out-and-back services — where the ordering actually carries information.
Reading a trip as a sequence rather than a set
The practical habit that follows is to load a trip’s calls the same way every time: filter to
the trip, sort by stop_sequence, and only then compute. Written once as a helper and used
everywhere, it removes the whole class of ordering bugs, and it makes the cost visible — the
sort is over a few dozen rows per trip rather than over the whole table, provided the filter
comes first.
The same discipline applies in reverse when writing. Any process that emits stop_times rows
— an expansion of frequency-based
service,
an interpolation that fills untimed calls, a merge across agencies — must assign
stop_sequence values that preserve the intended order and stay unique within the trip.
Renumbering from one on output is tempting and usually harmless, but it breaks any external
reference to the original numbering, including a realtime feed that identifies calls by
sequence position. Preserving the published numbers, gaps and all, is the safer default.
Failure Modes and Edge Cases
stop_idvalues that differ only in case.STOP_1andstop_1are distinct identifiers to a join and identical to a human reading the file. Feeds that mix them usually intend one stop, and the result is half the calls attached to a stop that has no coordinates.- Platforms published as separate stops with near-identical names. Common on rail feeds without a parent-station hierarchy. Nothing is invalid, and every interchange between them has to be inferred from proximity rather than known, which is the case station modelling exists to fix.
- Calls with a blank
arrival_timeanddeparture_time. Legal at a non-timepoint stop, where the time is meant to be interpolated between the surrounding timepoints. Treating the blank as zero places the call at the start of the service day; dropping the row loses a stop the vehicle genuinely serves. - A
stop_sequencethat starts at zero on some trips and one on others. Both are valid — only the order matters — but code that assumes a particular first value will silently mis- identify the origin stop on half the feed. - Stops shared between agencies in a merged feed. Two operators calling at the same
physical platform will have published two different
stop_idvalues for it. Merging without reconciling them produces two stops at the same coordinates, and every interchange between the two networks disappears. - Very long trips. A long-distance service can have several hundred calls, and any per-trip computation that builds an intermediate structure per call will use far more memory on those trips than the average suggests. Size buffers by the longest trip, not the median.
In This Section
- Fixing Missing stop_times.txt Records in Python — orphaned trip detection, sequence gap analysis, foreign key validation, and spec-compliant patching
Related
- Mastering stops.txt and stop_times.txt Relationships (Architecture) — spec reference and in-depth join logic
- Handling Frequency-Based vs Timetable Schedules — distinguishing frequency trips from timetable trips before validating stop_times
- GTFS Validation Rules and Common Schema Errors — full referential integrity checks across all GTFS files
- Memory-Efficient Processing for Large Feeds — chunked reading and Parquet output for metro-scale feeds
- Timezone Handling and Schedule Normalization — converting stop_times departure values to UTC