BLS API Python Tutorial: CPI, Employment, and Wages
Download CPI, payroll employment, and hourly earnings. Clean the BLS JSON response with pandas, calculate inflation and real wage growth, build clear charts, and create a Labor Market Pulse animation.
BLS API with Python: Download CPI Data and Calculate Inflation
The U.S. Bureau of Labor Statistics provides a public API that lets you download CPI, employment, wages, unemployment, and many other economic time series directly into Python.
In this tutorial, you will use the BLS API to download Consumer Price Index data, convert the JSON response into a pandas DataFrame, calculate monthly and year-over-year inflation, and create a simple chart.
You will also learn how to request several BLS series at once and extend the same workflow to payroll employment and average hourly earnings.
The examples use Python's requests library so you can see exactly how the API request and response work.
Table of contents
- Quick CPI example
- What is the BLS Public Data API?
- Do you need a BLS API key?
- Install Python packages
- Choose the correct CPI series
- Make a single-series request
- Understand the JSON response
- Convert BLS data to pandas
- Calculate year-over-year inflation
- Calculate monthly inflation
- Plot CPI inflation
- Request a year range with POST
- Build a reusable BLS function
- Request several series
- Employment and wages
- Keep BLS footnotes
- Validate BLS data
- Common BLS API errors
- Frequently asked questions
Quick example: download CPI data with Python
If you only want to download one CPI series, start here.
import requests
import pandas as pd
series_id = "CUUR0000SA0"
url = (
"https://api.bls.gov/publicAPI/v2/"
f"timeseries/data/{series_id}"
)
response = requests.get(
url,
timeout=30,
)
response.raise_for_status()
payload = response.json()
print(payload["status"])
CUUR0000SA0 is the Consumer Price Index for All Urban Consumers, U.S. city average, all items, not seasonally adjusted.
A single-series GET request is part of the official BLS Version 2 API interface.
What is the BLS Public Data API?
The Bureau of Labor Statistics publishes data on consumer prices, employment, wages, unemployment, productivity, job openings, workplace injuries, and many other parts of the U.S. economy.
The BLS Public Data API gives programs access to published BLS time series.
The main endpoint used in this tutorial is:
https://api.bls.gov/publicAPI/v2/timeseries/data/
BLS supports GET requests for single-series retrieval and POST requests when you want to send a larger request, such as several series or a selected year range.
Python is useful because you can download the data, clean it, calculate inflation, make charts, and save the results in one reproducible workflow.
Do you need a BLS API key?
You can make basic BLS requests without registering.
Registered access gives you higher limits and extra Version 2 features.
| Access | Daily queries | Series per query | Years per query |
|---|---|---|---|
| Unregistered | 25 | 25 | 10 |
| Registered | 500 | 50 | 20 |
Both access levels currently have a rate limit of 50 requests per 10 seconds.
For a short CPI tutorial, you do not need to register. For longer research projects, registration is useful.
Store a BLS registration key safely
If you register, do not put your real key directly into public Python code.
macOS or Linux
export BLS_API_KEY="your_registration_key"
Windows PowerShell
$env:BLS_API_KEY="your_registration_key"
Then read it in Python:
import os
BLS_API_KEY = os.getenv(
"BLS_API_KEY"
)
Install the Python packages
python -m pip install requests pandas matplotlib
| Package | Purpose |
|---|---|
requests |
Send requests to BLS |
pandas |
Clean and transform the data |
matplotlib |
Create charts |
Choose the correct CPI series
A BLS series ID identifies one specific economic series.
For CPI, an important choice is whether you want seasonally adjusted or not seasonally adjusted data.
| Series ID | Description | Seasonal adjustment |
|---|---|---|
CUUR0000SA0 |
CPI-U, U.S. city average, all items | Not seasonally adjusted |
CUSR0000SA0 |
CPI-U, U.S. city average, all items | Seasonally adjusted |
This distinction matters. BLS normally reports the headline 12-month CPI change using not seasonally adjusted data, while month-to-month changes are usually reported on a seasonally adjusted basis.
For that reason, this tutorial uses CUUR0000SA0 for year-over-year inflation and CUSR0000SA0 for month-to-month inflation.
Make a single-series BLS request
import requests
BLS_URL = (
"https://api.bls.gov/publicAPI/v2/"
"timeseries/data/"
)
series_id = "CUUR0000SA0"
response = requests.get(
f"{BLS_URL}{series_id}",
timeout=30,
)
response.raise_for_status()
data = response.json()
Do not stop at response.raise_for_status(). A successful HTTP request does not always mean the BLS request itself is valid.
if data.get("status") != "REQUEST_SUCCEEDED":
raise RuntimeError(
data.get("message", [])
)
Also inspect messages:
print(data.get("message", []))
Understand the BLS JSON response
Each returned series contains a seriesID and a list of observations.
A typical monthly observation contains fields such as:
year
period
periodName
value
footnotes
BLS monthly periods are normally represented as M01 through M12.
Some monthly series can also contain M13, which represents an annual average rather than a thirteenth calendar month.
Convert the BLS response into pandas
import pandas as pd
def parse_bls_series(
payload: dict,
) -> pd.DataFrame:
results = payload.get(
"Results",
{}
)
if isinstance(results, list):
results = (
results[0]
if results
else {}
)
series_list = results.get(
"series",
[]
)
rows = []
for series in series_list:
series_id = series.get(
"seriesID"
)
for item in series.get(
"data",
[]
):
rows.append({
"series_id": series_id,
"year": item.get("year"),
"period": item.get("period"),
"period_name": item.get(
"periodName"
),
"value": item.get("value"),
"footnotes": item.get(
"footnotes",
[]
),
})
return pd.DataFrame(rows)
Use it:
cpi = parse_bls_series(data)
print(cpi.head())
Keep only monthly observations
cpi = cpi[
cpi["period"].between(
"M01",
"M12",
)
].copy()
Convert the period into a numeric month:
cpi["month"] = (
cpi["period"]
.str.removeprefix("M")
.astype(int)
)
Build a real date column:
cpi["date"] = pd.to_datetime(
{
"year": pd.to_numeric(
cpi["year"]
),
"month": cpi["month"],
"day": 1,
},
errors="coerce",
)
Convert the index value:
cpi["value"] = pd.to_numeric(
cpi["value"],
errors="coerce",
)
Then sort the data:
cpi = (
cpi
.sort_values("date")
.reset_index(drop=True)
)
CPI is not the inflation rate
CPI is an index level. Inflation is calculated from the percentage change in that index.
Calculate year-over-year inflation
cpi["inflation_yoy"] = (
cpi["value"]
.pct_change(
periods=12,
fill_method=None,
)
* 100
)
This calculates:
100 × ((CPI today / CPI 12 months ago) - 1)
This matches the general way BLS presents the all-items 12-month CPI change using not seasonally adjusted data.
Download seasonally adjusted CPI for monthly inflation
For month-to-month analysis, use CUSR0000SA0.
sa_series_id = "CUSR0000SA0"
response = requests.get(
f"{BLS_URL}{sa_series_id}",
timeout=30,
)
response.raise_for_status()
sa_data = response.json()
cpi_sa = parse_bls_series(
sa_data
)
Clean the monthly observations:
cpi_sa = cpi_sa[
cpi_sa["period"].between(
"M01",
"M12",
)
].copy()
cpi_sa["month"] = (
cpi_sa["period"]
.str.removeprefix("M")
.astype(int)
)
cpi_sa["date"] = pd.to_datetime(
{
"year": pd.to_numeric(
cpi_sa["year"]
),
"month": cpi_sa["month"],
"day": 1,
}
)
cpi_sa["value"] = pd.to_numeric(
cpi_sa["value"],
errors="coerce",
)
cpi_sa = cpi_sa.sort_values(
"date"
)
Calculate the monthly percentage change:
cpi_sa["inflation_monthly"] = (
cpi_sa["value"]
.pct_change(
periods=1,
fill_method=None,
)
* 100
)
Using seasonally adjusted CPI for monthly changes helps remove recurring seasonal patterns from the month-to-month comparison.
Plot year-over-year CPI inflation
import matplotlib.pyplot as plt
plot_data = (
cpi
.dropna(
subset=["inflation_yoy"]
)
)
fig, ax = plt.subplots(
figsize=(11, 6)
)
ax.plot(
plot_data["date"],
plot_data["inflation_yoy"],
)
ax.axhline(
0,
linewidth=0.8,
)
ax.set_title(
"U.S. CPI Inflation"
)
ax.set_xlabel("Date")
ax.set_ylabel(
"12-month change, percent"
)
ax.grid(alpha=0.25)
fig.tight_layout()
plt.show()
A useful image filename is bls-api-python-cpi-inflation.png.
A clear alt description is: Chart of the 12-month percentage change in U.S. CPI-U calculated from BLS data.
Request a specific year range
For more control over the time period, use a POST request.
import requests
payload = {
"seriesid": [
"CUUR0000SA0"
],
"startyear": "2017",
"endyear": "2026",
}
response = requests.post(
BLS_URL,
json=payload,
timeout=30,
)
response.raise_for_status()
data = response.json()
Without registered Version 2 access, keep the request within the current unregistered limits.
Build a reusable BLS API function
import os
from typing import Any
import requests
BLS_URL = (
"https://api.bls.gov/publicAPI/v2/"
"timeseries/data/"
)
def fetch_bls(
series_ids: list[str],
start_year: int,
end_year: int,
api_key: str | None = None,
) -> dict[str, Any]:
if start_year > end_year:
raise ValueError(
"start_year must be less "
"than or equal to end_year"
)
max_series = (
50 if api_key else 25
)
max_years = (
20 if api_key else 10
)
year_count = (
end_year
- start_year
+ 1
)
if len(series_ids) > max_series:
raise ValueError(
f"Maximum series for this "
f"request: {max_series}"
)
if year_count > max_years:
raise ValueError(
f"Maximum years for this "
f"request: {max_years}"
)
payload = {
"seriesid": series_ids,
"startyear": str(start_year),
"endyear": str(end_year),
}
if api_key:
payload[
"registrationkey"
] = api_key
response = requests.post(
BLS_URL,
json=payload,
timeout=30,
)
response.raise_for_status()
result = response.json()
if (
result.get("status")
!= "REQUEST_SUCCEEDED"
):
raise RuntimeError(
result.get(
"message",
[],
)
)
messages = (
result.get("message")
or []
)
if messages:
print(
"BLS API message:",
messages,
)
return result
Use it like this:
BLS_API_KEY = os.getenv(
"BLS_API_KEY"
)
raw = fetch_bls(
series_ids=[
"CUUR0000SA0",
"CUSR0000SA0",
],
start_year=2017,
end_year=2026,
api_key=BLS_API_KEY,
)
Request several BLS series at once
SERIES = {
"CUUR0000SA0":
"CPI Unadjusted",
"CUSR0000SA0":
"CPI Seasonally Adjusted",
"CES0000000001":
"Total Nonfarm Employment",
"CES0500000003":
"Average Hourly Earnings",
}
raw = fetch_bls(
series_ids=list(SERIES),
start_year=2017,
end_year=2026,
api_key=BLS_API_KEY,
)
Convert several series into a wide DataFrame
long = parse_bls_series(
raw
)
monthly = long[
long["period"].between(
"M01",
"M12",
)
].copy()
monthly["month"] = (
monthly["period"]
.str.removeprefix("M")
.astype(int)
)
monthly["date"] = pd.to_datetime(
{
"year": pd.to_numeric(
monthly["year"]
),
"month": monthly["month"],
"day": 1,
}
)
monthly["value"] = pd.to_numeric(
monthly["value"],
errors="coerce",
)
wide = (
monthly
.pivot(
index="date",
columns="series_id",
values="value",
)
.rename(
columns=SERIES
)
.sort_index()
)
wide.columns.name = None
Calculate monthly payroll change
wide[
"Monthly Payroll Change"
] = (
wide[
"Total Nonfarm Employment"
]
.diff()
)
Because total nonfarm employment is reported in thousands, a result of 50 means an increase of about 50,000 payroll jobs.
Calculate nominal wage growth
wide[
"Nominal Wage Growth"
] = (
wide[
"Average Hourly Earnings"
]
.pct_change(
periods=12,
fill_method=None,
)
* 100
)
Calculate a simple real wage index
wide[
"Real Wage Ratio"
] = (
wide[
"Average Hourly Earnings"
]
/ wide[
"CPI Seasonally Adjusted"
]
)
first_value = (
wide[
"Real Wage Ratio"
]
.dropna()
.iloc[0]
)
wide[
"Real Wage Index"
] = (
wide[
"Real Wage Ratio"
]
/ first_value
* 100
)
wide[
"Real Wage Growth"
] = (
wide[
"Real Wage Index"
]
.pct_change(
periods=12,
fill_method=None,
)
* 100
)
BLS also publishes an official Real Earnings series and release, so use that source when you need the official BLS real earnings measure rather than a custom calculation.
Keep BLS footnotes
Do not throw away the footnotes in the API response. BLS can mark observations as preliminary or attach other notes.
def footnote_text(
footnotes: list,
) -> str:
texts = []
for footnote in (
footnotes or []
):
if not footnote:
continue
text = footnote.get(
"text"
)
if text:
texts.append(text)
return "; ".join(texts)
Preliminary values matter because some employment and earnings estimates are revised after their first release.
Validate BLS data before analysis
assert (
monthly["date"]
.notna()
.all()
)
assert not monthly.duplicated(
[
"series_id",
"date",
]
).any()
Check positive level values:
assert (
wide[
"CPI Unadjusted"
]
.dropna()
.gt(0)
.all()
)
Do not silently forward-fill missing observations. A missing value may mean the requested period is unavailable, a series starts later than expected, or something went wrong in the request.
Save the data and retrieval date
monthly.to_csv(
"bls_api_monthly.csv",
index=False,
)
wide.to_csv(
"bls_api_wide.csv"
)
from datetime import (
datetime,
timezone,
)
retrieved_at = (
datetime.now(
timezone.utc
)
)
print(retrieved_at.isoformat())
For a reproducible project, also keep the series IDs, start and end years, retrieval date, units, seasonal adjustment status, footnotes, transformation formulas, and Python package versions.
Common BLS API errors
Invalid Series
The series ID may be incorrect. Copy the official BLS series ID exactly.
No data returned
The requested year may be outside the available range. Try a shorter period and verify the series.
Too many years
Unregistered requests currently support up to 10 years per query. Registered requests support up to 20 years.
Too many series
The current limits are 25 series for unregistered requests and 50 for registered requests.
HTTP 429
You may be sending requests too quickly or exceeding an API limit.
HTTP 415
Check the request body and content type. For JSON POST requests, send a valid JSON body.
BLS says the request succeeded but returns a message
Always inspect data["status"] and data["message"].
Seasonally adjusted vs not seasonally adjusted CPI
Use not seasonally adjusted CPI when you want to reproduce the common BLS 12-month all-items inflation measure.
Use seasonally adjusted CPI when analyzing month-to-month changes.
Always state the exact series ID in a report or chart.
Why your calculated inflation may differ from a BLS headline
Several things can create small differences. You may be using seasonally adjusted instead of unadjusted CPI, a different CPI population, a different item category, a different geographic series, rounded values, or revised seasonal factors.
Check your series before assuming the calculation is wrong.
CPI-U vs CPI-W
CPI-U represents all urban consumers. CPI-W represents urban wage earners and clerical workers.
BLS also publishes the Chained Consumer Price Index for All Urban Consumers, or C-CPI-U.
This tutorial uses CPI-U because it is the most widely cited general CPI measure.
Limitations to remember
CPI is a broad average. An individual household can experience a different rate of price change because spending patterns vary.
Housing, transportation, food, energy, medical expenses, location, and household structure can all affect the prices a particular household faces.
Employment and earnings data also have limitations. Average hourly earnings can change because the mix of workers or industries changes, and recent employment observations can be revised.
A chart showing wages and inflation moving together does not prove that one caused the other.
Frequently asked questions
What is the BLS API?
The BLS Public Data API lets software retrieve published Bureau of Labor Statistics time series.
Is the BLS API free?
Yes. Basic public access is available without a usage fee.
Do I need a BLS API key?
Not for basic unregistered requests. Registration is needed for higher Version 2 limits and extra features.
What is the BLS API endpoint?
https://api.bls.gov/publicAPI/v2/timeseries/data/
Which CPI series should I use for annual inflation?
CUUR0000SA0
This is the not seasonally adjusted CPI-U, U.S. city average, all items series used in this tutorial for the common 12-month calculation.
Which CPI series should I use for monthly inflation?
CUSR0000SA0
This is the seasonally adjusted all-items CPI-U series used in this tutorial for month-to-month analysis.
What does M13 mean?
For some monthly BLS series, M13 represents an annual average rather than a calendar month.
How many series can I request?
BLS currently allows up to 25 series per query for unregistered access and up to 50 for registered access.
How many years can I request?
The current limit is 10 years per query for unregistered access and 20 years for registered access.
Can employment data change after I download it?
Yes. Employment estimates can be revised after initial publication, and recent values may be marked preliminary.
Can I use the BLS API for a dashboard?
Yes. Cache responses instead of requesting the same data every time a page loads.
Final workflow
- Identify the exact BLS series you need.
- Check its units and seasonal adjustment status.
- Use GET for a simple single-series request.
- Use POST when you need several series or a selected year range.
- Check both the HTTP response and the BLS
statusandmessagefields. - Keep monthly periods
M01throughM12. - Convert dates and values explicitly.
- Keep footnotes and preliminary markers.
- Use not seasonally adjusted CPI for the common 12-month CPI calculation.
- Use seasonally adjusted CPI for month-to-month analysis.
- Validate the cleaned data.
- Save the series IDs, retrieval date, and transformations.
- Re-run the code when you need current figures instead of copying values from an old article.
The most important part is choosing the correct series.
Once the series ID and seasonal adjustment are clear, the BLS API is straightforward to use with Python and pandas.
Official references
- BLS Public Data API FAQ
- BLS API Version 2 request documentation
- Official BLS Python examples
- BLS CPI series ID documentation
- Consumer Price Index overview
About the author
dataclue publishes practical tutorials on Python, statistics, APIs, economic data, and research workflows.
