This is a Datasette project for exploring the BTS T-100 segment data: aggregate monthly flights, seats, passengers and freight between airports, for international and domestic services, 1990-2025.
It has two layers:
- a front page at
/(Datasette's own index is at/-/): an introduction and three short stories with a graphic each: a map of the busiest US airports (Alaska, Hawaii and Puerto Rico in insets), long-haul flights moving to twin-engined jets, and how much of the market the biggest airlines carry. Its numbers come from thebusiest_airports,engines_by_year,carrier_concentrationandtop_carriersqueries when the page is rendered. Old links to/?year=…&sort=…redirect to/airports. - the busiest airports table at
/airports: a sortable table of the busiest US airports for any year, linking to each airport's map and its routes over time. The year range comes fromplugins: t100_globe: min_year / max_yearindatasette.yml; updatemax_yearwhen a new year of data is loaded, and the front page's stories follow it. - a most dedicated airports page at
/airports/dedicated: two server-rendered top-10 tables for one year (?year=2025), one for passengers (among the 100 busiest US airports) and one for freight (among the 25 biggest), of the airports where a single airline group carries the largest share. It readsairport_carrier_yearthrough thededicated_airportsquery and is the second view in the airports section's views line. - an airport network map at
/airports/<AIRPORT>/globe(for example/airports/ATL/globe;/airports/globeis the same map with an airport chooser, where hovering an airport (or tapping it once) shows its domestic routes for the latest year;/globeredirects there): pick a US airport, see its routes by year on a globe or flat map (international in orange, domestic in blue), play through the years, and drill down to months. Clicking a route links to its route page. - a routes-over-time page, the main page for an airport, at
/airports/<AIRPORT>: a destination-by-year matrix (optionally grouped by BTS city market, with rows that open into their airports). - a route page at
/airports/<FROM>/<TO>(and/airports/<FROM>/<TO>/<YEAR>for the months of one year): a stacked chart by carrier or aircraft and a carrier-and-aircraft table. These replace the Datasetterouteandroute_yearpages for browsing; those queries remain as raw data tables. - the standard Datasette interface for tables and canned queries, for exploring everything else.
- Python:
>=3.14 - uv: The project uses
uvfor dependency management (seepyproject.tomlanduv.lock).
The project relies on the following key packages:
datasette: The core data exploration tooldatasette-geojson: For rendering map visualizations (e.g., flight routes)sqlite-utils: For database manipulation
-
Install dependencies: If you haven't already, install the project dependencies using
uv:uv sync
-
Import data: See
setup_database.md(schema, segment files, lookup tables and airport coordinates). -
Run the Datasette server: Start the application by running the following command from the root of the project directory:
./start.sh
(that is
uv run datasette --port 8808 --reload .) Note: you must pass the directory (.), not justt100.db. Directory mode is what loadsdatasette.yml(canned queries and settings),plugins/,templates/andstatic/; naming only the database file silently ignores all of them. -
Explore the data: Open your browser and navigate to
http://localhost:8808to view the interface. You can authenticate as therootuser using the link in the terminal.
t100.db: SQLite database:t100_internationalandt100_domesticsegment tables, at100view over both (with adomestic0/1 column), and lookup tables for carriers, aircraft types, airports and airport coordinate overrides,airport_year_totals(per-airport yearly totals),airport_carrier_year(each merger-aware airline group's yearly passengers, departures, seats and freight leaving each airport, for an airport's carrier mix),airport_names(BTS's newest city name per airport code),airport_market(BTS's newest city market per airport code), and rollups by aircraft family, merger-aware carrier group and aircraft group/distance band (aircraft_family,family_year,carrier_group,carrier_group_year,aircraft_group_band_year,family_detail_year,carrier_route_year,route_year_totals), all derived from the segment tables: rebuild them withbuild_database.rebuild_derived_tablesafter loading data. Not committed.datasette.yml: Datasette configuration and canned queries:top_us_airports,destinations,route,route_year, and the queries behind the map (airports,routes_by_year,routes_by_month).plugins/:t100_globe.pyregisters the front page and the/airports/...pages (the older/globe,/globe/ATL,/routes/ATLand/route/A/Bpaths redirect);t100_render_links.pyrendersmore_detailcolumns as links;t100_guard.pyrefuses_nextand_faceton the two big segment tables and serves/robots.txt;t100_usage.pywrites the anonymous visit log described under Usage statistics.templates/:globe.html(the map page) and templates for the canned query pages.static/nav/andtemplates/_nav.html: the header shared by every page (breadcrumb trail, and a theme chooser: Auto follows the system's light or dark mode, or pick Light or Dark, both from AirTrail's colours) and the code that keepstheme,measure,serviceand the matrix settings in in-site links (reading a map'shidesetting back intoservice). The site name in the trail issite_nameunderplugins: t100_globe(defaultt100.husk.org).static/home/: the front page's graphics. The map usesstatic/globe/src/usamap.js, d3's Albers USA projection with a third inset for Puerto Rico and the Virgin Islands, drawn over the vendored us-atlasstates-10m.json.static/globe/: the map's browser code (plain ES modules, no build step) and vendored d3-geo; served at/static/globe/src/routestyle.jsandsrc/compare.jshold the colouring, filtering and year comparison shared with the airline map (static/charts/airlineglobe.js).build_database.py: schema, indexes, thet100view and airport override loading (schema.sqlis generated from it).data/aircraft_families.csvanddata/carrier_groups.csv: the editable rules for the family and merged-carrier rollups (re-runrebuild_derived_tablesafter editing).data/airport_overrides.csv: coordinates for airport codes missing from OpenFlights.serve_public.sh: run command for a locked-down public instance.tests/andstatic/globe/test/: pytest andnode --testsuites.fetch_t100.py: script for fetching or updating the raw T100 source data.
uv run pytest # queries, page route, public mode
npm test # the map's pure JavaScript modules./serve_public.sh serves the database read-only (--immutable), with arbitrary SQL and database downloads turned off and responses cacheable for a day. PORT (default 8808) and HOST (default 127.0.0.1; use 0.0.0.0 in a container) are environment variables. Container and hosting configuration is not included yet.
destinations: aggregate statistics (passengers, seats, freight) for flights from an airport, by month, as a plain table.route: a specific route (origin to destination) by year, carrier and aircraft type.route_year: month-by-month data for a route in one year.top_airports: readsairport_year_totals. For one year (year=2025), the airports in the top 20 by passengers, departures, seats or freight, so the busiest-airports table can show a top 20 for whichever column is sorted.destinationsalso takes an optionalyear.busiest_airports,carrier_concentration,top_carriers: the front page.carrier_concentrationranks operating carriers by passengers each year, so regional airlines flying for the big brands count on their own.dedicated_airports: readsairport_carrier_year. For one year (year=2025) andmeasure=passengersorfreight, the ten airports whose biggest airline group has the largest share of the airport's passengers (pool: the 100 busiest) or freight (pool: the 25 biggest), with the airport's size rank, the airline's and the airport's totals, and the share. Freight is in pounds.domestic_routes: readsroute_year_totals. Every domestic route in one year (year=2025), with the same rule for domestic and the same 12-departure cut-off asroutes_by_year, so hovering an airport on/airports/globeshows what its own map shows with international hidden.airports,routes_by_year,routes_by_month: JSON for the map pages.routes_by_yearandroutes_by_monthlabel each destinationdomesticorinternationalby whether most of that period's departures were domestic. Routes with fewer than 12 departures in the year (one-off charters, diversion legs, positioning flights) are left out of both; passmin_deps=Nto change the cut-off (min_deps=0shows everything). In the month query the cut-off applies to the year's total, so a route shows all its months or none.
Every push to main runs .github/workflows/deploy.yml: the Python and JavaScript tests, then flyctl deploy to the Fly app named in fly.toml, then scripts/smoke_check.sh against the live site (also usable by hand: scripts/smoke_check.sh [base-url]). The deploy needs a Fly deploy token stored as the FLY_API_TOKEN repository secret:
flyctl tokens create deploy -a blech-datasette-t100 | gh secret set FLY_API_TOKEN
The database is not part of the image: it lives on the Fly volume (/mnt/db/t100.db) and a code deploy does not change it. If a code change needs a new table or column, load it on the volume first, or the smoke check will fail after the deploy. A deploy restarts the machine, which means a few seconds of downtime.
When T100_USAGE_LOG names a directory (fly.toml sets /mnt/db/usage), plugins/t100_usage.py appends one tab-separated line per page view to visits-YYYY-MM.log there: time, an anonymous visitor token, the page (years replaced by {year}, no query string), status, referring host, and human, bot or other (crawler traffic on the data pages). The token is the start of a hash of the client address, User-Agent and a random salt kept in a private file and replaced each UTC day, so it can't be reversed and doesn't link days. No address or User-Agent is stored, and a failure to write never affects a page. The design is in docs/usage-stats.md.
Read it with scripts/usage_report.py (standard library only; it is in the image):
flyctl ssh console -a blech-datasette-t100 -C "python3 scripts/usage_report.py /mnt/db/usage --month 2026-10"
--prune 12 also deletes files older than 12 months. The log is per machine, so it assumes the single machine this app runs on.