Automating GTFS Version Control with Python Scripts
Download the GTFS ZIP, normalize every .txt file to a canonical byte sequence, compute a single SHA-256 digest, and commit the snapshot to Git with an agency-scoped tag — the tag hash is your version identifier. Re-running the script on an unchanged feed produces the exact same hash and skips the commit, making the workflow fully idempotent.
Why GTFS Feeds Silently Break Without Version Control
Static GTFS feeds update on unpredictable schedules and almost never ship a machine-readable changelog. A stop_times.txt refresh that shifts a dozen departure times by two minutes is indistinguishable from a no-op unless you diff the files. Two real patterns cause the most damage in production:
Silent export drift. Many agencies regenerate feeds nightly from a scheduling tool. Even if no routes changed, the export process adds a new feed_version string or updates a column header’s whitespace. Without normalization before hashing, every nightly download looks like a new version, saturating CI pipelines with unnecessary routing-engine rebuilds.
Structural changes with no semantic bump. feed_info.txt version strings are agency-controlled strings, not enforced semver. An agency can publish v3 twice in a row with completely different stop coordinates. Treating feed_version as a stability guarantee breaks downstream caches.
The approach here treats each feed as a content-addressed artifact: two feeds with identical normalized bytes share one hash; any substantive change produces a new hash and a new Git commit. This integrates cleanly with the agency metadata extraction patterns that validate agency.txt and feed_info.txt before the snapshot is committed.
Production-Ready Version Control Script
The script below handles the full pipeline: streaming download, deterministic normalization, SHA-256 digest, Git init if needed, commit, and annotated tag. Copy it as-is into a scheduler (cron, GitHub Actions, Airflow) — no further edits required except the three constants at the top.
import hashlib
import logging
import subprocess
import tempfile
import zipfile
from datetime import datetime
from pathlib import Path
import pandas as pd
import requests
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(asctime)s: %(message)s")
log = logging.getLogger(__name__)
# --- Configure per agency ---
GTFS_REPO = Path("gtfs-version-control") # local Git repo root
FEED_URL = "https://example-transit-agency.com/gtfs.zip"
AGENCY_ID = "metro-central" # used in tag names
# ----------------------------
HASH_STATE_FILE = GTFS_REPO / ".last_hash"
def stream_and_extract(url: str, extract_dir: Path) -> None:
"""Stream GTFS ZIP from url, extract all .txt files to extract_dir."""
with requests.get(url, stream=True, timeout=60) as resp:
resp.raise_for_status()
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp:
for chunk in resp.iter_content(chunk_size=65_536):
tmp.write(chunk)
tmp_path = Path(tmp.name)
with zipfile.ZipFile(tmp_path, "r") as zf:
txt_members = [m for m in zf.namelist() if m.endswith(".txt")]
if not txt_members:
raise ValueError(f"No .txt files found in archive from {url}")
zf.extractall(extract_dir, members=txt_members)
tmp_path.unlink(missing_ok=True)
log.info("Extracted %d GTFS files to %s", len(txt_members), extract_dir)
def compute_normalized_hash(feed_dir: Path) -> str:
"""
SHA-256 over all .txt files in sorted order, each normalized to a
canonical byte sequence: dtype=str, whitespace stripped, columns and
rows sorted alphabetically by first column.
"""
sha256 = hashlib.sha256()
files_processed = 0
for csv_path in sorted(feed_dir.glob("*.txt")):
try:
df = pd.read_csv(
csv_path,
dtype=str,
encoding="utf-8-sig",
keep_default_na=False,
)
# Strip whitespace from every string cell
df = df.apply(
lambda col: col.str.strip() if col.dtype == "object" else col
)
# Canonical ordering: columns alphabetical, rows by first column
df = df.sort_index(axis=1)
if not df.empty:
df = df.sort_values(by=df.columns[0], ignore_index=True)
canonical = df.to_csv(index=False).encode("utf-8")
sha256.update(csv_path.name.encode()) # include filename in digest
sha256.update(canonical)
files_processed += 1
except Exception as exc:
log.warning("Skipping %s: %s", csv_path.name, exc)
if files_processed == 0:
raise RuntimeError(f"No valid CSV files hashed in {feed_dir}")
digest = sha256.hexdigest()
log.info("Hashed %d files → %s", files_processed, digest[:16] + "…")
return digest
def git_commit_and_tag(repo_dir: Path, feed_hash: str, agency: str) -> str:
"""
Initialise repo if needed, stage all changes, commit, and create an
annotated tag. Returns the tag name so callers can log or store it.
"""
def _run(*args: str) -> None:
subprocess.run(list(args), cwd=repo_dir, check=True, capture_output=True)
if not (repo_dir / ".git").exists():
_run("git", "init", "-b", "main")
_run("git", "config", "user.email", "[email protected]")
_run("git", "config", "user.name", "GTFS Version Bot")
_run("git", "add", ".")
date_str = datetime.now().strftime("%Y%m%d")
short_hash = feed_hash[:12]
tag_name = f"{agency}/{date_str}/{short_hash}"
commit_msg = f"feed({agency}): {date_str} hash={short_hash}"
_run("git", "commit", "-m", commit_msg)
_run("git", "tag", "-a", tag_name, "-m", commit_msg)
log.info("Committed and tagged: %s", tag_name)
return tag_name
def load_previous_hash() -> str:
if HASH_STATE_FILE.exists():
return HASH_STATE_FILE.read_text().strip()
return ""
def save_hash(feed_hash: str) -> None:
HASH_STATE_FILE.write_text(feed_hash)
def main() -> None:
GTFS_REPO.mkdir(exist_ok=True)
extract_dir = GTFS_REPO / "current"
extract_dir.mkdir(exist_ok=True)
log.info("Downloading feed from %s", FEED_URL)
stream_and_extract(FEED_URL, extract_dir)
feed_hash = compute_normalized_hash(extract_dir)
previous = load_previous_hash()
if feed_hash == previous:
log.info("Feed unchanged (hash=%s) — skipping commit.", feed_hash[:12])
return
log.info("Feed changed: %s → %s", previous[:12] or "none", feed_hash[:12])
tag = git_commit_and_tag(GTFS_REPO, feed_hash, AGENCY_ID)
save_hash(feed_hash)
log.info("Version control complete. Tag: %s", tag)
if __name__ == "__main__":
main()
Step-by-Step Walkthrough
stream_and_extract — streams the feed in 64 KB chunks to a NamedTemporaryFile, then extracts only .txt members. Filtering to .txt excludes embedded PDFs, shape files, or vendor metadata that some agencies accidentally bundle into the ZIP. The temp file is deleted immediately after extraction to avoid disk accumulation across runs.
compute_normalized_hash — this is the engine of reproducibility. Three normalization rules ensure identical schedules always hash identically:
dtype=strprevents pandas from silently coercing numeric-looking IDs (00123→123), which would corrupt stop and trip lookups.keep_default_na=Falseprevents empty strings in optional fields (common inagency.txt) from being loaded asNaN, which serializes differently across pandas versions.- Column sort + row sort by first column removes all agency-side ordering variation. Two exports of the same timetable may list routes in different orders; sorted canonical form collapses them to one hash.
The filename is hashed alongside the content (sha256.update(csv_path.name.encode())). This means renaming calendar.txt to calendar_new.txt registers as a structural change rather than being silently absorbed into the digest.
git_commit_and_tag — uses a structured tag format: {agency}/{YYYYMMDD}/{12-char-hash}. This sorts lexicographically by date within each agency namespace, making git tag --list "metro-central/*" a clean chronological audit log. The commit message embeds the same information so git log --oneline is self-documenting.
load_previous_hash / save_hash — a single .last_hash file stores the previous successful digest. This is lighter than shelling out to git describe and works even before the first commit when the repo is empty.
Verification and Output
After a successful run, verify the snapshot with:
import subprocess
from pathlib import Path
GTFS_REPO = Path("gtfs-version-control")
AGENCY_ID = "metro-central"
# List all tags for this agency in chronological order
result = subprocess.run(
["git", "tag", "--list", f"{AGENCY_ID}/*", "--sort=creatordate"],
cwd=GTFS_REPO,
capture_output=True,
text=True,
check=True,
)
tags = result.stdout.strip().splitlines()
print(f"Total snapshots: {len(tags)}")
for tag in tags[-5:]: # show the five most recent
print(" ", tag)
# Confirm the stored hash matches a fresh recalculation
import hashlib
import pandas as pd
def _recompute(feed_dir: Path) -> str:
sha256 = hashlib.sha256()
for p in sorted(feed_dir.glob("*.txt")):
df = pd.read_csv(p, dtype=str, encoding="utf-8-sig", keep_default_na=False)
df = df.apply(lambda c: c.str.strip() if c.dtype == "object" else c)
df = df.sort_index(axis=1)
if not df.empty:
df = df.sort_values(by=df.columns[0], ignore_index=True)
sha256.update(p.name.encode())
sha256.update(df.to_csv(index=False).encode("utf-8"))
return sha256.hexdigest()
stored = (GTFS_REPO / ".last_hash").read_text().strip()
computed = _recompute(GTFS_REPO / "current")
assert stored == computed, f"Hash mismatch: stored={stored[:12]} computed={computed[:12]}"
print("Hash verified:", computed[:12], "…")
Expected output on a stable feed:
Total snapshots: 7
metro-central/20260610/3f8a1c4e7d2b
metro-central/20260617/3f8a1c4e7d2b
metro-central/20260621/3f8a1c4e7d2b
metro-central/20260622/a1d9f3b82c50
metro-central/20260624/a1d9f3b82c50
Hash verified: a1d9f3b82c50 …
Identical hashes on consecutive dates confirm the feed was republished without substantive change. The jump from 3f8a1c4e7d2b to a1d9f3b82c50 on the 22nd marks a real schedule update — the commit diff will show exactly which rows changed.
Gotchas and Edge Cases
-
feed_info.txtmissing or malformed. The GTFS specification marksfeed_info.txtas conditionally required. When it is absent, the hash still works because it covers all.txtfiles present — but downstream systems that parse the tag metadata forfeed_versionwill need a fallback. Log aWARNINGand document the omission in the commit message. -
Agencies that reuse
stop_idvalues across feed revisions. Some operators retire a stop physically, delete its row, then issue the samestop_idto a new location in a future feed. The hash detects this as a change, but a diff ofstops.txtalone will show only a coordinate update — not the semantic break. When memory-efficient processing is in use and only delta records are loaded, this silent identity collision causes spatial mismatches. Always persist the full normalized snapshot, not just the delta. -
Feeds with times past midnight in
stop_times.txt. GTFS encodes overnight services with departure times such as25:30:00. Sortingstop_times.txtrows alphabetically bytrip_id(the first column) groups these correctly, but any downstream code that parses arrival/departure times must handle theHH > 23case explicitly — it is not a data error and must not be stripped during normalization. -
Network retries and partial downloads. A
requests.getthat times out mid-stream can leave a truncated ZIP. The current script will raiseBadZipFileon extraction, which is the correct failure mode. Add a retry decorator (tenacity.retry) for production deployments where transient agency server outages are common; do not silently fall back to the previous snapshot without logging the failure clearly.
Why does a blank feed_version in feed_info.txt affect the hash?
The feed_version cell, even if empty, is part of the feed_info.txt byte content after normalization. If an agency switches from publishing an empty feed_version to publishing "2026-Q2", the canonical CSV changes and the digest changes — which is the correct behaviour. The hash reflects content, not the agency’s intent.
How do I roll back to a previous snapshot?
Use git checkout <tag> -- current/ to restore any historical snapshot into the working directory without altering the main branch history. Recompute the hash from the checked-out files and confirm it matches the hash embedded in the tag name before feeding the snapshot to a routing engine.
Can I extend this to validate the feed before committing?
Yes — insert a validation step between compute_normalized_hash and git_commit_and_tag. For lightweight validation, check that required files (agency.txt, routes.txt, trips.txt, stop_times.txt, stops.txt, calendar.txt) are all present. For full spec compliance, shell out to the official gtfs-validator JAR. Only commit if validation passes; log the validation report as a file alongside the snapshot so the audit trail includes both content hash and compliance status.
Related
- Agency Metadata and Feed Versioning Practices — parent cluster: extracting and validating
agency.txtandfeed_info.txtbefore committing snapshots - Automating Feed Updates with gtfs-kit — detecting new feed publications via HTTP ETag and
Last-Modifiedheaders - Memory-Efficient Processing for Large Feeds — chunked reading and Parquet output for feeds that exceed available RAM
- Python Parsing & Data Normalization — pillar home: library selection, normalization patterns, and pipeline architecture