Agency Metadata and Feed Versioning Practices

When mobility platforms ingest, transform, or distribute GTFS datasets, every route, trip, and stop eventually traces back to two small but load-bearing files: agency.txt and feed_info.txt. Sloppy handling of these files — missing agency_id values, unvalidated timezone strings, no version tags — does not fail loudly at parse time. Instead, the damage surfaces later: routing engines apply the wrong schedule window, fare attribution silently diverges across operators, and compliance audits cannot reconstruct which feed version was live on a given date. This page covers the schema constraints, Python validation patterns, and versioning strategies that keep agency metadata trustworthy across the full feed lifecycle.

Prerequisites

Before implementing the patterns below, confirm your environment meets these baseline requirements:

  • GTFS files required: agency.txt (mandatory), feed_info.txt (strongly recommended)
  • Python 3.9+ with csv, zipfile, hashlib, datetime, logging from the standard library
  • Validation library: pydantic v2 for strict schema enforcement
bash
pip install pydantic
  • Assumed feed conditions: The GTFS archive is a valid .zip file with UTF-8 (or UTF-8-BOM) encoded CSVs. For feeds with structural integrity issues, run GTFS schema validation before attempting metadata extraction.
  • Assumed knowledge: Familiarity with the GTFS static feed structure and how file-level foreign keys connect agency.txt to routes.txt, trips.txt, and stop_times.txt.

Concept and Spec Background

agency.txt — the ownership anchor

agency.txt defines the legal operating entity behind every route. The GTFS spec marks agency_id as conditionally required: optional when the feed contains exactly one agency, but mandatory when two or more are present. In practice, omitting agency_id even in single-agency feeds breaks any downstream merge with another operator’s data, so production pipelines should treat it as required unconditionally.

Field Required Type Notes
agency_id Conditional String Unique identifier; used as FK in routes.txt
agency_name Yes String Human-readable; used in UI attribution
agency_url Yes URL Official site or open data portal
agency_timezone Yes IANA string e.g. America/New_York; governs all departure times
agency_lang No ISO 639-1 e.g. en, fr; used by accessibility tools
agency_phone No String E.164 or local format
agency_fare_url No URL Fare information page
agency_email No Email Rider contact address

agency_timezone is the most operationally critical field. GTFS departure times in stop_times.txt are wall-clock local times, not UTC offsets. Routing engines use agency_timezone to resolve them. A wrong value — or a valid-looking but misspelled IANA string such as US/Eastern instead of America/New_York — produces silently incorrect departures. Applying timezone normalization before storage prevents DST-related trip duplication across schedule transitions.

feed_info.txt — provenance and validity window

feed_info.txt records the publisher, language, and the date range during which the feed is valid. Routing engines use feed_start_date and feed_end_date to decide whether a feed is current; data lakes use feed_version to differentiate successive releases.

Field Required Type Notes
feed_publisher_name Yes String Organisation publishing the feed
feed_publisher_url Yes URL Publisher’s official site
feed_lang Yes ISO 639-1 Language of the feed content
feed_start_date Recommended YYYYMMDD First day this feed is valid
feed_end_date Recommended YYYYMMDD Last day this feed is valid
feed_version Recommended String Arbitrary version tag; see versioning schemes below
default_lang No ISO 639-1 Language when rider locale is unknown
GTFS metadata pipeline Flow diagram showing five stages: fetch archive, extract metadata files, validate schema, compute checksum, store versioned artifact. Fetch agency GTFS .zip archive Extract agency.txt feed_info.txt Validate Pydantic schema timezone + dates Checksum SHA-256 of raw archive Store versioned artifact + lineage record Reject + log error (missing field / bad TZ) on failure

Step-by-step Implementation

The steps below build a production-ready extraction module. Each step is runnable in isolation; combine them for a complete ingestion pipeline.

Step 1 — Define strict Pydantic models

python
import re
from typing import Optional
import pydantic
from pydantic import Field, field_validator

VALID_IANA_TIMEZONES: set[str] = set()
try:
    import zoneinfo
    VALID_IANA_TIMEZONES = zoneinfo.available_timezones()
except ImportError:
    pass  # Python 3.8 fallback: skip IANA check

class AgencyMetadata(pydantic.BaseModel):
    model_config = pydantic.ConfigDict(str_strip_whitespace=True)

    agency_id: str
    agency_name: str
    agency_url: pydantic.AnyHttpUrl
    agency_timezone: str
    agency_lang: str = Field(pattern=r"^[a-z]{2,3}$")
    agency_phone: Optional[str] = None
    agency_fare_url: Optional[pydantic.AnyHttpUrl] = None
    agency_email: Optional[pydantic.EmailStr] = None

    @field_validator("agency_timezone")
    @classmethod
    def validate_iana_timezone(cls, v: str) -> str:
        if VALID_IANA_TIMEZONES and v not in VALID_IANA_TIMEZONES:
            raise ValueError(
                f"agency_timezone '{v}' is not a valid IANA timezone. "
                "Common mistake: use 'America/New_York', not 'US/Eastern'."
            )
        return v

class FeedInfoMetadata(pydantic.BaseModel):
    model_config = pydantic.ConfigDict(str_strip_whitespace=True)

    feed_publisher_name: str
    feed_publisher_url: pydantic.AnyHttpUrl
    feed_lang: str = Field(pattern=r"^[a-z]{2,3}$")
    feed_start_date: str = Field(pattern=r"^\d{8}$")
    feed_end_date: str = Field(pattern=r"^\d{8}$")
    feed_version: str
    default_lang: Optional[str] = Field(default=None, pattern=r"^[a-z]{2,3}$")

field_validator on agency_timezone cross-references the zoneinfo standard library’s timezone list, which catches the extremely common US/Eastern / America/New_York confusion that breaks timezone normalization downstream.

Step 2 — Compute a deterministic checksum

python
import hashlib
from pathlib import Path

def compute_sha256(file_path: Path) -> str:
    """Stream the raw archive in 8 kB chunks to avoid loading it fully into memory."""
    sha256 = hashlib.sha256()
    with open(file_path, "rb") as fh:
        for chunk in iter(lambda: fh.read(8192), b""):
            sha256.update(chunk)
    return sha256.hexdigest()

Always hash the raw .zip before extraction, not the extracted CSVs. Middleware layers (antivirus, CDN, proxy) sometimes silently normalise line endings in text files after download; hashing the archive catches that drift.

Step 3 — Extract and validate both metadata files

python
import csv
import zipfile
import logging
from datetime import datetime, timezone
from pydantic import ValidationError

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("gtfs.metadata")

REQUIRED_FILES = {"agency.txt", "feed_info.txt"}

def parse_csv_from_zip(zf: zipfile.ZipFile, filename: str) -> list[dict]:
    """Read a UTF-8-BOM-safe CSV from an open ZipFile and return rows as dicts."""
    with zf.open(filename) as raw:
        text = raw.read().decode("utf-8-sig")
    reader = csv.DictReader(text.splitlines(), skipinitialspace=True)
    return [
        {k.strip(): v.strip() for k, v in row.items() if k}
        for row in reader
    ]

def extract_and_validate_feed(feed_path: Path) -> dict:
    """
    Extract agency.txt and feed_info.txt from a GTFS zip, validate both,
    and return a structured result with a SHA-256 checksum.
    """
    if not feed_path.exists():
        raise FileNotFoundError(f"GTFS archive not found: {feed_path}")

    archive_hash = compute_sha256(feed_path)

    with zipfile.ZipFile(feed_path, "r") as zf:
        present = set(zf.namelist())
        missing = REQUIRED_FILES - present
        if missing:
            raise ValueError(
                f"GTFS archive is missing required metadata files: {sorted(missing)}"
            )

        # --- agency.txt ---
        raw_agencies = parse_csv_from_zip(zf, "agency.txt")
        agencies: list[AgencyMetadata] = []
        for i, row in enumerate(raw_agencies):
            try:
                agencies.append(AgencyMetadata(**row))
            except ValidationError as exc:
                log.error("agency.txt row %d failed validation: %s", i, exc)
                raise

        # --- feed_info.txt ---
        raw_feed_info = parse_csv_from_zip(zf, "feed_info.txt")
        try:
            feed_info = FeedInfoMetadata(**raw_feed_info[0])
        except (IndexError, ValidationError) as exc:
            log.error("feed_info.txt validation failed: %s", exc)
            raise

    # Validate date window
    start_dt = datetime.strptime(feed_info.feed_start_date, "%Y%m%d")
    end_dt = datetime.strptime(feed_info.feed_end_date, "%Y%m%d")
    if start_dt > end_dt:
        raise ValueError(
            f"feed_start_date ({feed_info.feed_start_date}) is after "
            f"feed_end_date ({feed_info.feed_end_date})."
        )

    log.info(
        "Feed validated: version=%s agencies=%d hash=%s...",
        feed_info.feed_version,
        len(agencies),
        archive_hash[:12],
    )

    return {
        "agencies": [a.model_dump() for a in agencies],
        "feed_info": feed_info.model_dump(),
        "checksum": archive_hash,
        "validated_at": datetime.now(timezone.utc).isoformat(),
    }

The utf-8-sig codec silently strips the UTF-8 BOM that many Windows-based agency export tools prepend to CSV files. Without it, the BOM becomes the first character of the header row name, producing a field called agency_id that never matches your Pydantic model.

Step 4 — Deterministic version tagging

python
from datetime import date

def build_version_tag(
    feed_info: FeedInfoMetadata,
    archive_hash: str,
    scheme: str = "date",
) -> str:
    """
    Return a canonical version tag for artifact registry storage.

    scheme='date'  → '20240315'   (aligns with feed_start_date)
    scheme='hash'  → 'sha256:abc123...'  (cryptographic immutability)
    scheme='feed'  → passthrough of feed_version from feed_info.txt
    """
    if scheme == "date":
        return feed_info.feed_start_date
    if scheme == "hash":
        return f"sha256:{archive_hash}"
    if scheme == "feed":
        return feed_info.feed_version
    raise ValueError(f"Unknown versioning scheme: {scheme!r}")

Choosing the right scheme depends on pipeline cadence. Date-based tags work well for weekly refresh cycles. Content-hash tags suit CI/CD pipelines where idempotent reruns must produce the same identifier for the same payload. For automated pipeline orchestration that commits version tags back to a repository, see Automating GTFS Version Control with Python Scripts.

Validation and Verification

After running extract_and_validate_feed, confirm the result before writing to an artifact store:

python
def verify_extraction_result(result: dict, feed_path: Path) -> None:
    agencies = result["agencies"]
    feed_info = result["feed_info"]

    # At least one agency must be present
    assert len(agencies) >= 1, "No agencies extracted from agency.txt"

    # Every agency must have a non-empty agency_id
    for agency in agencies:
        assert agency["agency_id"], f"Empty agency_id found: {agency}"

    # agency_id values must be unique within the feed
    ids = [a["agency_id"] for a in agencies]
    assert len(ids) == len(set(ids)), f"Duplicate agency_id values: {ids}"

    # Date window must be valid and non-expired relative to today
    today = date.today().strftime("%Y%m%d")
    assert feed_info["feed_end_date"] >= today, (
        f"Feed has expired: feed_end_date={feed_info['feed_end_date']}"
    )

    # Checksum must match the file on disk (guards against race conditions)
    on_disk_hash = compute_sha256(feed_path)
    assert result["checksum"] == on_disk_hash, (
        "Checksum mismatch — archive was modified between extraction and verification"
    )

    print(
        f"Verification passed: {len(agencies)} agency record(s), "
        f"valid through {feed_info['feed_end_date']}"
    )

Run verify_extraction_result as a gate step in CI before the feed is promoted to production. The checksum re-verification catches the rare but real case where a download manager or antivirus tool modifies the archive between ingestion and storage.

Failure Modes and Edge Cases

  • Missing feed_info.txt: Many smaller agencies omit this file entirely. Your pipeline should warn and fall back gracefully — log a WARNING, synthesise a version tag from the download timestamp, and continue ingestion rather than failing hard.

  • agency_id absent in single-agency feeds: Technically valid per spec, but breaks the agency_id FK in routes.txt. Detect with if not row.get("agency_id"): row["agency_id"] = "DEFAULT" and log the synthetic assignment.

  • Timezone aliasing: US/Eastern, EST, EST5EDT, and America/New_York all refer to the same zone but only the last is a valid IANA identifier. Reject the aliases explicitly; do not silently coerce them, because US/Eastern is deprecated in some zoneinfo implementations and will raise ZoneInfoNotFoundError at runtime.

  • feed_end_date set far in the future: Some agencies publish feeds with end dates 10+ years out as a lazy default. Your pipeline should log a warning but not reject the feed, since consuming apps may legitimately want to plan far ahead. Set an internal alert threshold (e.g. warn if feed_end_date is more than 365 days out).

  • BOM in feed_info.txt but not agency.txt: Both files can independently carry or omit the BOM marker. Always decode with utf-8-sig regardless of whether you expect a BOM — the codec is a no-op when no BOM is present.

  • Trailing whitespace in field values: Some export tools emit "America/New_York " (note trailing space). Pydantic’s str_strip_whitespace=True config option handles this, but raw csv.DictReader does not strip automatically — hence the explicit v.strip() in parse_csv_from_zip.

  • Multi-agency feeds with mismatched timezones: The GTFS spec permits multiple agencies with different agency_timezone values in a single feed. Routing engines handle this correctly only if stop_times.txt departure times are expressed in the timezone of the agency that operates each trip. Validate that each agency_id referenced in trips.txt maps back to a valid agency record and that the corresponding timezone is consistent within that operator’s service.

Performance and Scale Notes

For large multi-agency aggregations or real-time ingestion loops, the metadata files themselves are small (rarely more than a few kilobytes). The performance concern is the cost of validating and checksumming hundreds of feeds per hour.

Batch processing strategy:

python
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

def process_feed_batch(
    feed_paths: list[Path],
    max_workers: int = 8,
) -> list[dict]:
    """
    Validate and checksum a batch of GTFS archives in parallel.
    Returns a list of result dicts; logs and skips feeds that fail validation.
    """
    results = []
    with ThreadPoolExecutor(max_workers=max_workers) as pool:
        future_to_path = {
            pool.submit(extract_and_validate_feed, p): p
            for p in feed_paths
        }
        for future in as_completed(future_to_path):
            path = future_to_path[future]
            try:
                result = future.result()
                results.append(result)
            except Exception as exc:
                log.error("Feed %s failed: %s", path.name, exc)
    return results

For feeds larger than 500 MB (uncommon for metadata files alone, but possible with large combined archives), stream the checksum computation in chunks as shown in compute_sha256 above — never load the full bytes into memory. If you are also processing stop_times.txt or shapes.txt from the same archive, see memory-efficient processing for large feeds for chunked pandas reading strategies.

When storing validated metadata in a data lake, write Parquet rather than CSV for the structured output:

python
import pandas as pd

def results_to_parquet(results: list[dict], output_path: Path) -> None:
    records = []
    for r in results:
        for agency in r["agencies"]:
            records.append({
                "agency_id": agency["agency_id"],
                "agency_name": agency["agency_name"],
                "agency_timezone": agency["agency_timezone"],
                "feed_version": r["feed_info"]["feed_version"],
                "feed_start_date": r["feed_info"]["feed_start_date"],
                "feed_end_date": r["feed_info"]["feed_end_date"],
                "archive_sha256": r["checksum"],
                "validated_at": r["validated_at"],
            })
    df = pd.DataFrame(records).astype({
        "agency_id": "string",
        "agency_name": "string",
        "agency_timezone": "string",
        "feed_version": "string",
        "archive_sha256": "string",
        "validated_at": "string",
    })
    df.to_parquet(output_path, index=False, compression="snappy")
    log.info("Wrote %d agency records to %s", len(df), output_path)

Storing archive_sha256 alongside every agency record means you can always trace a routing anomaly back to the exact feed archive that produced it — a prerequisite for GTFS compliance audits and for multi-agency batch processing strategies.

FAQ

What happens if agency_id is missing from agency.txt?

When the feed contains a single agency, agency_id is technically optional per the GTFS spec, but production pipelines should enforce its presence anyway. Missing agency_id breaks foreign-key joins between routes.txt and agency.txt in multi-agency merges, and causes silent attribution failures in routing engines. Synthesise a default value, log the assignment, and flag the feed for manual review.

How should feed_version be formatted?

The GTFS spec leaves feed_version as a free-form string. For production pipelines, date-based tags (YYYYMMDD) align naturally with feed_start_date, while content-hash tags (sha256:abc123) provide cryptographic immutability. Pick one scheme and apply it consistently across all feeds in your registry. Mixing schemes in the same registry makes historical diffs unreliable.

Why does agency_timezone matter for routing engines?

GTFS departure times in stop_times.txt are expressed in local wall-clock time, not UTC. The agency_timezone field tells routing engines and ETL pipelines which IANA timezone to use when converting those times. An incorrect or missing timezone produces wrong departures — especially across DST boundaries. See converting local transit times to UTC in Python for the full conversion pattern.


Related

Up: GTFS Feed Architecture Fundamentals | Home