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.


GTFS Version Control Pipeline Five-stage pipeline showing: Download ZIP, Normalize CSVs, SHA-256 Hash, Diff vs previous hash, then either Git commit and tag (if changed) or skip (if identical). Download GTFS ZIP Normalize CSVs SHA-256 Hash Diff vs prev hash Git commit + tag Skip (identical) changed same

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.

python
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:

  1. dtype=str prevents pandas from silently coercing numeric-looking IDs (00123123), which would corrupt stop and trip lookups.
  2. keep_default_na=False prevents empty strings in optional fields (common in agency.txt) from being loaded as NaN, which serializes differently across pandas versions.
  3. 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:

python
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:

text
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.txt missing or malformed. The GTFS specification marks feed_info.txt as conditionally required. When it is absent, the hash still works because it covers all .txt files present — but downstream systems that parse the tag metadata for feed_version will need a fallback. Log a WARNING and document the omission in the commit message.

  • Agencies that reuse stop_id values across feed revisions. Some operators retire a stop physically, delete its row, then issue the same stop_id to a new location in a future feed. The hash detects this as a change, but a diff of stops.txt alone 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 as 25:30:00. Sorting stop_times.txt rows alphabetically by trip_id (the first column) groups these correctly, but any downstream code that parses arrival/departure times must handle the HH > 23 case explicitly — it is not a data error and must not be stripped during normalization.

  • Network retries and partial downloads. A requests.get that times out mid-stream can leave a truncated ZIP. The current script will raise BadZipFile on 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.