Learn how to calculate MoM and YoY changes in pandas using pct_change() and shift() — the same logic as Excel formulas, but cleaner, faster, and scalable across multiple product lines or regions. Includes a complete business reporting example with formatted output.

Every business report eventually asks the same question: "How does this number compare to last month?" or "Are we up or down versus last year?" These comparisons — month-over-month (MoM) and year-over-year (YoY) changes — are the bread and butter of business reporting. They give raw numbers context. A revenue figure of $450,000 is interesting; a revenue figure that's up 18% from last year is a story.
If you've built these calculations in Excel, you know the pattern: you write a formula in one cell that references the cell above it or the cell twelve rows up, then drag it down the column and hope nothing goes wrong when rows shift. In pandas, the same logic applies — but it's cleaner, more scalable, and far less fragile. Once you understand the mechanics, you'll be building these calculations into reusable report pipelines that handle months or years of data without any manual formula-dragging.
By the end of this lesson, you'll be able to calculate MoM and YoY changes on real time-series data, handle the edge cases that trip up beginners, and produce a clean summary table ready for a stakeholder report.
What you'll learn:
shift() works and why it's the foundation for period comparisonspct_change() automates percentage change calculationsYou should be comfortable loading a DataFrame and working with its columns. If you're new to pandas, start with Your First pandas DataFrame: Loading CSV and Excel Files and Exploring Data before continuing. You should also understand how date columns work in pandas — if time-series data is new to you, Working with Dates and Time Series in pandas: Parsing, Resampling, and Rolling Windows covers the fundamentals.
Let's work with a realistic scenario. You're a data analyst at a mid-sized e-commerce company. Your database exports monthly revenue and order count data, and your manager wants a report showing MoM and YoY changes for each metric.
Here's a dataset we'll build and use throughout the lesson:
import pandas as pd
import numpy as np
# Simulate 3 years of monthly sales data
dates = pd.date_range(start='2022-01-01', periods=36, freq='MS') # 'MS' = Month Start
np.random.seed(42)
base_revenue = 400_000
# Add trend + seasonality + noise
trend = np.linspace(0, 100_000, 36)
seasonality = 30_000 * np.sin(np.linspace(0, 6 * np.pi, 36))
noise = np.random.normal(0, 10_000, 36)
revenue = base_revenue + trend + seasonality + noise
orders = (revenue / 85 + np.random.normal(0, 50, 36)).astype(int)
df = pd.DataFrame({
'month': dates,
'revenue': revenue.round(2),
'orders': orders
})
print(df.head(10))
month revenue orders
0 2022-01-01 395823.45 4651
1 2022-02-01 408742.18 4812
2 2022-03-01 434521.09 5121
3 2022-04-01 450312.77 5292
4 2022-05-01 445210.33 5238
5 2022-06-01 432180.91 5082
6 2022-07-01 418903.54 4928
7 2022-08-01 421450.22 4955
8 2022-09-01 441230.87 5189
9 2022-10-01 463291.44 5445
This gives us 36 rows — three full calendar years. Now let's build the comparison columns.
Before we touch pct_change(), it's worth understanding shift() because it's what makes period comparisons possible. The concept is simple: shift(n) moves all the values in a column down by n rows (or up if n is negative), filling the newly empty positions with NaN.
Think of it like holding a column of numbers and sliding it down one row. The value that was in row 1 is now in row 2, the value from row 2 is now in row 3, and so on. Row 0 becomes NaN because there's nothing above it to slide down.
Here's what it looks like in practice:
# See shift in action
demo = df[['month', 'revenue']].copy()
demo['revenue_prev_month'] = demo['revenue'].shift(1)
demo['revenue_prev_year'] = demo['revenue'].shift(12)
print(demo.head(15))
month revenue revenue_prev_month revenue_prev_year
0 2022-01-01 395823.45 NaN NaN
1 2022-02-01 408742.18 395823.45 NaN
2 2022-03-01 434521.09 408742.18 NaN
...
12 2023-01-01 458291.34 447832.11 395823.45
13 2023-02-01 471203.55 458291.34 408742.18
Notice what happened: revenue_prev_month lags by one row, so row 1 shows January's revenue. Row 12 (January 2023) now has February 2022 as its "previous month" and January 2022 as its "previous year" — exactly what you'd expect.
Key insight
shift(1) gives you last month's value in the same row as this month's value. shift(12) gives you the same month last year. This alignment is the foundation of every MoM and YoY calculation you'll ever build.
Now you can calculate the change manually:
df['mom_change_raw'] = df['revenue'] - df['revenue'].shift(1)
df['yoy_change_raw'] = df['revenue'] - df['revenue'].shift(12)
And the percentage change:
df['mom_pct'] = (df['revenue'] - df['revenue'].shift(1)) / df['revenue'].shift(1) * 100
df['yoy_pct'] = (df['revenue'] - df['revenue'].shift(12)) / df['revenue'].shift(12) * 100
This is valid and readable, but pandas gives you a shortcut.
pct_change() does exactly what you just did manually — it calculates the percentage change between each value and the value n periods before it. The default is 1 period (one row back), but you can pass any integer.
# Month-over-month percentage change (default: 1 period)
df['mom_pct'] = df['revenue'].pct_change(periods=1) * 100
# Year-over-year percentage change (12 months back)
df['yoy_pct'] = df['revenue'].pct_change(periods=12) * 100
print(df[['month', 'revenue', 'mom_pct', 'yoy_pct']].iloc[11:16].round(2))
month revenue mom_pct yoy_pct
11 2022-12-01 447832.11 1.42 NaN
12 2023-01-01 458291.34 2.33 15.78
13 2023-02-01 471203.55 2.82 15.26
14 2023-03-01 490832.21 4.17 12.96
15 2023-04-01 498021.44 1.46 10.60
The YoY column is NaN for the first 12 rows because there's no "same month last year" to compare against — that's correct behavior.
Tip
pct_change() returns a decimal by default (0.15 for 15%), not a percentage. Multiply by 100 to get human-readable percentages. This is a common source of confusion when you first start using it.
When you build MoM calculations, row 0 becomes NaN. When you build YoY calculations, the first 12 rows become NaN. These aren't errors — they're mathematically correct, because there's no prior period to compare against. But you need to handle them deliberately before handing your report to anyone.
You have three options:
Option 1: Drop them. If you're producing a report that only needs to show full comparisons, filter them out:
report_df = df.dropna(subset=['mom_pct', 'yoy_pct'])
Option 2: Keep them but label them clearly. This is better for transparency. You can fill NaN with a placeholder string in a separate display column:
df['yoy_display'] = df['yoy_pct'].apply(
lambda x: f"{x:+.1f}%" if pd.notna(x) else "N/A (first year)"
)
Option 3: Leave the NaNs. If you're feeding the DataFrame into another calculation or export, NaN is often the cleanest representation of missing data. Tools like Excel will display blank cells.
Warning
Never fill NaN period-change values with 0. A 0% change is a valid data point meaning "nothing changed." A missing value means "no comparison period exists." These are completely different things, and conflating them will cause confusion downstream.
For our report, we'll keep the NaNs but add formatted display columns later.
shift() and pct_change() are purely positional — they don't look at your date column to determine order. They look at row positions. If your DataFrame is not sorted chronologically, you'll get nonsense results without any error message.
# If you shuffle the data first...
df_shuffled = df.sample(frac=1, random_state=99)
df_shuffled['mom_pct_wrong'] = df_shuffled['revenue'].pct_change() * 100
# The result is meaningless because row 0 isn't January 2022 anymore
Always sort your data by date before running period comparisons:
df = df.sort_values('month').reset_index(drop=True)
And if you're working with data grouped by region, product line, or any other category, you must sort within each group before calculating changes — we'll cover that in the next section.
Warning
This is the most common silent mistake in period comparison calculations. pandas will happily calculate a "month-over-month" change between May 2023 and August 2022 if those rows happen to be adjacent. Always verify your sort order.
Real business data almost always has more than one dimension. Your revenue data isn't just one column — it's broken down by region, product category, or sales rep. You need MoM and YoY changes within each group, not across groups.
Let's extend our dataset:
# Create regional breakdown
regions = ['North', 'South', 'East', 'West']
rows = []
np.random.seed(7)
for region in regions:
base = np.random.randint(80_000, 150_000)
for i, date in enumerate(dates):
rev = base + 20_000 * np.sin(i / 3) + np.random.normal(0, 5_000)
rows.append({'month': date, 'region': region, 'revenue': round(rev, 2)})
regional_df = pd.DataFrame(rows)
regional_df = regional_df.sort_values(['region', 'month']).reset_index(drop=True)
Now we apply pct_change() within each group using groupby and transform:
regional_df['mom_pct'] = (
regional_df
.groupby('region')['revenue']
.pct_change(periods=1) * 100
)
regional_df['yoy_pct'] = (
regional_df
.groupby('region')['revenue']
.pct_change(periods=12) * 100
)
# Check that YoY is NaN for first year of each region
print(regional_df[regional_df['region'] == 'North'].iloc[10:16])
This is the correct pattern. By calling .pct_change() on a GroupBy object, pandas applies the calculation independently for each region. Row 12 in the North group compares January 2023 North to January 2022 North — not to December 2022 South.
Key insight
When you have grouped data, always use groupby().pct_change(), never just df['col'].pct_change(). The ungrouped version will silently bleed across group boundaries — comparing the last row of one region to the first row of the next.
If you want to understand how groupby works more broadly, Grouping and Aggregating in pandas: groupby as the PivotTable Replacement covers the full picture.
Sometimes pct_change() isn't flexible enough. You might need to compare against a specific anchor period — like the first month of the fiscal year, a pre-campaign baseline, or a rolling average. That's when you go back to shift() directly.
Here's a practical example: calculating each month's revenue as a percentage of the same month's budget (if you have a budget column) or comparing against a fixed baseline:
# Compare every month to January 2022 as the baseline (index 0)
baseline_revenue = df['revenue'].iloc[0]
df['vs_baseline_pct'] = (df['revenue'] - baseline_revenue) / baseline_revenue * 100
# Or, more flexibly, compare to the same quarter's first month
df['month_dt'] = pd.to_datetime(df['month'])
df['quarter'] = df['month_dt'].dt.quarter
df['year'] = df['month_dt'].dt.year
You can also use shift() with negative values to look forward rather than backward — useful for calculating how much a period contributed to the next period's change:
# What will next month's value be? (peek forward)
df['next_month_revenue'] = df['revenue'].shift(-1)
Tip
Negative shifts (shift(-1)) look forward in time. Positive shifts look backward. This is the opposite of what some people expect because the values move in the opposite direction from the shift direction — shift(1) moves values down, which means each row now sees the value from above (the past).
Let's pull everything together into a clean report. We'll take our main df, calculate the metrics, round appropriately, and format the output so it's ready to hand to a stakeholder — or export to Excel.
# Start fresh with clean calculations
report = df[['month', 'revenue', 'orders']].copy()
report = report.sort_values('month').reset_index(drop=True)
# Period comparisons
report['mom_rev_pct'] = report['revenue'].pct_change(1) * 100
report['yoy_rev_pct'] = report['revenue'].pct_change(12) * 100
report['mom_orders_pct'] = report['orders'].pct_change(1) * 100
report['yoy_orders_pct'] = report['orders'].pct_change(12) * 100
# Revenue absolute change
report['mom_rev_delta'] = report['revenue'] - report['revenue'].shift(1)
report['yoy_rev_delta'] = report['revenue'] - report['revenue'].shift(12)
# Format for display
def fmt_pct(val):
if pd.isna(val):
return "—"
sign = "+" if val > 0 else ""
return f"{sign}{val:.1f}%"
def fmt_currency(val):
if pd.isna(val):
return "—"
sign = "+" if val > 0 else ""
return f"{sign}${val:,.0f}"
display_report = report[['month', 'revenue', 'mom_rev_pct', 'yoy_rev_pct',
'mom_rev_delta', 'yoy_rev_delta']].copy()
display_report['month'] = display_report['month'].dt.strftime('%b %Y')
display_report['revenue'] = display_report['revenue'].apply(lambda x: f"${x:,.0f}")
display_report['mom_rev_pct'] = display_report['mom_rev_pct'].apply(fmt_pct)
display_report['yoy_rev_pct'] = display_report['yoy_rev_pct'].apply(fmt_pct)
display_report['mom_rev_delta'] = display_report['mom_rev_delta'].apply(fmt_currency)
display_report['yoy_rev_delta'] = display_report['yoy_rev_delta'].apply(fmt_currency)
display_report.columns = ['Month', 'Revenue', 'MoM %', 'YoY %', 'MoM Δ', 'YoY Δ']
print(display_report.iloc[11:17].to_string(index=False))
Month Revenue MoM % YoY % MoM Δ YoY Δ
Dec 2022 $447,832 +1.4% — +$6,293 —
Jan 2023 $458,291 +2.3% +15.8% +$10,459 +$62,468
Feb 2023 $471,204 +2.8% +15.3% +$12,912 +$62,461
Mar 2023 $490,832 +4.2% +13.0% +$19,628 +$56,311
Apr 2023 $498,021 +1.5% +10.6% +$7,189 +$47,709
May 2023 $502,113 +0.8% +12.8% +$4,092 +$56,903
This is a table you can drop into a weekly email or pipe into Building Summary Reports with pandas pivot_table and to_excel to produce a formatted Excel workbook.
Work through these steps using the regional dataset we built earlier:
Start with regional_df, which has columns: month, region, revenue.
Sort the DataFrame by region then month.
Calculate mom_pct and yoy_pct for revenue within each region using groupby().pct_change().
Add an absolute_yoy column showing the raw dollar change versus the same month last year (use shift(12) within each group with groupby().transform(lambda x: x.shift(12))).
Filter the DataFrame to show only 2024 data (the third year), and produce a summary table showing average MoM % and average YoY % for each region.
Identify which region had the highest average YoY growth in 2024. Add a column that flags that region with "Top Performer" and all others with "Standard".
Challenge: Reshape this table so that regions are columns and months are rows, making it easier to compare regions side by side. Use the techniques from Reshaping Wide and Long Data for Reporting: When and How to Use melt, pivot, and unstack in pandas if you need a reference.
"My MoM values look completely wrong — some months show 50% swings that don't make sense."
This almost always means your data isn't sorted by date. Run df = df.sort_values('date_column').reset_index(drop=True) before doing any period calculations.
"My grouped pct_change is comparing across group boundaries."
You called df['revenue'].pct_change() instead of df.groupby('region')['revenue'].pct_change(). The ungrouped version doesn't know about your groups.
"I have weekly data but I want month-over-month changes."
pct_change() operates on rows, not calendar periods. For weekly data, you need to resample to monthly first: df.resample('MS', on='date')['revenue'].sum(). Then apply pct_change() to the resampled result. See Working with Dates and Time Series in pandas: Parsing, Resampling, and Rolling Windows for the full resampling workflow.
"I'm getting NaN in the middle of my data, not just at the start."
Gaps in your time series (missing months) will cause shift() to align the wrong periods. A shift(12) on a series with a missing month will compare February 2024 to January 2023, not February 2023. Audit your data with Validating and Profiling a New Dataset with pandas: Row Counts, Distributions, and Outlier Checks Before You Analyze and ensure your time series has no gaps before calculating period changes.
"pct_change() gives me inf instead of NaN when the prior period is zero."
Division by zero produces inf in pandas. If your data can include zero values (like revenue for a new product line that hadn't launched yet), protect against it:
prior = df['revenue'].shift(1)
df['mom_pct'] = ((df['revenue'] - prior) / prior.replace(0, np.nan)) * 100
You now have the full toolkit for building period comparison reports in pandas:
shift(n) is the foundational operation — it aligns values from one period so you can subtract or divide them in anotherpct_change(n) automates the percentage change calculation, with n=1 for MoM and n=12 for YoY on monthly datagroupby().pct_change() whenever your data has multiple categoriesFrom here, the natural next step is putting these calculations into a production-quality report. Building a Reusable ETL Pipeline in pandas: Extract, Transform, and Load Data from Multiple Sources into a Clean, Analysis-Ready Output shows you how to wrap this kind of logic into functions you can run automatically every month. And if you want to add running totals or cumulative growth rates alongside your period comparisons, Ranking and Window Calculations in pandas: Running Totals, Cumulative Averages, and Percent Rank with groupby and transform covers exactly those techniques.