gtfslint
Fast, pure-Python GTFS Schedule validator and quality report that runs in CI.
gtfslint checks a GTFS Schedule feed
(a .zip archive or a folder) against the specification, reports every problem with a stable
rule code, a severity and the exact file/line/field, and computes quality metrics such as
service span, trips per day and headways. It is a pip install away, has no compiled
dependencies, and validates a feed with one million stop_times.txt rows in about seven
seconds.
The canonical MobilityData GTFS Validator
is a Java application. gtfslint does not replace it. It covers the checks that catch most
broken feeds and fits where a Python tool works better: pre-commit hooks, GitHub Actions,
data pipelines and notebooks. When a rule matches a MobilityData notice, the notice name is
listed next to it, so you can compare the two reports.
Installation
pip install gtfslint-cli
The distribution on PyPI is called gtfslint-cli (the name gtfslint is too close to an
existing project); the import name and the command are both gtfslint.
Python 3.9 or newer. The only dependencies are tomli on Python < 3.11 and tzdata on
Windows.
Command line
gtfslint check feed.zip # text report, exit code 1 on errors
gtfslint check feed.zip --html report.html # also write a self-contained HTML report
gtfslint check feed/ --format json > report.json
gtfslint check feed.zip --format github # GitHub Actions annotations
gtfslint check feed.zip --select GL2,GL4 --ignore GL408 --fail-on warning
gtfslint stats feed.zip --html stats.html # quality metrics
gtfslint rules # list every rule
check options:
| Option | Meaning |
|---|---|
-f, --format |
text (default), json, markdown, html, github |
-o, --output FILE |
write the --format output to a file instead of stdout |
--json / --markdown / --html FILE |
write extra reports in those formats |
--select, --ignore |
comma-separated rule codes, code prefixes (GL4) or names (invalid_url) |
--fail-on |
lowest severity that fails: error (default), warning, info, never |
--reference-date |
date used by the expiration rules (default: today) |
--max-notices-per-rule |
notices kept per rule (default 1000; counts are always complete) |
--stats |
add feed statistics to the report |
-q, --quiet |
print only the summary line |
--config FILE, --no-config |
choose or skip the pyproject.toml configuration |
Exit codes: 0 when no notice reaches --fail-on, 1 when at least one does, 2 for
usage errors, invalid configuration or an unreadable feed.
GitHub Actions
- run: pip install gtfslint-cli
- run: gtfslint check gtfs/ --format github --html gtfs-report.html
- uses: actions/upload-artifact@v4
if: always()
with:
name: gtfs-report
path: gtfs-report.html
When the feed is a folder, annotations point to the exact file and line. When it is a zip archive, they are attached to the archive and the message gives the location inside it.
pre-commit
repos:
- repo: https://github.com/YusufEminoglu/gtfslint_sdk
rev: v0.1.0
hooks:
- id: gtfslint
The hook runs gtfslint check on every committed .zip feed.
Configuration
Settings are read from the nearest pyproject.toml. Command-line options override them.
[tool.gtfslint]
select = ["ALL"] # codes, prefixes or names
ignore = ["GL401", "GL606"]
fail-on = "warning" # error | warning | info | never
max-notices-per-rule = 1000
reference-date = "2026-11-08" # default: today
min-stop-distance-m = 2.0 # GL502 threshold
max-parent-distance-m = 1000.0 # GL504 threshold
max-stop-distance-km = 50 # GL503 threshold for every route type (default: per type)
speed-limits-kmh = { 3 = 120, 0 = 80 } # GL408 limits per basic route_type
Python API
import gtfslint
report = gtfslint.validate("feed.zip") # -> gtfslint.Report
print(report.errors, report.warnings, report.infos, report.ok)
for notice in report.notices:
print(notice.code, notice.severity.label, notice.location, notice.message)
report.write("report.html") # format inferred from the suffix
data = report.to_dict() # also to_json/to_text/to_markdown/to_github
report.exit_code(fail_on="warning")
report = gtfslint.validate(
"feed/",
select=["GL2", "GL4"],
ignore=["fast_travel_between_consecutive_stops"],
reference_date="2026-11-08",
with_stats=True,
)
st = gtfslint.stats("feed.zip") # -> gtfslint.FeedStats
st.trips_per_day["2026-11-09"], st.busiest_date, st.headways[0].median_headway_min
cfg = gtfslint.load_config("pyproject.toml") # -> gtfslint.Config
gtfslint.validate("feed.zip", config=cfg)
gtfslint.RULES maps every code to a Rule, which has code, name, severity,
description, reference and mobilitydata. gtfslint.FeedError is raised when the
path does not exist or is not a readable zip archive.
Quality metrics
gtfslint stats and gtfslint.stats() report:
- Counts: agencies, stops, stations, entrances, generic nodes, boarding areas, routes, trips, stop_times, shapes, services, frequency-based trips.
- Route types: number of routes per type. Extended types are listed separately.
- Service span: first and last service date, the number of active dates, and the first
departure and last arrival times. Times past
24:00:00are kept as they are. - Trips per day: trip count for each service date, after combining
calendar.txtandcalendar_dates.txt. Trips infrequencies.txtare expanded into their departures. The busiest date is reported separately. - Headways per route and direction: minimum, median, mean and maximum headway in
minutes, and the number of departures, on the busiest date or on
--date. Vehicle-hours for the same date. - Coverage: bounding box of stops and shape points (lon/lat).
- Shapes: percentage of trips whose
shape_idresolves to a shape. - Completeness: percentage of stops with
wheelchair_boarding, trips withwheelchair_accessible, trips withbikes_allowed(value 1 or 2), and stops with coordinates.
Rules
Every rule has a code (GL + three digits), a snake_case name, a default severity and a
link to the part of the GTFS Schedule reference it enforces. The last column gives the
matching MobilityData validator notice,
when there is one. Row numbers are physical line numbers, with the header on line 1.
| Code | Name | Severity | Description | MobilityData notice |
|---|---|---|---|---|
| GL001 | missing_required_file |
error | A file required by the specification (agency, routes, trips, stop_times, stops) is missing. | missing_required_file |
| GL002 | missing_calendar_and_calendar_date_files |
error | Neither calendar.txt nor calendar_dates.txt is present; at least one is required. | missing_calendar_and_calendar_date_files |
| GL003 | empty_file |
error | A required file is empty or has a header but no records. | empty_file |
| GL004 | missing_required_column |
error | A required column is absent from the header of a file. | missing_required_column |
| GL005 | duplicated_column |
error | The same column name appears more than once in a header. | duplicated_column |
| GL006 | empty_column_name |
error | A header contains an empty column name. | empty_column_name |
| GL007 | invalid_row_length |
error | A row has a different number of fields than the header. | invalid_row_length |
| GL008 | csv_parsing_failed |
error | The file could not be parsed as UTF-8 CSV; the rest of the file was skipped. | csv_parsing_failed |
| GL009 | unknown_file |
info | The feed contains a file that is not part of the GTFS Schedule specification. | unknown_file |
| GL010 | unknown_column |
info | A file has a column that is not defined by the specification. | unknown_column |
| GL011 | leading_or_trailing_whitespaces |
warning | A value or column name has leading or trailing whitespace (it is trimmed before the other checks). | leading_or_trailing_whitespaces |
| GL012 | duplicate_key |
error | Two records share the same primary key (e.g. stop_id, or trip_id + stop_sequence). | duplicate_key |
| GL013 | missing_required_field |
error | A required or conditionally required field is empty. | missing_required_field |
| GL014 | route_both_short_and_long_name_missing |
error | A route has neither route_short_name nor route_long_name. | route_both_short_and_long_name_missing |
| GL015 | empty_row |
warning | A row contains only empty fields. | empty_row |
| GL016 | missing_recommended_file |
warning | feed_info.txt is missing; it is recommended so consumers know the publisher and validity window. | missing_recommended_file |
| GL101 | invalid_color |
error | A color is not a six-digit hexadecimal value such as FFFFFF. | invalid_color |
| GL102 | invalid_url |
error | A URL is not a fully qualified http:// or https:// URL. | invalid_url |
| GL103 | invalid_timezone |
error | A timezone is not a valid IANA TZ database name such as Europe/Istanbul. | invalid_timezone |
| GL104 | invalid_date |
error | A date is not a valid calendar date in YYYYMMDD format. | invalid_date |
| GL105 | invalid_time |
error | A time is not in H:MM:SS or HH:MM:SS format (hours above 24 are allowed). | invalid_time |
| GL106 | invalid_integer |
error | A value that must be an integer (or enumeration) could not be parsed. | invalid_integer |
| GL107 | invalid_float |
error | A value that must be a number could not be parsed. | invalid_float |
| GL108 | unexpected_enum_value |
warning | An enumeration field has a value not listed by the specification. | unexpected_enum_value |
| GL109 | number_out_of_range |
error | A number is outside its allowed range (latitude, longitude, non-negative or positive fields). | number_out_of_range |
| GL110 | invalid_email |
error | An e-mail address is malformed. | invalid_email |
| GL111 | invalid_language_code |
error | A language code is not a well-formed IETF BCP 47 tag. | invalid_language_code |
| GL112 | invalid_currency |
error | A currency code is not an ISO 4217 alphabetical code. | invalid_currency |
| GL113 | point_near_origin |
error | A stop or shape point lies at (0, 0) ('null island'), usually a missing coordinate. | point_near_origin |
| GL114 | inconsistent_agency_timezone |
error | Agencies in agency.txt use different agency_timezone values. | inconsistent_agency_timezone |
| GL115 | route_color_contrast |
warning | route_color and route_text_color have a WCAG contrast ratio below 3:1. | route_color_contrast |
| GL116 | non_ascii_or_non_printable_char |
warning | An ID contains non-ASCII or non-printable characters. | non_ascii_or_non_printable_char |
| GL201 | foreign_key_violation |
error | A reference points to a record that does not exist (e.g. trips.route_id, stop_times.stop_id, stops.parent_station, transfers, fare tables). | foreign_key_violation |
| GL202 | wrong_parent_location_type |
error | parent_station references a location of the wrong type (a stop's parent must be a station, a boarding area's parent must be a stop). | wrong_parent_location_type |
| GL203 | station_with_parent_station |
error | A station (location_type 1) has a parent_station. | station_with_parent_station |
| GL204 | location_with_unexpected_stop_time |
error | stop_times references a station, entrance or generic node instead of a stop or boarding area. | location_with_unexpected_stop_time |
| GL301 | start_and_end_range_out_of_order |
error | A start date/time is after its end (calendar, feed_info, frequencies). | start_and_end_range_out_of_order |
| GL302 | service_never_active |
warning | A service_id is never active once calendar and calendar_dates are combined. | - |
| GL303 | redundant_calendar_date |
info | A calendar_dates exception adds a date that is already active or removes one that is not. | - |
| GL304 | expired_calendar |
warning | A service has no active dates on or after the reference date. | expired_calendar |
| GL401 | stop_times_not_sorted |
info | The rows of a trip are not listed in increasing stop_sequence order (allowed, but slows down consumers). | - |
| GL402 | stop_time_with_arrival_before_previous_departure_time |
error | A stop's arrival_time is earlier than the departure_time at the previous stop. | stop_time_with_arrival_before_previous_departure_time |
| GL403 | stop_time_arrival_after_departure |
error | A stop's arrival_time is later than its own departure_time. | - |
| GL404 | stop_time_with_only_arrival_or_departure_time |
error | Only one of arrival_time and departure_time is set. | stop_time_with_only_arrival_or_departure_time |
| GL405 | missing_trip_edge |
error | The first or last stop of a trip has no arrival_time/departure_time. | missing_trip_edge |
| GL406 | decreasing_or_equal_stop_time_distance |
error | shape_dist_traveled does not increase along a trip. | decreasing_or_equal_stop_time_distance |
| GL407 | stop_time_timepoint_without_times |
error | timepoint=1 but the stop has no arrival/departure time. | stop_time_timepoint_without_times |
| GL408 | fast_travel_between_consecutive_stops |
warning | The speed between two timed stops exceeds the plausible maximum for the route type. | fast_travel_between_consecutive_stops |
| GL409 | unusable_trip |
warning | A trip visits fewer than two stops. | unusable_trip |
| GL501 | decreasing_shape_distance |
error | shape_dist_traveled decreases along a shape. | decreasing_shape_distance |
| GL502 | stops_too_close |
warning | Two different stops (not in the same station) are closer than min-stop-distance-m; often an accidental duplicate. | - |
| GL503 | stops_too_far |
warning | Two consecutive stops of a trip are farther apart than plausible for the route type; often a wrong coordinate. | - |
| GL504 | stop_too_far_from_parent_station |
warning | A stop, entrance or node is farther than max-parent-distance-m from its parent station. | - |
| GL601 | stop_without_stop_time |
warning | A stop (location_type 0) is never served by stop_times. | stop_without_stop_time |
| GL602 | unused_station |
info | A station has no child stops. | unused_station |
| GL603 | route_without_trips |
warning | A route has no trips. | - |
| GL604 | unused_shape |
warning | A shape is not referenced by any trip. | unused_shape |
| GL605 | unused_trip |
warning | A trip has no stop_times. | unused_trip |
| GL606 | unused_service |
info | A service_id from calendar/calendar_dates is not used by any trip. | - |
| GL701 | block_trips_with_overlapping_stop_times |
error | Two trips with the same block_id run at overlapping times on a shared service date. | block_trips_with_overlapping_stop_times |
| GL702 | missing_feed_info_date |
warning | feed_info.txt has no feed_start_date or no feed_end_date. | missing_feed_info_date |
| GL703 | feed_expiration_date7_days |
warning | feed_end_date is less than 7 days after the reference date (or already passed). | feed_expiration_date7_days |
| GL704 | feed_expiration_date30_days |
warning | feed_end_date is between 7 and 30 days after the reference date. | feed_expiration_date30_days |
Notes on rule behaviour
- Field checks (GL101-GL116) apply to every field that the reference defines for
agency, stops, routes, trips, stop_times, calendar, calendar_dates, fare_attributes,
fare_rules, shapes, frequencies, transfers, pathways, levels, feed_info, translations and
attributions. Newer files such as Fares v2, flex and networks are recognised, so they do
not raise
unknown_file, but their fields are not checked yet. - Required fields that depend on other fields (GL013):
stop_name,stop_latandstop_lonforlocation_type0-2;parent_stationforlocation_type2-4;agency_idwhen there are several agencies;stop_idin stop_times unlesslocation_group_idorlocation_idis set; the transfer stops fortransfer_type1-3 and the transfer trips for 4-5. - Foreign keys (GL201): trips → routes, calendars and shapes; stop_times → trips and
stops; stops → parent station and levels; routes and fare_attributes → agency;
frequencies → trips; transfers → stops, routes and trips; fare_rules → fare_attributes,
routes and
zone_id; pathways → stops; attributions → agency, routes and trips. - Travel speed (GL408) is checked between consecutive timed stops. Untimed stops in between add to the distance. A time difference under 60 s counts as 60 s, because most feeds round times to the minute. The default limits in km/h are: tram 100, subway 150, rail 500, bus 150, ferry 80, cable tram 30, aerial lift 50, funicular 50, trolleybus 150, monorail 150, air 1200.
- Stop spacing (GL503) default maximum gap in km: 20 for urban rail and cable modes, 100 for bus, 500 for rail and ferry.
- Block overlaps (GL701) are reported when two trips with the same
block_idoverlap in time and their services share at least one date. Frequency-based trips are skipped. - Expiration (GL304, GL703, GL704) is measured from
--reference-date, which defaults to today. Set a fixed date in CI if builds must be reproducible.
Performance
stop_times.txt and shapes.txt are streamed one trip or one shape at a time. Small tables
are kept in compact indexes, and parsed values are cached. The file is not loaded into
memory. Producers almost always write the rows of a trip together. If a trip's rows are
scattered through the file, gtfslint finds those trips and reads them again in a second pass,
so the results stay correct.
Benchmark (benchmarks/synthetic_feed.py, a synthetic zipped feed with 100 routes, 50,000
trips, 2,000 stops and 1,000,000 stop_times rows, Python 3.12 on a Windows laptop):
| Task | Time |
|---|---|
gtfslint.validate() (all rules) |
6.8 s (about 150,000 rows/s) |
gtfslint.stats() |
6.5 s |
Development
pip install -e ".[dev]"
python -m pytest --cov=gtfslint
python -m ruff check . && python -m ruff format --check src tests
python -m mypy src
python benchmarks/synthetic_feed.py /tmp/gtfs-bench
The tests use a small synthetic feed, tests/data/valid_feed, which passes every rule.
tests/test_rules.py has one broken variant of it per rule.
License
MIT © Yusuf Eminoğlu. See LICENSE.
Metadata
Release files for gtfslint-cli 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| gtfslint_cli-0.1.0.tar.gz | 68.2 kB | With provenance | Details for gtfslint_cli-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| gtfslint_cli-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for gtfslint_cli-0.1.0-py3-none-any.whl |
Total release size: 118.3 kB
Release files / gtfslint_cli-0.1.0.tar.gz
| Download URL | gtfslint_cli-0.1.0.tar.gz |
|---|---|
| Size | 68.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
365212b214bc72db29ce425d711f4c2476e68a0829ce67b8efde7581cc41c3b6
|
|
BLAKE2b-256 checksum How to use checksums |
481706b145a93febb32e6939ebab0440be84a2b20a6914ec4d86807f6baf3e17
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.
Transparency logRelease files / gtfslint_cli-0.1.0-py3-none-any.whl
| Download URL | gtfslint_cli-0.1.0-py3-none-any.whl |
|---|---|
| Size | 50.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fe5fb10d6beac2d75fead06aaabbb782a1a5a73bfe6ca282734a5d376035ef44
|
|
BLAKE2b-256 checksum How to use checksums |
d5fba64bfdf666c6a6a839265eaa54762ae5ff5ad69a695aff7428cf7e478718
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.
Transparency logSecurity for gtfslint-cli 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for gtfslint_cli-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 8, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.