At the end of Percentiles I promised an answer to "should I use SQL or Python?" Here it is: yes. The answer is 100% yes. I use both every week, and they're good at different jobs. So instead of picking a side, I gave them the same question and made them race.
The question
What's the median fixed download speed in every Texas county, every quarter of 2025?
The data is Ookla's open performance tiles, shared publicly through Ookla for Good. Each tile is about 600x600 meters and carries an average download speed plus a count of how many tests were taken in each tile. For Texas in 2025 that's 535,217 tile-quarters and about 6.7 million tests across all 254 counties.
How the work actually split
- Get the data: SQL. This step is the E and the L in ELT: extract, load, transform. You pull the raw data out of wherever it lives (extract), park it somewhere you can work with it (load), and do the real shaping afterward (transform). Ookla publishes one Parquet file per quarter for the whole planet, which is a lot more internet than I need. So DuckDB reads those files right off S3 and only grabs the Texas tiles and the nine columns I care about. Four files covering the planet go in, and one 10 MB Texas file lands on my laptop. Steps 2 and 3 are the T.
- Put each tile in a county: Python. Each tile comes with a point (its center) but no county. Figuring out which county a point falls inside is a spatial join, sometimes called point-in-polygon. SQL can do this too! DuckDB has a spatial extension, and so do Postgres and most warehouses. I still reach for Python here for a few reasons. geopandas reads the Census county map file directly, warns me if the two layers use different coordinate systems, and builds a spatial index on its own, so 535,000 points don't each get checked against all 254 counties. And when something looks off, I can drop the tiles on a map in a couple of lines and see it. The join itself is one line, and it ran in about 15 seconds.
- Compute the medians: both. This is the race.
- Prove they match: Python. A small script runs both versions and fails loudly if they disagree. They agree on all 1,016 county-quarter rows.
Notice neither language won every step. Moving and shrinking data is SQL's bread and butter. Geometry, gluing steps together and checking the work is where Python shines.
Two medians, not one
Here's the twist that made this fun. "Median speed by county" sounds like one number, and it's really two.
The middle tile: line up every tile from slowest to fastest and take the one in the middle. Each tile counts once, whether it had 1 test or 1,000.
The middle test: same line, but keep a running total of tests as you walk from the slow end. The tile where you pass the halfway mark is your median. Busy tiles count more.
Here's the middle-test version in SQL (trimmed a little for the page):
SELECT county, quarter,
MEDIAN(mbps) AS median_tile,
-- first tile where the running total of tests passes half
MIN(mbps) FILTER (WHERE running >= total / 2.0) AS median_test
FROM (
SELECT *,
SUM(tests) OVER (PARTITION BY county, quarter
ORDER BY mbps ROWS UNBOUNDED PRECEDING) AS running,
SUM(tests) OVER (PARTITION BY county, quarter) AS total
FROM tiles
)
GROUP BY ALL;
And the same thing in pandas:
keys = ["county", "quarter"]
out = tiles.groupby(keys).agg(median_tile=("mbps", "median"))
# sort slowest first, keep a running total of tests
tiles = tiles.sort_values(keys + ["mbps"])
running = tiles.groupby(keys)["tests"].cumsum()
total = tiles.groupby(keys)["tests"].transform("sum")
out["median_test"] = tiles[running >= total / 2].groupby(keys)["mbps"].min()
Which one reads better? SQL reads like a description of the answer, while pandas reads more like a recipe.
What the two medians said
| All of Texas | Middle tile | Middle test |
|---|---|---|
| Q1 2025 | 288 Mbps | 360 Mbps |
| Q2 2025 | 283 Mbps | 361 Mbps |
| Q3 2025 | 293 Mbps | 372 Mbps |
| Q4 2025 | 309 Mbps | 401 Mbps |
Same data, same quarter, and a 92 Mbps gap in Q4 just from asking a slightly different question. Busy tiles tend to be in urban or suburban strata, and those strata tend to have faster service. Across Texas in Q4, tiles with a single test had a median of 145 Mbps. Tiles with 20 or more tests came in at 409 Mbps.
Where Texas tiles actually land
Fixed tiles by average download speed, Q4 2025, 50 Mbps bins
The middle tile and the middle test can tell very different stories
Median fixed download speed in Mbps, Q4 2025
| Q4 2025 | Middle tile | Middle test |
|---|---|---|
| Texas (all tiles) | 309 Mbps | 401 Mbps |
| Harris | 388 Mbps | 410 Mbps |
| Dallas | 404 Mbps | 413 Mbps |
| Bexar | 397 Mbps | 426 Mbps |
| Travis | 377 Mbps | 410 Mbps |
| Falls | 93 Mbps | 508 Mbps |
| Reeves | 153 Mbps | 62 Mbps |
In the big metros the two medians land close together. Harris, Dallas, Bexar and Travis are all within about 35 Mbps. The fun is in the small counties.
Reeves County: one tile, two devices
In Q4, Reeves County had 2,124 tests spread across 243 tiles. One of those tiles had 1,044 of them, almost half the county, from just 2 devices. That tile averaged 62 Mbps.
So the middle test for all of Reeves County is 62 Mbps, because that one tile sits right on the halfway mark. The middle tile says 153 Mbps.
I don't know who's behind those 1,044 tests, and the data can't tell me. What I do know is that a test-weighted median listens to whoever talks the most. This is why I check the devices column next to tests before I trust a weighted number in a small area. There are techniques used to ensure these situations don't impact final results of analysis, but for the sake of this conversation, let's leave it here.
Falls County: the opposite problem
Falls County flips it around. The middle tile is 93 Mbps and the middle test is 508 Mbps.
More than half its tiles have 2 tests or fewer, and those tiles have a median around 77 Mbps. The tiles with 10 or more tests have a median of 508 Mbps, and about 60% of their tests come from in and around Marlin, the county seat. The tile median hears from the countryside. The test median hears from town.
Neither number is wrong. They answer different questions: "what does a typical place in this county get?" versus "what does a typical test in this county see?" Pick the one that matches your question, and say which one you picked.
So which do I reach for?
- SQL when the data lives in a warehouse or a big file and the job is filter, join, group or rank. Do the heavy lifting where the data lives.
- Python for geometry, charts, stats that SQL doesn't have built in, and stitching steps together.
- Both when the number matters. Compute it two ways and make them agree. That match on all 1,016 rows is what lets me sleep at night.
Everything is in the data-stories repo: the download script, the county join, both versions and the check. Run it yourself and tell me if I got something wrong!
Next week: how many speed tests is enough to trust a number at all? Reeves County is going to come up again.
Speedtest® by Ookla® Global Fixed and Mobile Network Performance Maps, from Ookla for Good via the AWS Open Data Registry. Based on analysis by Ookla of Speedtest Intelligence® data for Q1 to Q4 2025. Provided by Ookla and accessed 13 October 2026. Ookla trademarks used under license and reprinted with permission. Data licensed CC BY-NC-SA 4.0. County boundaries: US Census Bureau.