CPI Analysis Python Tutorial: Monthly Reproducible Inflation Analysis
Build one repeatable BLS workflow for headline inflation, core CPI, short term momentum, charts, validation, and monthly updates.
CPI Analysis Python Tutorial for Monthly Reproducible Inflation Analysis
The latest published U.S. CPI report is for June 2026. Headline CPI fell 0.4 percent from May on a seasonally adjusted basis and was 3.5 percent higher than a year earlier. Core CPI, which removes food and energy, was unchanged for the month and rose 2.6 percent over 12 months. This CPI analysis Python tutorial shows how to rebuild those measures from official BLS index data, then rerun the same notebook after every monthly release.
As of August 9, 2026, the July 2026 CPI release has not been published. BLS has scheduled it for August 12, 2026 at 8:30 a.m. Eastern Time. That timing is important because a reproducible monthly page should update from the newest official release, not from a number copied into an old article.
Key findings from the June 2026 CPI release
The June report gives a useful example of why monthly and yearly inflation measures should be read together.
-
Headline CPI decreased 0.4 percent from May after increasing 0.5 percent in May.
-
Headline CPI was 3.5 percent higher than in June 2025. That was lower than the 4.2 percent 12 month rate reported for May.
-
Core CPI was unchanged in June after rising 0.2 percent in May.
-
Core CPI increased 2.6 percent over the year, down from 2.9 percent in May.
-
Energy prices fell 5.7 percent in June and were a major reason headline CPI declined during the month.
-
Shelter still increased 0.1 percent in June and was 3.3 percent higher over the year.
-
Lower inflation does not mean the overall price level has returned to an earlier level. It means the rate of price increase is slower. A monthly decline in the overall index can occur even while the 12 month price level remains higher.
The official source for these figures is the U.S. Bureau of Labor Statistics June 2026 Consumer Price Index release.
Latest CPI snapshot
The first table gives a fast reading of the current release. The monthly measures are seasonally adjusted. The 12 month measures are not seasonally adjusted, following normal BLS presentation.
| Measure | June 2026 | May 2026 | How to read it |
|---|---|---|---|
| Headline CPI monthly change | -0.4% | 0.5% | Short term change in the all items index |
| Headline CPI 12 month change | 3.5% | 4.2% | Standard yearly headline inflation rate |
| Core CPI monthly change | 0.0% | 0.2% | Short term change excluding food and energy |
| Core CPI 12 month change | 2.6% | 2.9% | Standard yearly core inflation rate |
| Energy monthly change | -5.7% | 3.9% | Large monthly energy reversal |
| Shelter monthly change | 0.1% | 0.3% | Continued positive shelter inflation |
The short term momentum measures later in the tutorial are calculated from index levels rather than typed by hand. That matters because BLS publishes index values with more precision than the one decimal monthly changes shown in the news release.
What CPI measures and what this Python project will do
The Consumer Price Index measures changes over time in prices paid by consumers for a basket of goods and services. The CPI for All Urban Consumers, often called CPI U, represents the spending patterns of more than 90 percent of the U.S. population.
The index level itself is not an inflation rate. If CPI is 333.952, that number is an index value relative to a historical base period. Inflation is calculated from the percentage change in the index between two dates.
This project uses official BLS time series data to build four core measures:
-
Headline CPI, not seasonally adjusted, for the standard 12 month inflation rate.
-
Headline CPI, seasonally adjusted, for monthly changes and short term momentum.
-
Core CPI, not seasonally adjusted, for the standard 12 month core rate.
-
Core CPI, seasonally adjusted, for monthly core changes and short term momentum.
The notebook then calculates yearly inflation, monthly inflation, three month annualized momentum, six month annualized momentum, recent history, charts, category movements, validation checks, and an animated CPI Momentum Pulse.
Why a monthly reproducible CPI workflow is useful
A one time CPI chart is easy to make. A good monthly research page is harder.
Each new release changes the latest observation. Seasonally adjusted history can also change because BLS updates seasonal factors each year. A reliable process should therefore rerun the analysis rather than append one new row and assume the past is fixed.
A reproducible workflow has five advantages.
-
The same formulas are used every month.
-
The latest month is checked across every series before analysis begins.
-
Historical seasonal revisions are captured automatically.
-
Every table and chart comes from the same cleaned DataFrame.
-
The article can show when the data were retrieved and which release they describe.
This is the main value of a monthly CPI analysis Python workflow. The code becomes a small research system, not a disposable example.
BLS data source and API limits
The tutorial uses the BLS Public Data API Version 2 endpoint:
https://api.bls.gov/publicAPI/v2/timeseries/data/
BLS currently documents the following registered user limits for Version 2:
| Limit | Registered Version 2 |
|---|---|
| Series per query | 50 |
| Years per query | 20 |
| Queries per day | 500 |
| Request rate | 50 requests per 10 seconds |
| Net and percent calculations | Available |
| Annual averages | Available |
BLS also notes that API results are returned with the most recent observations first. That detail is easy to miss. If you calculate pct_change() before sorting the dates, you can calculate changes in the wrong direction.
Table 1. Core CPI Series ID dictionary
Keep all important Series IDs in one place near the top of the notebook.
| Series ID | Name in code | Seasonal status | Main role |
|---|---|---|---|
CUUR0000SA0 |
headline_nsa |
Not seasonally adjusted | Headline 12 month inflation |
CUSR0000SA0 |
headline_sa |
Seasonally adjusted | Headline monthly change and momentum |
CUUR0000SA0L1E |
core_nsa |
Not seasonally adjusted | Core 12 month inflation |
CUSR0000SA0L1E |
core_sa |
Seasonally adjusted | Core monthly change and momentum |
Use the dictionary directly in code:
SERIES = {
"CUUR0000SA0": {
"name": "headline_nsa",
"label": "Headline CPI",
"seasonal": "Not seasonally adjusted",
"role": "12 month headline inflation",
},
"CUSR0000SA0": {
"name": "headline_sa",
"label": "Headline CPI",
"seasonal": "Seasonally adjusted",
"role": "Monthly headline inflation and momentum",
},
"CUUR0000SA0L1E": {
"name": "core_nsa",
"label": "Core CPI",
"seasonal": "Not seasonally adjusted",
"role": "12 month core inflation",
},
"CUSR0000SA0L1E": {
"name": "core_sa",
"label": "Core CPI",
"seasonal": "Seasonally adjusted",
"role": "Monthly core inflation and momentum",
},
}
This dictionary prevents a common mistake. The letters U and S near the start of a CPI Series ID identify unadjusted and seasonally adjusted versions of the series. Do not switch them without understanding how the measure will be used.
Headline CPI versus core CPI
Headline CPI includes the full consumer basket.
Core CPI removes food and energy. Those categories are excluded because they can be unusually volatile from month to month. Core CPI does not mean food and energy are unimportant. It is a separate measure that can make underlying price pressure easier to study.
The June 2026 report shows the difference clearly. Headline CPI fell 0.4 percent during the month as energy dropped sharply. Core CPI was unchanged. Over 12 months, headline inflation was 3.5 percent and core inflation was 2.6 percent.
Neither measure should replace the other. Headline CPI describes the full basket. Core CPI helps study a less volatile part of that basket.
Seasonally adjusted versus not seasonally adjusted CPI
Seasonal adjustment removes price patterns that tend to happen at similar times each year. Examples can include holiday sales, weather patterns, model changeovers, and normal production cycles.
BLS says seasonally adjusted changes are usually preferred when analyzing short term price trends. For that reason, this tutorial uses seasonally adjusted indexes for monthly, three month, and six month calculations.
For the standard 12 month inflation comparison, this tutorial uses not seasonally adjusted indexes. That matches the normal 12 month presentation in the BLS CPI release and compares actual index levels one year apart.
Why past seasonally adjusted values can change
BLS updates CPI seasonal factors each February. The new factors are applied to the previous five years of seasonally adjusted data. Older seasonally adjusted indexes are considered final.
That means a notebook should not simply download one new month and attach it to an old saved seasonally adjusted history forever. After the February update, rerun the recent history so revised values flow through the monthly and momentum calculations.
Install the Python packages
Use Python 3.10 or newer for the code in this tutorial.
python -m pip install requests pandas matplotlib plotly jupyter
The packages have simple roles.
| Package | Purpose |
|---|---|
requests |
Sends API requests |
pandas |
Cleans and transforms monthly CPI data |
matplotlib |
Creates publication ready static charts |
plotly |
Creates the CPI Momentum Pulse animation |
jupyter |
Runs the full analysis as a notebook |
Store the BLS API key securely
Register for the BLS Public Data API, then store the registration key outside the notebook.
macOS or Linux
export BLS_API_KEY="your_registration_key"
Windows PowerShell
$env:BLS_API_KEY="your_registration_key"
Read the key in Python
import os
BLS_API_KEY = os.getenv("BLS_API_KEY")
if not BLS_API_KEY:
raise RuntimeError(
"BLS_API_KEY is not set. Add your BLS registration key first."
)
Do not publish a real key in GitHub, a public notebook, or an article code block.
Create a reusable BLS API request function
The API client should request several series at once, use a timeout, detect HTTP errors, inspect BLS response status, and retry temporary server problems without retrying a bad Series ID forever.
import json
import os
from typing import Iterable
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
BLS_URL = "https://api.bls.gov/publicAPI/v2/timeseries/data/"
def build_session() -> requests.Session:
retry = Retry(
total=4,
backoff_factor=1,
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=("POST",),
respect_retry_after_header=True,
)
session = requests.Session()
session.mount(
"https://",
HTTPAdapter(max_retries=retry),
)
return session
SESSION = build_session()
def bls_request(
series_ids: Iterable[str],
start_year: int,
end_year: int,
) -> dict:
registration_key = os.getenv("BLS_API_KEY")
if not registration_key:
raise RuntimeError("BLS_API_KEY is not set.")
payload = {
"seriesid": list(series_ids),
"startyear": str(start_year),
"endyear": str(end_year),
"registrationkey": registration_key,
}
response = SESSION.post(
BLS_URL,
json=payload,
timeout=30,
)
response.raise_for_status()
data = response.json()
if data.get("status") != "REQUEST_SUCCEEDED":
message = "; ".join(data.get("message", []))
raise RuntimeError(
f"BLS API request failed: {message or data.get('status')}"
)
return data
Request the four core series together so they share the same retrieval time and date window.
from datetime import datetime, timezone
current_year = datetime.now(timezone.utc).year
start_year = current_year - 10
raw_payload = bls_request(
SERIES.keys(),
start_year=start_year,
end_year=current_year,
)
retrieved_at = datetime.now(timezone.utc).isoformat()
A ten year default gives enough history for the main chart while staying inside the registered API limit.
Save the raw response before cleaning it
A reproducible analysis should keep a copy of what was downloaded.
from pathlib import Path
raw_dir = Path("data/raw")
raw_dir.mkdir(parents=True, exist_ok=True)
raw_file = raw_dir / f"bls_cpi_{current_year}.json"
raw_file.write_text(
json.dumps(raw_payload, indent=2),
encoding="utf-8",
)
A raw copy makes later debugging easier. If a historical value changes after a seasonal revision, you can compare the old and new downloads.
Convert BLS period codes to real monthly dates
BLS uses values such as M01, M02, and M12 for months. It can also return M13, which is an annual average rather than a thirteenth month.
The parser should keep M01 through M12 and exclude M13 from monthly calculations.
import pandas as pd
def bls_month_to_date(year: str, period: str) -> pd.Timestamp | pd.NaT:
if not period.startswith("M"):
return pd.NaT
month_text = period[1:]
if not month_text.isdigit():
return pd.NaT
month = int(month_text)
if month < 1 or month > 12:
return pd.NaT
return pd.Timestamp(
year=int(year),
month=month,
day=1,
)
The explicit range check is important. It prevents M13 from entering the monthly DataFrame.
Parse the four CPI series
def parse_bls_payload(
payload: dict,
series_map: dict,
) -> pd.DataFrame:
rows = []
series_list = (
payload
.get("Results", {})
.get("series", [])
)
for series in series_list:
series_id = series["seriesID"]
if series_id not in series_map:
continue
column_name = series_map[series_id]["name"]
for obs in series.get("data", []):
date = bls_month_to_date(
obs["year"],
obs["period"],
)
if pd.isna(date):
continue
rows.append({
"series_id": series_id,
"name": column_name,
"date": date,
"value": pd.to_numeric(
obs.get("value"),
errors="coerce",
),
})
long = pd.DataFrame(rows)
if long.empty:
raise ValueError("No monthly observations were returned.")
if long.duplicated(["series_id", "date"]).any():
raise ValueError("Duplicate series and month rows found.")
panel = (
long
.pivot(
index="date",
columns="name",
values="value",
)
.sort_index()
)
return panel
monthly = parse_bls_payload(
raw_payload,
SERIES,
)
The final .sort_index() step is not optional. BLS Version 2 returns recent observations first, so sorting must happen before any lag or percentage change.
Validate the latest month before calculating inflation
A clean table can still be wrong if one series is missing the latest observation.
def require_common_latest_month(
panel: pd.DataFrame,
required_columns: list[str],
) -> pd.Timestamp:
latest_dates = {
column: panel[column].last_valid_index()
for column in required_columns
}
unique_dates = set(latest_dates.values())
if len(unique_dates) != 1:
raise ValueError(
f"Core CPI series do not share one latest month: {latest_dates}"
)
latest = unique_dates.pop()
if latest is None:
raise ValueError("No latest CPI month was found.")
return latest
LATEST_MONTH = require_common_latest_month(
monthly,
[
"headline_nsa",
"headline_sa",
"core_nsa",
"core_sa",
],
)
This is safer than letting pandas quietly join June headline data with May core data.
Calculate headline and core 12 month inflation
Use the not seasonally adjusted indexes.
monthly["headline_yoy"] = (
monthly["headline_nsa"]
.pct_change(
periods=12,
fill_method=None,
)
* 100
)
monthly["core_yoy"] = (
monthly["core_nsa"]
.pct_change(
periods=12,
fill_method=None,
)
* 100
)
The formula is:
12 month inflation = 100 × ((current index / index 12 months earlier) - 1)
The key point is that the CPI index is converted into a rate of change. Do not label the raw index level as inflation.
Calculate headline and core monthly inflation
Use the seasonally adjusted indexes for short term changes.
monthly["headline_mom"] = (
monthly["headline_sa"]
.pct_change(
periods=1,
fill_method=None,
)
* 100
)
monthly["core_mom"] = (
monthly["core_sa"]
.pct_change(
periods=1,
fill_method=None,
)
* 100
)
A monthly reading can move sharply because of one category. That is why the next section adds three month and six month measures instead of treating one month as a full trend.
Calculate three month and six month annualized inflation momentum
Annualizing a short period answers a specific question: what yearly pace would result if the price change over that short period continued at the same compounded rate?
It is not a forecast.
def annualized_change(
series: pd.Series,
months: int,
) -> pd.Series:
periods_per_year = 12 / months
return (
(
series
/ series.shift(months)
) ** periods_per_year
- 1
) * 100
monthly["headline_3m_ann"] = annualized_change(
monthly["headline_sa"],
months=3,
)
monthly["core_3m_ann"] = annualized_change(
monthly["core_sa"],
months=3,
)
monthly["headline_6m_ann"] = annualized_change(
monthly["headline_sa"],
months=6,
)
monthly["core_6m_ann"] = annualized_change(
monthly["core_sa"],
months=6,
)
The formulas are:
Three month annualized rate = 100 × ((current index / index 3 months earlier)^4 - 1)
Six month annualized rate = 100 × ((current index / index 6 months earlier)^2 - 1)
Do not call these the official yearly inflation rate. The official 12 month rate compares the current index directly with the same month one year earlier.
Table 2. Build the latest CPI snapshot from code
latest = monthly.loc[LATEST_MONTH]
prior = monthly.loc[:LATEST_MONTH].iloc[-2]
latest_snapshot = pd.DataFrame({
"measure": [
"Headline monthly change",
"Headline 12 month inflation",
"Core monthly change",
"Core 12 month inflation",
"Headline 3 month annualized momentum",
"Core 3 month annualized momentum",
"Headline 6 month annualized momentum",
"Core 6 month annualized momentum",
],
"value": [
latest["headline_mom"],
latest["headline_yoy"],
latest["core_mom"],
latest["core_yoy"],
latest["headline_3m_ann"],
latest["core_3m_ann"],
latest["headline_6m_ann"],
latest["core_6m_ann"],
],
})
latest_snapshot["value"] = (
latest_snapshot["value"]
.round(2)
)
latest_snapshot
When the notebook is run after a new CPI release, this table updates automatically.
Table 3. Recent monthly history
The current public BLS releases show the following recent history. Monthly changes are seasonally adjusted. The 12 month rates are not seasonally adjusted.
| Month | Headline monthly | Headline 12 month | Core monthly | Core 12 month |
|---|---|---|---|---|
| Dec. 2025 | 0.3% | 2.7% | 0.2% | 2.6% |
| Jan. 2026 | 0.2% | 2.4% | 0.3% | 2.5% |
| Feb. 2026 | 0.3% | 2.4% | 0.2% | 2.5% |
| Mar. 2026 | 0.9% | 3.3% | 0.2% | 2.6% |
| Apr. 2026 | 0.6% | 3.8% | 0.4% | 2.8% |
| May 2026 | 0.5% | 4.2% | 0.2% | 2.9% |
| Jun. 2026 | -0.4% | 3.5% | 0.0% | 2.6% |
The pattern shows why a single release can be misleading. Headline monthly inflation accelerated sharply in March, April, and May, then reversed in June as energy fell. Core monthly inflation was much steadier over the same period.
The notebook can generate a longer 12 to 18 month history directly from the cleaned panel:
recent_history = (
monthly[
[
"headline_mom",
"headline_yoy",
"core_mom",
"core_yoy",
"core_3m_ann",
"core_6m_ann",
]
]
.tail(18)
.round(2)
)
Plot 1. Headline and core 12 month inflation
The first publication chart should show the slower moving yearly picture.
import matplotlib.pyplot as plt
plot_data = monthly[
["headline_yoy", "core_yoy"]
].dropna().tail(120)
fig, ax = plt.subplots(figsize=(11, 6))
ax.plot(
plot_data.index,
plot_data["headline_yoy"],
label="Headline CPI, 12 month",
)
ax.plot(
plot_data.index,
plot_data["core_yoy"],
label="Core CPI, 12 month",
)
ax.axhline(0, linewidth=0.8)
ax.set_title("U.S. Headline and Core CPI Inflation")
ax.set_xlabel("Date")
ax.set_ylabel("Percent")
ax.legend()
ax.grid(alpha=0.25)
fig.tight_layout()
fig.savefig(
"cpi-analysis-python-headline-core-inflation.png",
dpi=160,
bbox_inches="tight",
)
plt.show()
The key interpretation is the gap between the lines. A large headline move with a steadier core rate can point to food or energy volatility. A broad move in both measures suggests price pressure is more widespread.
Suggested alt text: Headline and core U.S. CPI 12 month inflation rates calculated in Python.
Plot 2. Headline and core monthly inflation
Recent monthly CPI changes from official BLS releases.
The monthly chart should use seasonally adjusted data and show only a recent window so individual changes remain readable.
monthly_plot = monthly[
["headline_mom", "core_mom"]
].dropna().tail(36)
fig, ax = plt.subplots(figsize=(11, 6))
ax.plot(
monthly_plot.index,
monthly_plot["headline_mom"],
label="Headline monthly change",
)
ax.plot(
monthly_plot.index,
monthly_plot["core_mom"],
label="Core monthly change",
)
ax.axhline(0, linewidth=0.8)
ax.set_title("Monthly U.S. CPI Changes, Seasonally Adjusted")
ax.set_xlabel("Date")
ax.set_ylabel("Percent")
ax.legend()
ax.grid(alpha=0.25)
fig.tight_layout()
fig.savefig(
"cpi-analysis-python-monthly-inflation.png",
dpi=160,
bbox_inches="tight",
)
plt.show()
Monthly inflation is noisy. Read this chart beside the momentum chart rather than treating the latest point as a complete trend.
Plot 3. Three month, six month, and 12 month core inflation
This chart compares recent core momentum with the slower 12 month rate.
momentum = monthly[
[
"core_3m_ann",
"core_6m_ann",
"core_yoy",
]
].dropna().tail(60)
fig, ax = plt.subplots(figsize=(11, 6))
ax.plot(
momentum.index,
momentum["core_3m_ann"],
label="Core 3 month annualized",
)
ax.plot(
momentum.index,
momentum["core_6m_ann"],
label="Core 6 month annualized",
)
ax.plot(
momentum.index,
momentum["core_yoy"],
label="Core 12 month",
)
ax.set_title("Core CPI Inflation Momentum")
ax.set_xlabel("Date")
ax.set_ylabel("Percent")
ax.legend()
ax.grid(alpha=0.25)
fig.tight_layout()
fig.savefig(
"cpi-analysis-python-inflation-momentum.png",
dpi=160,
bbox_inches="tight",
)
plt.show()
If the three month line falls below the 12 month line for several months, recent core inflation is running cooler than the longer comparison. If the three month line rises well above it, recent momentum is hotter.
Neither case guarantees what inflation will do next.
Analyze major CPI categories
A category table makes the headline number easier to understand. It should show price movements without pretending that the category with the largest percentage change made the largest contribution.
Contribution depends on both price movement and the category weight in the CPI basket.
Table 4. June 2026 major category movement
| Category | June monthly change | June 12 month change | May monthly change |
|---|---|---|---|
| Food | 0.2% | 3.0% | 0.2% |
| Energy | -5.7% | 15.7% | 3.9% |
| Shelter | 0.1% | 3.3% | 0.3% |
| New vehicles | 0.0% | 0.5% | -0.3% |
| Used cars and trucks | -0.2% | -1.8% | 0.1% |
| Transportation services | -0.3% | 3.4% | -0.6% |
| Medical care services | -0.1% | 2.9% | 0.5% |
| Recreation | 0.5% | 2.8% | 0.3% |
Energy is the clearest June story. It fell sharply in the month but remained much higher than a year earlier. Shelter rose only 0.1 percent in June, its smallest one month increase since January 2021 according to BLS.
Optional category Series IDs
When category analysis is added to the notebook, verify the current BLS Series ID before publication. Common seasonally adjusted examples include:
CATEGORY_SERIES = {
"CUSR0000SAF1": "food_sa",
"CUSR0000SA0E": "energy_sa",
"CUSR0000SAH1": "shelter_sa",
"CUSR0000SETA01": "new_vehicles_sa",
"CUSR0000SETA02": "used_cars_trucks_sa",
"CUSR0000SAM2": "medical_care_services_sa",
"CUSR0000SAR": "recreation_sa",
"CUSR0000SAS4": "transportation_services_sa",
}
For a 12 month category table, also use the matching not seasonally adjusted series or calculate the yearly change from verified unadjusted category indexes. Keep the monthly and yearly seasonal choices explicit.
Plot 4. Category movement chart
A horizontal bar chart is easier to read than a heatmap when the list is short.
category_latest = pd.DataFrame({
"category": [
"Food",
"Energy",
"Shelter",
"New vehicles",
"Used cars and trucks",
"Transportation services",
"Medical care services",
"Recreation",
],
"monthly_change": [
0.2, -5.7, 0.1, 0.0,
-0.2, -0.3, -0.1, 0.5,
],
"twelve_month_change": [
3.0, 15.7, 3.3, 0.5,
-1.8, 3.4, 2.9, 2.8,
],
})
In a live monthly notebook, build this table from downloaded category series instead of keeping the current values above. The hard coded example is shown only to make the plotting method clear.
fig, ax = plt.subplots(figsize=(10, 6))
plot_categories = (
category_latest
.sort_values("twelve_month_change")
)
ax.barh(
plot_categories["category"],
plot_categories["twelve_month_change"],
)
ax.axvline(0, linewidth=0.8)
ax.set_title("Major CPI Category 12 Month Changes")
ax.set_xlabel("Percent")
ax.set_ylabel("Category")
ax.grid(axis="x", alpha=0.25)
fig.tight_layout()
fig.savefig(
"cpi-analysis-python-category-changes.png",
dpi=160,
bbox_inches="tight",
)
plt.show()
Do not write that the longest bar was the largest contributor to headline CPI unless official effect or contribution data support that claim.
Table 5. Inflation formula reference
| Metric | Source index | Seasonal status | Formula | Best use |
|---|---|---|---|---|
| Headline 12 month inflation | All items | Not seasonally adjusted | 100 × ((CPI_t / CPI_t12) - 1) |
Standard headline yearly rate |
| Core 12 month inflation | All items less food and energy | Not seasonally adjusted | 100 × ((Core_t / Core_t12) - 1) |
Standard core yearly rate |
| Headline monthly change | All items | Seasonally adjusted | 100 × ((CPI_t / CPI_t1) - 1) |
Short term headline movement |
| Core monthly change | Core CPI | Seasonally adjusted | 100 × ((Core_t / Core_t1) - 1) |
Short term core movement |
| Three month annualized momentum | Seasonally adjusted index | Seasonally adjusted | 100 × ((Index_t / Index_t3)^4 - 1) |
Recent momentum |
| Six month annualized momentum | Seasonally adjusted index | Seasonally adjusted | 100 × ((Index_t / Index_t6)^2 - 1) |
Smoother recent momentum |
The notation t12 means the observation 12 months earlier. It does not mean subtract 12 from the index value.
Build the CPI Momentum Pulse animation

The animation should have a purpose. Each frame is one month. The bars compare four measures:
-
Headline 12 month inflation.
-
Core 12 month inflation.
-
Headline three month annualized momentum.
-
Core three month annualized momentum.
The animation makes it easier to see when short term momentum moves before the slower yearly measures.
Prepare the animation data
animation_wide = (
monthly[
[
"headline_yoy",
"core_yoy",
"headline_3m_ann",
"core_3m_ann",
]
]
.dropna()
.tail(72)
.rename(columns={
"headline_yoy": "Headline 12 month",
"core_yoy": "Core 12 month",
"headline_3m_ann": "Headline 3 month annualized",
"core_3m_ann": "Core 3 month annualized",
})
)
animation = (
animation_wide
.reset_index()
.melt(
id_vars="date",
var_name="indicator",
value_name="percent",
)
)
animation["month"] = (
animation["date"]
.dt.strftime("%Y %b")
)
Create the Plotly animation
import plotly.express as px
full_min = animation["percent"].min()
full_max = animation["percent"].max()
padding = max(1.0, (full_max - full_min) * 0.12)
fig = px.bar(
animation,
x="indicator",
y="percent",
animation_frame="month",
category_orders={
"indicator": [
"Headline 12 month",
"Core 12 month",
"Headline 3 month annualized",
"Core 3 month annualized",
]
},
range_y=[
min(0, full_min - padding),
full_max + padding,
],
text_auto=".1f",
title="CPI Momentum Pulse",
)
fig.update_layout(
xaxis_title="Inflation measure",
yaxis_title="Percent",
showlegend=False,
)
fig.write_html(
"cpi-momentum-pulse.html",
include_plotlyjs="cdn",
)
fig.show()
Keep the vertical scale fixed. If the scale changes in every frame, small movements can look much larger than they are.
The caption should say: Three month annualized measures are short term momentum indicators. They are not forecasts and they are not the official 12 month inflation rate.
How to interpret the latest monthly release
A useful interpretation begins with facts and then moves to cautious analysis.
Headline inflation cooled sharply in June
The all items CPI fell 0.4 percent during June after rising 0.5 percent in May. The yearly rate fell from 4.2 percent to 3.5 percent.
That is disinflation over the 12 month comparison. Inflation slowed. It does not mean all consumer prices fell back to where they were one year earlier.
Core inflation also cooled
Core CPI was unchanged in June. Its 12 month rate fell from 2.9 percent in May to 2.6 percent in June.
That matters because the decline was not only a headline energy story. At the same time, one month is not enough to establish a lasting trend.
Energy drove much of the monthly headline decline
Energy fell 5.7 percent in June after several months of large increases. Gasoline fell 9.7 percent for the month. The yearly energy rate was still 15.7 percent.
This combination shows why monthly and yearly comparisons can point in different directions.
Shelter was still positive, but the monthly pace was small
Shelter rose 0.1 percent in June and 3.3 percent over 12 months. BLS described the June shelter increase as the smallest one month change since January 2021.
A slower shelter reading can matter for core inflation because shelter has a large weight in the consumer basket. Still, it should be followed for several months before calling it a new trend.
Seasonal adjustment revisions and why past values can change
Seasonally adjusted CPI is not a permanently fixed historical series when first published.
BLS recalculates seasonal factors each year and revises the previous five years of seasonally adjusted indexes. The February 2026 update revised seasonally adjusted data for 2021 through 2025.
This has a direct effect on reproducible analysis.
Suppose you calculated a three month annualized core rate in December and saved only the final percentage. If seasonal factors later change one or more of the three index values, the correct historical momentum rate may also change.
A good monthly process therefore saves raw data but also reruns the recent history after seasonal updates.
Table 6. Reproducibility record
Every published monthly update should include a small record like this.
| Field | Value |
|---|---|
| Data source | U.S. Bureau of Labor Statistics Public Data API |
| Core series | CUUR0000SA0, CUSR0000SA0, CUUR0000SA0L1E, CUSR0000SA0L1E |
| Latest observation | Generated by notebook |
| Release date | Checked against official BLS release calendar |
| Retrieval time | UTC timestamp saved by notebook |
| Monthly seasonal choice | Seasonally adjusted |
| 12 month seasonal choice | Not seasonally adjusted |
| Annual seasonal revision rule | Rerun recent history after February seasonal update |
| Notebook version | Save with each publication |
Create the record in Python:
from datetime import datetime, timezone
reproducibility = pd.DataFrame([{
"source": "U.S. Bureau of Labor Statistics Public Data API",
"latest_observation": LATEST_MONTH.strftime("%Y-%m"),
"retrieved_at_utc": datetime.now(timezone.utc).isoformat(),
"series_count": len(SERIES),
"monthly_method": "Seasonally adjusted index change",
"yearly_method": "Not seasonally adjusted 12 month change",
"seasonal_revision_note": "Rerun recent history after annual BLS seasonal update",
"notebook_version": "1.0",
}])
Validation checks before publishing
Validation should run before the final tables and charts are exported.
Confirm ascending dates
assert monthly.index.is_monotonic_increasing
Confirm there are no duplicate months
assert not monthly.index.duplicated().any()
Confirm all four series share the latest month
require_common_latest_month(
monthly,
[
"headline_nsa",
"headline_sa",
"core_nsa",
"core_sa",
],
)
Confirm index values are positive where present
for column in [
"headline_nsa",
"headline_sa",
"core_nsa",
"core_sa",
]:
assert monthly[column].dropna().gt(0).all()
Confirm yearly rates use a real 12 month lag
sample_date = monthly["headline_yoy"].dropna().index[-1]
expected = (
monthly.loc[sample_date, "headline_nsa"]
/ monthly.loc[sample_date - pd.DateOffset(months=12), "headline_nsa"]
- 1
) * 100
assert math.isclose(
monthly.loc[sample_date, "headline_yoy"],
expected,
rel_tol=1e-10,
)
Confirm the latest month matches the official release
This check cannot be fully automated from the time series response alone. Compare the notebook latest month with the official CPI release page or release calendar before publishing the update.
Common BLS API and CPI analysis errors
Error 1. The percentage change runs backward
Cause: the API returned newest observations first and the DataFrame was not sorted.
Fix: sort dates ascending before pct_change(), shift(), or rolling calculations.
Error 2. M13 appears as a monthly observation
Cause: the annual average period was not removed.
Fix: accept only M01 through M12 in the monthly parser.
Error 3. Monthly inflation looks different from the BLS release
Cause: the calculation used the not seasonally adjusted index.
Fix: use the seasonally adjusted series for normal short term monthly analysis.
Error 4. The 12 month rate was calculated from the wrong series
Cause: the seasonally adjusted version was used without explaining the choice.
Fix: use the not seasonally adjusted series for the standard 12 month comparison in this workflow.
Error 5. Missing observations became zeros
Cause: numeric cleaning or filling replaced missing data with zero.
Fix: preserve missing values and investigate them. Zero is a real economic value, not a missing value marker.
Error 6. Old momentum rates no longer match
Cause: BLS revised seasonal factors.
Fix: download and recalculate the recent seasonal history after the annual update.
Error 7. A category is called the largest contributor because it had the biggest percent change
Cause: percentage change was confused with contribution.
Fix: a contribution calculation also needs category weights or official BLS effect data.
Monthly update checklist
Use the same process after each CPI release.
-
Check the official BLS CPI release schedule.
-
Confirm the new release is published before running the public update.
-
Run the notebook from the first cell.
-
Request enough history to capture recent seasonal revisions.
-
Confirm all four core CPI series have the same latest month.
-
Recalculate monthly, yearly, three month, and six month measures.
-
Regenerate every publication table and chart.
-
Regenerate the CPI Momentum Pulse animation.
-
Review BLS release notes for unusual category movements, corrections, or seasonal changes.
-
Update the key findings with the new notebook outputs.
-
Update the article observation month, release date, retrieval timestamp, and last updated date.
-
Save a short update log that records what changed.
-
After the annual February seasonal update, rerun the recent seasonally adjusted history instead of only appending the new January observation.
Limitations
This CPI analysis is descriptive. It measures what happened to consumer price indexes. It does not identify every cause of inflation.
First, CPI is an average for a large population. A household can experience a very different personal inflation rate because its spending mix is different.
Second, core CPI removes food and energy. That makes it useful for studying underlying movement, but it does not make those costs unimportant to households.
Third, annualized three month and six month rates can move quickly. They are momentum measures, not forecasts.
Fourth, seasonally adjusted history can be revised for up to five years.
Fifth, category percentage changes are not the same as contributions to the total CPI.
Sixth, one monthly release is not a trend. A stronger conclusion should be supported by several observations and, when relevant, other inflation measures such as PCE inflation.
Download the complete CPI analysis Python notebook
The supporting notebook should run from top to bottom after a valid BLS registration key is added. It includes:
-
Package imports.
-
API key setup.
-
Core Series ID dictionary.
-
Reusable BLS API client.
-
Raw response saving.
-
BLS month parsing.
-
Data cleaning and alignment checks.
-
Headline and core yearly inflation.
-
Headline and core monthly inflation.
-
Three month and six month annualized momentum.
-
Latest snapshot table.
-
Recent history table.
-
Matplotlib charts.
-
Category analysis structure.
-
Plotly CPI Momentum Pulse animation.
-
Validation checks.
-
Reproducibility record.
-
Saved output files.
Notebook filename:
cpi_analysis_python_monthly_reproducible.ipynb
Frequently asked questions
How do I download CPI data with Python?
Use the BLS Public Data API. Send the required Series IDs in a Version 2 request, convert the returned monthly observations into a pandas DataFrame, sort the dates, and calculate the changes you need.
What BLS CPI Series ID should I use in Python?
It depends on the measure. CUUR0000SA0 is headline CPI U, all items, not seasonally adjusted. CUSR0000SA0 is the seasonally adjusted version. CUUR0000SA0L1E and CUSR0000SA0L1E are the matching core CPI series excluding food and energy.
What is the difference between CUUR0000SA0 and CUSR0000SA0?
The first is not seasonally adjusted. The second is seasonally adjusted. This tutorial uses the unadjusted series for the standard 12 month change and the adjusted series for short term monthly analysis.
Should I use seasonally adjusted CPI for monthly inflation?
Usually, yes. BLS says seasonally adjusted changes are preferred for analyzing short term price trends because regular seasonal patterns have been removed.
How do I calculate year over year CPI inflation in Python?
Divide the current not seasonally adjusted CPI index by the index 12 months earlier, subtract one, and multiply by 100. In pandas, pct_change(periods=12, fill_method=None) * 100 performs the same calculation after dates are sorted.
How do I calculate month over month inflation in Python?
Use the seasonally adjusted CPI index and calculate the percentage change from the previous month.
How do I calculate core CPI in Python?
Core CPI is not created by subtracting food and energy from the headline index yourself. Use the official BLS all items less food and energy series.
How do I calculate three month annualized inflation?
Divide the current seasonally adjusted index by its level three months earlier, raise the result to the fourth power, subtract one, and multiply by 100.
Why can seasonally adjusted CPI history change?
BLS recalculates seasonal factors each year and applies the new factors to the previous five years of seasonally adjusted CPI data.
What is M13 in BLS API data?
M13 is an annual average period. It is not a thirteenth month and should be excluded from monthly calculations.
Does lower inflation mean prices are falling?
Not necessarily. Inflation can slow while the overall price level still rises. Falling inflation is often called disinflation. Deflation means the price level is actually declining over the comparison period.
How often should I update a CPI analysis page?
Update it after each official monthly CPI release. Also rerun recent seasonally adjusted history after the annual seasonal factor update.
Can I automate this monthly CPI notebook?
Yes. The notebook can be scheduled after CPI release dates, but the publication workflow should still confirm the release is available and review the BLS release notes before automatically publishing interpretation.
Can I compare CPI with wages, PCE inflation, or interest rates?
Yes. CPI can be combined with BLS earnings data for real wage analysis, BEA PCE inflation for measure comparison, and Federal Reserve or FRED interest rate data for broader economic context. Keep each source, frequency, and revision rule clear.
Related economic analysis
This tutorial fits naturally into a broader U.S. economic data site.
-
BLS API with Python: Link to the BLS API Python tutorial for CPI, employment, and wages.
-
CPI versus PCE inflation: Compare the two major U.S. consumer inflation measures.
-
Real wage growth: Deflate wage growth with CPI and explain what happens to purchasing power.
-
U.S. inflation data: Link back to a pillar page that explains official inflation sources and methods.
-
FRED API Python: Show an alternative route for downloading many economic time series.
-
Unemployment analysis Python: Add labor market context to monthly inflation analysis.
-
Time series validation: Use ordered validation for any inflation forecasting project.
Methodology and editorial policy
This article uses the U.S. Bureau of Labor Statistics as the technical authority for CPI definitions, release values, API behavior, and seasonal adjustment.
Current numbers in the article should be refreshed from the notebook or the official BLS release. The reusable code is the main source for calculated tables and charts. Manual values shown in the June 2026 example come directly from BLS releases and should be replaced during the next monthly update.
Facts and interpretation should remain separate. A sentence such as “core CPI was unchanged in June” is an observed fact. A sentence such as “this may suggest recent underlying pressure cooled” is interpretation and should be written with appropriate caution.
Corrections should be made when BLS publishes an erratum or when a code or transcription error is found. The page should show an accurate last updated date.
Official sources
Last updated: August 9, 2026
