How to Measure Internet Speed Change Between Two Quarters
On this page
Problem statement
Did internet speeds improve over the past year, and where? Ookla publishes the same tiles every quarter, so April–June 2025 can be compared with April–June 2026. But the set of tiles changes, single tiles are noisy, and a national median can rise because of who tested rather than how networks changed. This page compares the two quarters nationally, tile by tile and county by county, and shows which comparison to trust.
Quick answer
Compare like with like: tests-weighted medians over the same area for each quarter, tiles present in both quarters, and counties with enough tests in both. Quote changes in matched tiles or well-tested counties, not in single tiles.
a, b = wmedian(fixed25.avg_d_kbps, fixed25.tests) / 1000, wmedian(fixed.avg_d_kbps, fixed.tests) / 1000
print(f"tests-weighted median download, lower 48: Q2 2025 {a:.1f} Mbps, Q2 2026 {b:.1f} Mbps ({b / a - 1:+.1%})")
print(f"tests: {fixed25.tests.sum():,} then {fixed.tests.sum():,}")
tests-weighted median download, lower 48: Q2 2025 341.0 Mbps, Q2 2026 381.2 Mbps (+11.8%)
tests: 15,462,680 then 15,134,971
The tests-weighted median download rose from 341.0 Mbps to 381.2 Mbps, 11.8%, while the number of tests barely changed (15,462,680 then 15,134,971).
Matched tiles, noise and counties
1. Tiles in both quarters
t = fixed25[["quadkey", "avg_d_kbps", "tests"]].merge(fixed[["quadkey", "avg_d_kbps", "tests", "GEOID", "STUSPS"]], on="quadkey", suffixes=("_25", "_26"))
print(f"tiles in 2025: {len(fixed25):,}; in 2026: {len(fixed):,}; in both: {len(t):,}")
t["change"] = t.avg_d_kbps_26 / t.avg_d_kbps_25 - 1
print(f"matched tiles: median change {t.change.median():+.1%}; faster {(t.change > 0).mean():.1%}")
tiles in 2025: 1,546,257; in 2026: 1,548,841; in both: 1,023,742
matched tiles: median change +12.8%; faster 58.9%
About 1.55 million tiles have tests in each quarter, but only 1,023,742 in both. In those matched tiles the median change is +12.8%, and 58.9% got faster, close to the national figure.
2. Noise in single tiles
few = t[(t.tests_25 < 3) | (t.tests_26 < 3)]; many = t[(t.tests_25 >= 20) & (t.tests_26 >= 20)]
print(f"spread of change (IQR): tiles with under 3 tests {few.change.quantile(0.75) - few.change.quantile(0.25):.2f}; tiles with 20+ in both {many.change.quantile(0.75) - many.change.quantile(0.25):.2f}")
spread of change (IQR): tiles with under 3 tests 1.53; tiles with 20+ in both 0.46
Single tiles are noisy. The middle half of changes spans 1.53 in tiles with fewer than 3 tests, against 0.46 in tiles with 20 or more tests in both quarters.
3. By county
w = lambda d, col: wmedian(d[col], d.tests)
ca = fixed25.groupby("GEOID").apply(lambda d: pd.Series({"t": d.tests.sum(), "m": w(d, "avg_d_kbps") / 1000}), include_groups=False)
cb = fixed.groupby("GEOID").apply(lambda d: pd.Series({"t": d.tests.sum(), "m": w(d, "avg_d_kbps") / 1000}), include_groups=False)
cc = ca.join(cb, lsuffix="_25", rsuffix="_26").dropna()
cc = cc[(cc.t_25 >= 100) & (cc.t_26 >= 100)]
cc["change"] = cc.m_26 / cc.m_25 - 1
print(f"counties compared: {len(cc):,}; median change {cc.change.median():+.1%}; faster {(cc.change > 0).mean():.1%}")
counties compared: 2,862; median change +12.6%; faster 75.2%
Among 2,862 counties with enough tests in both quarters, the median change is +12.6% and 75.2% got faster. County medians are steadier than tiles and still local enough to map.
Biggest rises and upload
The data used on this page
Ookla's Speedtest open data performance tiles for fixed broadband in April–June 2025 and April–June 2026, read from Amazon S3, and Census Bureau 2023 county boundaries.
from pathlib import Path
import duckdb
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
# Ookla Speedtest open data (AWS Open Data, keyless): one Parquet file per quarter and network type, zoom-16 web
# Mercator tiles with average speeds, latency, tests and devices. Published quarterly under fixed names.
BASE = "https://ookla-open-data.s3.amazonaws.com/parquet/performance"
def tiles(kind, year, q):
"""One quarter's tiles inside a box around the contiguous US, cached locally as Parquet."""
path = f"data/ookla-{kind}-{year}q{q}-conus.parquet"
if not Path(path).exists():
url = f"{BASE}/type={kind}/year={year}/quarter={q}/{year}-{(q - 1) * 3 + 1:02d}-01_performance_{kind}_tiles.parquet"
con = duckdb.connect()
con.sql("INSTALL httpfs; LOAD httpfs;")
con.sql(f"""COPY (SELECT quadkey, tile_x, tile_y, avg_d_kbps, avg_u_kbps, avg_lat_ms, avg_lat_down_ms, avg_lat_up_ms, tests, devices
FROM read_parquet('{url}') WHERE tile_x BETWEEN -125 AND -66 AND tile_y BETWEEN 24 AND 50
ORDER BY quadkey) TO '{path}' (FORMAT parquet)""")
return pd.read_parquet(path)
for name in ["cb_2023_us_county_20m.zip"]:
if not Path(f"data/{name}").exists():
Path(f"data/{name}").write_bytes(requests.get(f"https://www2.census.gov/geo/tiger/GENZ2023/shp/{name}", timeout=300).content)
counties = gpd.read_file("data/cb_2023_us_county_20m.zip")
counties = counties[~counties.STUSPS.isin(["AK", "HI", "PR"])].to_crs(4326)
def in_counties(df):
"""Attach the county each tile's centre falls in; tiles outside the lower 48 (Canada, Mexico, sea) drop out."""
pts = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.tile_x, df.tile_y), crs=4326)
j = gpd.sjoin(pts, counties[["GEOID", "STUSPS", "NAME", "geometry"]], predicate="within")
return pd.DataFrame(j.drop(columns=["geometry", "index_right"]))
def wmedian(values, weights):
"""Median of tile averages, each tile counted once per test."""
o = np.argsort(values.to_numpy())
v, w = values.to_numpy()[o], weights.to_numpy()[o]
c = np.cumsum(w)
return v[np.searchsorted(c, c[-1] / 2)]
fixed = in_counties(tiles("fixed", 2026, 2))
fixed25 = in_counties(tiles("fixed", 2025, 2))
print(f"Ookla fixed broadband, April-June 2026: {len(fixed):,} tiles in the lower 48, {fixed.tests.sum():,} tests")
Ookla fixed broadband, April-June 2026: 1,548,841 tiles in the lower 48, 15,134,971 tests
Example 1: the biggest rises
cc = cc.join(counties.set_index("GEOID")[["NAME", "STUSPS"]])
print(cc.sort_values("change").tail(4)[["NAME", "STUSPS", "m_25", "m_26"]].round(0).to_dict("records"))
[{'NAME': 'Washington', 'STUSPS': 'KS', 'm_25': 28.0, 'm_26': 256.0}, {'NAME': 'Humphreys', 'STUSPS': 'MS', 'm_25': 16.0, 'm_26': 151.0}, {'NAME': 'Morgan', 'STUSPS': 'OH', 'm_25': 36.0, 'm_26': 354.0}, {'NAME': 'Pawnee', 'STUSPS': 'OK', 'm_25': 18.0, 'm_26': 233.0}]
The biggest rises are from very low starting points: Washington County, Kansas went from 28 to 256 Mbps, and Humphreys County, Mississippi, Morgan County, Ohio and Pawnee County, Oklahoma rose similarly. Jumps like these point to a new network, typically fibre, reaching enough testers.
Example 2: upload
print(f"upload: {wmedian(fixed25.avg_u_kbps, fixed25.tests) / 1000:.1f} -> {wmedian(fixed.avg_u_kbps, fixed.tests) / 1000:.1f} Mbps")
upload: 129.4 -> 150.1 Mbps
Upload rose faster than download, from 129.4 to 150.1 Mbps, as fibre, which is symmetrical, replaces cable and DSL.
Why speeds rise
Measured speeds rise when networks are upgraded (fibre replacing copper, cable plans raising tiers), when people buy faster plans, and when devices and Wi-Fi improve. Speedtest data cannot separate these. A county's median can also jump because a few dozen households on a new fibre network started testing. Year-on-year comparisons are strongest for large areas and for tiles with many tests in both periods.
Comparison pitfalls
- Same quarter of the year avoids seasonal differences in who tests.
- Matched tiles remove changes in where people tested, but drop new areas.
- Weight changes by tests, not one vote per tile.
- Big percentage jumps usually come from low starting points in small counties.
Internal links
- How to map broadband speed by county — the county map
- Average internet speed comes out wrong — weighting by tests
- Empty Speedtest tiles — what absence means
FAQ
Did US internet speeds increase in 2026?
Yes: the tests-weighted median fixed-broadband download in the lower 48 rose from 341.0 Mbps in April–June 2025 to 381.2 Mbps in April–June 2026, 11.8%.
How do I compare Ookla speeds between quarters?
Read both quarters, compare tests-weighted medians, and for local change use tiles present in both or counties with enough tests in both.