Pandas · Lesson 12 of 12
Project: End-to-End Sales Analysis
An end-to-end pandas sales analysis project: load raw CSV data, clean it, join lookups, then build KPIs, monthly trends, rankings and an exportable report.
- Intermediate
- 25 min read
- 4 objectives
Before this lessonLesson 11: Performance and Large Datasets
What you will learn
- Plan an analysis around concrete business questions
- Chain loading, cleaning, joining and feature steps into a repeatable pipeline
- Answer questions with groupby, pivot tables, windows and rankings
- Package results into a tidy report ready to export
Your Progress
0 of 12 lessons 0%
- Lessons0 / 12
- Completed0
- Est. time left~ 3 hours
Create a free account to keep your progress on every device.
Tip: pressing Next marks this lesson complete automatically.
You now know the individual pieces of pandas. Real work is about stringing them together: raw data comes in messy, someone asks a handful of business questions, and you need answers you can trust and rerun next month. In this project you will analyze a small online store's orders from start to finish.
The dataset is tiny on purpose so every output fits on screen, but the steps are exactly what you would do with a million rows. Each code block below is self-contained so you can run it in the browser, which means the data setup repeats at the top. In a real project it would be one script or notebook.
The questions
Always start by writing down what you need to answer. It keeps the analysis focused and tells you which columns matter. The store manager wants to know:
- What were total revenue, order count and average order value?
- How did revenue develop month by month, and how fast is it growing?
- Which product categories and regions drive the most revenue?
- Who are the top customers, and what share of revenue do they bring?
Step 1: load and inspect
The raw export has the usual problems: prices as text with a currency symbol, inconsistent capitalization, a duplicated order, a missing quantity and a refund row. Load it with the right types for the columns that are already clean, then inspect.
import pandas as pd
from io import StringIO
raw = """order_id,date,customer,product_id,qty,price,status
1001,2026-01-05,Ada,P1,2,$25.00,paid
1002,2026-01-17,grace ,P2,1,$300.00,paid
1003,2026-01-17,grace ,P2,1,$300.00,paid
1004,2026-02-02,Linus,P3,,$8.00,paid
1005,2026-02-14,Ada,P2,1,$300.00,paid
1006,2026-02-20,Ken,P1,4,$25.00,refunded
1007,2026-03-03,Margaret,P4,2,$150.00,paid
1008,2026-03-09,Ada,P3,10,$8.00,paid
1009,2026-03-21,Ken,P4,1,$150.00,paid
1010,2026-03-28,Grace,P1,3,$25.00,paid
"""
orders = pd.read_csv(StringIO(raw), parse_dates=["date"], dtype={"order_id": "string"})
print(orders.shape)
print(orders.isna().sum()[lambda s: s > 0])
print(orders["status"].value_counts())(10, 7) qty 1 dtype: int64 status paid 9 refunded 1 Name: count, dtype: int64
Step 2: clean with a pipeline
Put the cleaning in a function so it is documented, testable and rerunnable. Each step fixes one problem: parse the price, normalize names, drop exact duplicate orders (same everything except the ID), fill the missing quantity and remove refunds.
import pandas as pd
from io import StringIO
raw = """order_id,date,customer,product_id,qty,price,status
1001,2026-01-05,Ada,P1,2,$25.00,paid
1002,2026-01-17,grace ,P2,1,$300.00,paid
1003,2026-01-17,grace ,P2,1,$300.00,paid
1004,2026-02-02,Linus,P3,,$8.00,paid
1005,2026-02-14,Ada,P2,1,$300.00,paid
1006,2026-02-20,Ken,P1,4,$25.00,refunded
1007,2026-03-03,Margaret,P4,2,$150.00,paid
1008,2026-03-09,Ada,P3,10,$8.00,paid
1009,2026-03-21,Ken,P4,1,$150.00,paid
1010,2026-03-28,Grace,P1,3,$25.00,paid
"""
orders = pd.read_csv(StringIO(raw), parse_dates=["date"], dtype={"order_id": "string"})
def clean(df):
return (
df
.assign(
price=lambda d: d["price"].str.replace("$", "", regex=False).astype(float),
customer=lambda d: d["customer"].str.strip().str.title(),
qty=lambda d: d["qty"].fillna(1).astype(int),
)
.drop_duplicates(subset=["date", "customer", "product_id", "qty", "price"])
.query("status == 'paid'")
.drop(columns="status")
.reset_index(drop=True)
)
clean_orders = clean(orders)
print(clean_orders)order_id date customer product_id qty price 0 1001 2026-01-05 Ada P1 2 25.0 1 1002 2026-01-17 Grace P2 1 300.0 2 1004 2026-02-02 Linus P3 1 8.0 3 1005 2026-02-14 Ada P2 1 300.0 4 1007 2026-03-03 Margaret P4 2 150.0 5 1008 2026-03-09 Ada P3 10 8.0 6 1009 2026-03-21 Ken P4 1 150.0 7 1010 2026-03-28 Grace P1 3 25.0
Step 3: enrich with a lookup
The orders only have product IDs. A product table holds names and categories, and a customer table holds regions. Join them with merge and add the revenue and month columns every later question needs. validate="many_to_one" makes pandas raise an error if a lookup table accidentally has duplicate keys, which would silently double revenue.
import pandas as pd
clean_orders = pd.DataFrame({
"order_id": ["1001", "1002", "1004", "1005", "1007", "1008", "1009", "1010"],
"date": pd.to_datetime(["2026-01-05", "2026-01-17", "2026-02-02", "2026-02-14",
"2026-03-03", "2026-03-09", "2026-03-21", "2026-03-28"]),
"customer": ["Ada", "Grace", "Linus", "Ada", "Margaret", "Ada", "Ken", "Grace"],
"product_id": ["P1", "P2", "P3", "P2", "P4", "P3", "P4", "P1"],
"qty": [2, 1, 1, 1, 2, 10, 1, 3],
"price": [25.0, 300.0, 8.0, 300.0, 150.0, 8.0, 150.0, 25.0],
})
products = pd.DataFrame({
"product_id": ["P1", "P2", "P3", "P4"],
"product": ["Mouse", "Desk", "Cable", "Chair"],
"category": ["accessories", "furniture", "accessories", "furniture"],
})
customers = pd.DataFrame({
"customer": ["Ada", "Grace", "Linus", "Margaret", "Ken"],
"region": ["EMEA", "AMER", "EMEA", "AMER", "APAC"],
})
sales = (
clean_orders
.merge(products, on="product_id", how="left", validate="many_to_one")
.merge(customers, on="customer", how="left", validate="many_to_one")
.assign(
revenue=lambda d: d["qty"] * d["price"],
month=lambda d: d["date"].dt.to_period("M"),
)
)
print(sales[["order_id", "customer", "product", "category", "region", "revenue", "month"]])
print("unmatched:", sales[["product", "region"]].isna().sum().sum())order_id customer product category region revenue month 0 1001 Ada Mouse accessories EMEA 50.0 2026-01 1 1002 Grace Desk furniture AMER 300.0 2026-01 2 1004 Linus Cable accessories EMEA 8.0 2026-02 3 1005 Ada Desk furniture EMEA 300.0 2026-02 4 1007 Margaret Chair furniture AMER 300.0 2026-03 5 1008 Ada Cable accessories EMEA 80.0 2026-03 6 1009 Ken Chair furniture APAC 150.0 2026-03 7 1010 Grace Mouse accessories AMER 75.0 2026-03 unmatched: 0
Step 4: answer the questions
With a clean, enriched table, each question is a few lines. Start with the headline KPIs, then the monthly trend with growth, then the breakdowns.
import pandas as pd
sales = pd.DataFrame({
"date": pd.to_datetime(["2026-01-05", "2026-01-17", "2026-02-02", "2026-02-14",
"2026-03-03", "2026-03-09", "2026-03-21", "2026-03-28"]),
"customer": ["Ada", "Grace", "Linus", "Ada", "Margaret", "Ada", "Ken", "Grace"],
"category": ["accessories", "furniture", "accessories", "furniture",
"furniture", "accessories", "furniture", "accessories"],
"region": ["EMEA", "AMER", "EMEA", "EMEA", "AMER", "EMEA", "APAC", "AMER"],
"revenue": [50.0, 300.0, 8.0, 300.0, 300.0, 80.0, 150.0, 75.0],
})
sales["month"] = sales["date"].dt.to_period("M")
# 1. Headline KPIs
print(f"revenue: {sales['revenue'].sum():.0f}, orders: {len(sales)}, "
f"avg order: {sales['revenue'].mean():.2f}")
# 2. Monthly trend with growth and running total
monthly = sales.groupby("month")["revenue"].sum().to_frame()
monthly["growth_pct"] = (monthly["revenue"].pct_change() * 100).round(1)
monthly["ytd"] = monthly["revenue"].cumsum()
print(monthly)
# 3. Category by region
print(sales.pivot_table(index="category", columns="region", values="revenue",
aggfunc="sum", fill_value=0, margins=True, margins_name="total"))revenue: 1263, orders: 8, avg order: 157.88
revenue growth_pct ytd
month
2026-01 350.0 NaN 350.0
2026-02 308.0 -12.0 658.0
2026-03 605.0 96.4 1263.0
region AMER APAC EMEA total
category
accessories 75.0 0.0 138.0 213.0
furniture 600.0 150.0 300.0 1050.0
total 675.0 150.0 438.0 1263.0February dipped 12% after the big January desk order, then March almost doubled thanks to two chair orders. One large order can swing a small store's month, so look at order counts alongside revenue. Furniture is fewer orders but most of the money, which is worth pointing out: a campaign on accessories would need a lot of volume to matter.
Step 5: rank customers
"Top customers" needs total revenue per customer, a rank and a cumulative share, which tells you how concentrated the business is.
import pandas as pd
sales = pd.DataFrame({
"customer": ["Ada", "Grace", "Linus", "Ada", "Margaret", "Ada", "Ken", "Grace"],
"revenue": [50.0, 300.0, 8.0, 300.0, 300.0, 80.0, 150.0, 75.0],
})
top = (
sales.groupby("customer")
.agg(revenue=("revenue", "sum"), orders=("revenue", "size"))
.sort_values("revenue", ascending=False)
.assign(
rank=lambda d: d["revenue"].rank(ascending=False, method="min").astype(int),
share_pct=lambda d: (d["revenue"] / d["revenue"].sum() * 100).round(1),
cum_share_pct=lambda d: d["share_pct"].cumsum().round(1),
)
)
print(top)revenue orders rank share_pct cum_share_pct customer Ada 430.0 3 1 34.0 34.0 Grace 375.0 2 2 29.7 63.7 Margaret 300.0 1 3 23.8 87.5 Ken 150.0 1 4 11.9 99.4 Linus 8.0 1 5 0.6 100.0
Two customers bring in nearly two thirds of revenue. On a real dataset you would check whether that concentration is a risk worth flagging.
Step 6: package the report
Finally, collect the answers into tidy tables with clear column names and export them. For people, an Excel workbook with one sheet per question works well. For downstream code or dashboards, write Parquet. Here we print a CSV to show the shape.
import pandas as pd
from io import StringIO
monthly = pd.DataFrame({
"month": ["2026-01", "2026-02", "2026-03"],
"revenue": [350.0, 308.0, 605.0],
})
monthly["growth_pct"] = (monthly["revenue"].pct_change() * 100).round(1)
buf = StringIO()
monthly.to_csv(buf, index=False)
print(buf.getvalue().strip())
# In a real project:
# with pd.ExcelWriter("sales_report.xlsx") as xl:
# monthly.to_excel(xl, sheet_name="monthly", index=False)
# top.to_excel(xl, sheet_name="customers")month,revenue,growth_pct 2026-01,350.0, 2026-02,308.0,-12.0 2026-03,605.0,96.4
Recap
- Start from written business questions; they decide which columns and steps matter.
- Put cleaning in a function built from chained steps so it is repeatable and reviewable.
- Use
mergewithvalidateto enrich data without silently duplicating rows. groupby,pivot_table,pct_change,cumsumandrankanswer most business questions.- Record assumptions and export tidy, clearly named tables for your audience.
# Write your solution here
Finished reading? Mark this lesson complete to track your progress.
