Prism Data Lab

Every analysis here ships its dataset and the code that produced every figure. Nothing on this site is investment advice.

Data Sources1,038 words, 5 minutes

BLS data in Python: CPI components and payrolls without a key

The BLS public API v1 needs no key. A script pulls CPI components and the jobs headline, handles the "-" returned for a missing month, and prints 12-month changes.

The Bureau of Labor Statistics publishes the CPI and the jobs report, and it also publishes an API that serves both without a key. Version 1 of the API is limited (the FAQ lists the limits as 25 queries a day, 25 series a query, and 10 years a query, against 500, 50, and 20 for the registered version 2), but 25 series over 10 years is enough for most questions, and it is one POST request. This article is the script that makes it, the one surprise in the response, and a table of the latest month with the differences from the headline release explained.

The request

Version 1 takes a JSON body at https://api.bls.gov/publicAPI/v1/timeseries/data/ with a seriesid list, startyear, and endyear, exactly as the API signature page documents. The script asks for seven series:

series id what it is
CUSR0000SA0 CPI-U, all items, seasonally adjusted
CUSR0000SA0L1E CPI-U, all items less food and energy, s.a.
CUSR0000SAF1 CPI-U, food, s.a.
CUSR0000SA0E CPI-U, energy, s.a.
CUSR0000SAH1 CPI-U, shelter, s.a.
LNS14000000 unemployment rate, s.a.
CES0000000001 total nonfarm payrolls, thousands, s.a.

The series ids are the whole difficulty of BLS. They are structured (CU for consumer prices, S for seasonally adjusted, R for the current base, 0000 for the U.S. city average, then an item code), but the structure has to be learned once and the ids copied exactly. The unadjusted CPI is CUUR0000SA0, one letter different, and the headline 12-month change in the BLS press release is computed from that unadjusted index, which becomes relevant below.

body = json.dumps({"seriesid": SERIES, "startyear": str(start_year), "endyear": str(end_year)}).encode()
req = urllib.request.Request("https://api.bls.gov/publicAPI/v1/timeseries/data/", data=body,
                             headers={"Content-type": "application/json", "User-Agent": "prism-data-lab/1.0"})
with urllib.request.urlopen(req, timeout=60) as r:
    j = json.loads(r.read().decode())
if j.get("status") != "REQUEST_SUCCEEDED":
    raise SystemExit(f"BLS API: {j.get('status')} {j.get('message')}")

The status check matters because the API reports its daily-limit and bad-id errors with HTTP 200 and a status string, not an HTTP error code. A script that skips the check gets an empty Results and no explanation.

The surprise: a month that is a dash

The response for each series is a list of observations with year, period (M01 to M12, with M13 for the annual average), periodName, and value as a string. Converting value with float() works for every month in ten years of these seven series except one. For October 2025, six of the seven series return the string "-", and float("-") raises. The API signature page does not document the marker; we found it by the traceback.

The series page for CUSR0000SA0 shows the same month as an "X" with the note "Data unavailable due to the 2025 lapse in appropriations". The script treats a non-numeric value as missing rather than skipping the row, so the month stays in the file as a gap:

try:
    val = float(d["value"])
except ValueError:          # BLS marks a missing month with "-" (e.g. LNS14000000 for 2025-10)
    val = float("nan")

That decision has a consequence downstream. A 12-month change computed with shift(12) on a table that simply lacks the October row would compare November 2025 with October 2024, eleven months apart, and print a wrong number with no error. The script reindexes to a complete monthly calendar first, so shift(12) is a shift of twelve calendar months and the change for October 2026, when it exists, will be blank rather than wrong. Payrolls (CES0000000001) did return an October 2025 value, which is why only six series show the gap.

The output

python code/bls-cpi-components-python-no-key.py reads the CSV retrieved 2026-09-05 (add --fetch to re-query) and prints:

series id what latest month latest value 12-month change month-on-month change
CUSR0000SA0 CPI-U all items 2026-07 332.813 +3.3% +0.07%
CUSR0000SA0L1E CPI-U less food and energy 2026-07 336.789 +2.5% +0.22%
CUSR0000SAF1 food 2026-07 349.881 +2.9% +0.08%
CUSR0000SA0E energy 2026-07 314.553 +14.4% -1.48%
CUSR0000SAH1 shelter 2026-07 429.095 +3.2% +0.14%
LNS14000000 unemployment rate (%) 2026-08 4.100 -0.2 pp +0.0 pp
CES0000000001 nonfarm payrolls (thousands) 2026-08 159,075.000 +603k +162k

The 12-month CPI-U change for the last thirteen months, with the gap where October 2025 is missing: 2025-06 2.7%, 2025-07 2.7%, 2025-08 2.9%, 2025-09 3.0%, 2025-11 2.7%, 2025-12 2.7%, 2026-01 2.4%, 2026-02 2.4%, 2026-03 3.3%, 2026-04 3.8%, 2026-05 4.2%, 2026-06 3.5%, 2026-07 3.3%. The peak in the ten-year file is 9.0% in June 2022.

Reconciling with the press release

The July 2026 CPI release (published 2026-08-12) says "the all items index increased 3.4 percent before seasonal adjustment" over twelve months, that "the all items less food and energy index rose 2.5 percent over the year", that "the energy index increased 14.7 percent", and that "the shelter index increased 3.2 percent". Our table says 3.3, 2.5, 14.4, and 3.2.

The differences are not errors on either side. The release's 12-month figures are computed from the not-seasonally-adjusted indexes, and the BLS is explicit about it in the phrase "before seasonal adjustment". We pulled the seasonally adjusted series because they are the right ones for month-on-month changes, and the seasonal factors do not cancel exactly over twelve months, so the annual change from the adjusted index differs by a tenth or three. Core and shelter happen to agree to one decimal; all items and energy do not. Anyone who reproduces a headline number and gets 3.3 where the release says 3.4 has almost certainly hit this and not a bug.

The employment figures reconcile directly. The August 2026 Employment Situation (released 2026-09-04) reports that "total nonfarm payroll employment increased by 162,000 in August" and that "the unemployment rate was unchanged at 4.1 percent". The table's +162k and 4.1 are those numbers, because the API served the same vintage the release did.

What the file is and is not

The CSV is tidy (one row per series and month, 807 rows) with a retrieved column, because BLS revises. Payrolls are revised in each of the next two releases and benchmarked every year; the CPI level is not revised, but its seasonal factors are recalculated each year, which changes the seasonally adjusted history. A 12-month change computed from this file next spring may not match the one printed here, and neither will be wrong.

The script does not compute contributions to the headline (that needs relative-importance weights, which are a separate BLS table), does not handle the M13 annual-average rows beyond dropping them, and stops at the 25-query daily limit without retrying, because the limit is per day and a retry would fail the same way. Registration for version 2 lifts the limits and costs nothing; the point of this article is that the first 25 questions do not need it.

This article is analysis and education, not investment, tax, or legal advice. Figures are cited to their source and dated; check them before relying on them.