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,loggingfrom the standard library - Validation library:
pydanticv2 for strict schema enforcement
pip install pydantic
- Assumed feed conditions: The GTFS archive is a valid
.zipfile 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.txttoroutes.txt,trips.txt, andstop_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 | 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 |
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
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
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
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
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:
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 aWARNING, synthesise a version tag from the download timestamp, and continue ingestion rather than failing hard. -
agency_idabsent in single-agency feeds: Technically valid per spec, but breaks theagency_idFK inroutes.txt. Detect withif not row.get("agency_id"): row["agency_id"] = "DEFAULT"and log the synthetic assignment. -
Timezone aliasing:
US/Eastern,EST,EST5EDT, andAmerica/New_Yorkall refer to the same zone but only the last is a valid IANA identifier. Reject the aliases explicitly; do not silently coerce them, becauseUS/Easternis deprecated in somezoneinfoimplementations and will raiseZoneInfoNotFoundErrorat runtime. -
feed_end_dateset 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 iffeed_end_dateis more than 365 days out). -
BOM in
feed_info.txtbut notagency.txt: Both files can independently carry or omit the BOM marker. Always decode withutf-8-sigregardless 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’sstr_strip_whitespace=Trueconfig option handles this, but rawcsv.DictReaderdoes not strip automatically — hence the explicitv.strip()inparse_csv_from_zip. -
Multi-agency feeds with mismatched timezones: The GTFS spec permits multiple agencies with different
agency_timezonevalues in a single feed. Routing engines handle this correctly only ifstop_times.txtdeparture times are expressed in the timezone of the agency that operates each trip. Validate that eachagency_idreferenced intrips.txtmaps 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:
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:
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
- Best Practices for GTFS Agency Metadata — production field-level rules for stable
agency_idvalues and locale codes - Automating GTFS Version Control with Python Scripts — CI/CD patterns for committing feed snapshots and tagging releases
- GTFS Validation Rules and Common Schema Errors — catch structural problems before metadata extraction
- Timezone Handling and Schedule Normalization — how
agency_timezonepropagates through departure-time conversion - Mastering stop_times.txt Relationships — the downstream file most affected by incorrect agency metadata