Project: Sales Data EDA
In this project you will use everything from the course to analyse sales.csv from start to finish, the way a data analyst would at a real company. The owner of an online store that ships across India has asked for a short report on 2025 before planning 2026.
You will follow the data science workflow from the introduction: ask clear questions, inspect, clean, explore, test, and communicate. Each step is a separate runnable example. Because every Try-it run starts fresh, each example begins with the same small loading-and-cleaning function; in a notebook you would define it once.
Step 1: the questions
Good analysis starts with questions the business cares about. After talking to the owner, you agree on five:
- When do we sell? How does revenue change over the year and the week?
- What do we sell? Which categories and products bring in the revenue?
- Where do we sell? Which cities matter most, and do they behave differently?
- How do customers pay? How common is cash on delivery, and where?
- Do discounts work? Do discounted orders contain more items?
Each answer should end in something the owner can act on: stock, staffing, marketing or payment policy.
Step 2: inspect the raw data
Start by measuring the problems, exactly as in Inspecting Data. Never clean what you have not counted.
import pandas as pd
raw = pd.read_csv("sales.csv")
print(raw.shape)
print("Duplicate rows:", raw.duplicated().sum())
print("Missing values:", raw.isna().sum()[lambda s: s > 0].to_dict())
print("City spellings:", raw["city"].nunique(), "| payment spellings:", raw["payment_mode"].nunique())
print("Quantity range:", raw["quantity"].min(), "to", raw["quantity"].max())
print(raw.nlargest(2, "quantity")[["order_id", "product", "quantity", "payment_mode"]])(1217, 10)
Duplicate rows: 8
Missing values: {'quantity': 6, 'payment_mode': 11}
City spellings: 20 | payment spellings: 12
Quantity range: -1.0 to 120.0
order_id product quantity payment_mode
1020 ORD11014 Exam Guide 120.0 Cash on Delivery
99 ORD10100 Masala Chai 250g 60.0 Net BankingThe list of problems is now concrete: 8 duplicate rows, 6 missing quantities, 11 missing payment modes, 20 spellings of 10 cities, 12 spellings of 5 payment modes, one negative quantity and two very large ones. You checked the large orders in Finding & Handling Outliers: the 120 exam guides were a typing error for 1, and the 60 packs of masala chai are a genuine bulk order.
Step 3: a cleaning function
Put every decision into one function, with comments that record why. This function is the documented, repeatable record of your cleaning.
import pandas as pd
PAY = {"upi": "UPI", "card": "Card", "cod": "Cash on Delivery", "cash on delivery": "Cash on Delivery",
"netbanking": "Net Banking", "net banking": "Net Banking", "wallet": "Wallet"}
def load_sales(path="sales.csv"):
df = pd.read_csv(path, parse_dates=["order_date"]).drop_duplicates() # exported twice
df["city"] = df["city"].str.strip().str.title().replace({"Bangalore": "Bengaluru"})
df["payment_mode"] = df["payment_mode"].str.strip().str.lower().map(PAY).fillna("Unknown")
df.loc[df["quantity"] < 1, "quantity"] = None # impossible value
df.loc[df["order_id"] == "ORD11014", "quantity"] = 1 # confirmed typo: 120 -> 1
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["month"] = df["order_date"].dt.month
df["weekend"] = df["order_date"].dt.dayofweek >= 5
return df
sales = load_sales()
assert sales["order_id"].is_unique and sales["city"].nunique() == 10
print(sales.shape)
print(sales["payment_mode"].value_counts())
kpi = {
"orders": len(sales),
"items sold": int(sales["quantity"].sum()),
"revenue (₹ lakh)": round(sales["revenue"].sum() / 1e5, 2),
"mean order (₹)": round(sales["revenue"].mean()),
"median order (₹)": round(sales["revenue"].median()),
}
for name, value in kpi.items():
print(f"{name:<18}{value:>10}")(1209, 13) payment_mode UPI 615 Card 208 Cash on Delivery 186 Wallet 100 Net Banking 89 Unknown 11 Name: count, dtype: int64 orders 1209 items sold 1863 revenue (₹ lakh) 18.57 mean order (₹) 1545 median order (₹) 1099
After cleaning there are 1,209 unique orders. The headline numbers, the KPIs (key performance indicators), give the scale of the business: about ₹18.6 lakh of revenue in the year. The mean order is well above the median, so order values are right-skewed; the report will quote the median as the "typical" order. The 7 orders with a missing quantity have no revenue, so they are left out of revenue totals; that is less than 1% of orders, small enough to note and move on.
Step 4: when do we sell?
Look at the months, then the days of the week.
import pandas as pd
import matplotlib.pyplot as plt
PAY = {"upi": "UPI", "card": "Card", "cod": "Cash on Delivery", "cash on delivery": "Cash on Delivery",
"netbanking": "Net Banking", "net banking": "Net Banking", "wallet": "Wallet"}
def load_sales(path="sales.csv"):
df = pd.read_csv(path, parse_dates=["order_date"]).drop_duplicates()
df["city"] = df["city"].str.strip().str.title().replace({"Bangalore": "Bengaluru"})
df["payment_mode"] = df["payment_mode"].str.strip().str.lower().map(PAY).fillna("Unknown")
df.loc[df["quantity"] < 1, "quantity"] = None
df.loc[df["order_id"] == "ORD11014", "quantity"] = 1
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["month"] = df["order_date"].dt.month
df["weekend"] = df["order_date"].dt.dayofweek >= 5
return df
sales = load_sales()
monthly = sales.groupby("month").agg(orders=("order_id", "count"), revenue=("revenue", "sum"))
monthly["revenue"] = (monthly["revenue"] / 1e5).round(2)
print(monthly.T)
festive = monthly.loc[[10, 11], "revenue"].sum() / monthly["revenue"].sum()
print(f"October + November share of revenue: {festive:.1%}")
days = sales.groupby(sales["order_date"].dt.dayofweek)["order_id"].count()
days.index = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
print((days / 52).round(2).to_dict())
names = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "N", "D"]
colours = ["#c2410c" if m in (10, 11) else "#cbd5e1" for m in monthly.index]
fig, ax = plt.subplots(figsize=(8, 3.5))
ax.bar(names, monthly["revenue"], color=colours)
ax.set_title("Festive months bring in over a quarter of the year's revenue", loc="left")
ax.set_ylabel("Revenue (₹ lakh)")
plt.show()month 1 2 3 4 ... 9 10 11 12
orders 86.00 79.00 99.00 78.00 ... 106.00 169.00 127.00 111.00
revenue 1.13 1.17 1.48 1.15 ... 1.93 2.81 2.24 1.32
[2 rows x 12 columns]
October + November share of revenue: 27.2%
{'Mon': 3.12, 'Tue': 2.9, 'Wed': 3.35, 'Thu': 3.13, 'Fri': 2.52, 'Sat': 4.38, 'Sun': 3.85}The bar chart shows ten grey months between roughly ₹1 lakh and ₹1.9 lakh and two orange bars, October at about ₹2.8 lakh and November at about ₹2.2 lakh. Revenue also builds from July to September. The weekday averages show about 4 orders on a typical Saturday and Sunday against about 3 on weekdays.
Action: order festive stock by September and plan extra packing staff for October, November and weekends.
Step 5: what do we sell?
import pandas as pd
import matplotlib.pyplot as plt
PAY = {"upi": "UPI", "card": "Card", "cod": "Cash on Delivery", "cash on delivery": "Cash on Delivery",
"netbanking": "Net Banking", "net banking": "Net Banking", "wallet": "Wallet"}
def load_sales(path="sales.csv"):
df = pd.read_csv(path, parse_dates=["order_date"]).drop_duplicates()
df["city"] = df["city"].str.strip().str.title().replace({"Bangalore": "Bengaluru"})
df["payment_mode"] = df["payment_mode"].str.strip().str.lower().map(PAY).fillna("Unknown")
df.loc[df["quantity"] < 1, "quantity"] = None
df.loc[df["order_id"] == "ORD11014", "quantity"] = 1
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["month"] = df["order_date"].dt.month
return df
sales = load_sales()
cat = sales.groupby("category").agg(orders=("order_id", "count"), revenue=("revenue", "sum"),
median_order=("revenue", "median"))
cat["share_pct"] = (cat["revenue"] / cat["revenue"].sum() * 100).round(1)
print(cat.sort_values("revenue", ascending=False).round(0))
top = sales.groupby("product")["revenue"].sum().nlargest(5) / 1000
print(top.round(1))
season = sales.pivot_table(index="month", columns="category", values="order_id", aggfunc="count")
print(season.idxmax())
fig, ax = plt.subplots(figsize=(7, 3.5))
top.sort_values().plot.barh(ax=ax, color="#2563eb")
ax.set_title("Top 5 products by revenue (₹ thousand)", loc="left")
ax.set_ylabel("")
plt.show()orders revenue median_order share_pct category Fashion 272 529526.0 1614.0 28.0 Electronics 301 502862.0 1199.0 27.0 Home & Kitchen 215 450092.0 1709.0 24.0 Groceries 187 178462.0 690.0 10.0 Sports 122 135094.0 899.0 7.0 Books 112 60697.0 450.0 3.0 product Mixer Grinder 189.9 Smartwatch 188.9 Running Shoes 181.4 Silk Saree 172.0 Pressure Cooker 116.7 Name: revenue, dtype: float64 category Books 3 Electronics 10 Fashion 10 Groceries 12 Home & Kitchen 10 Sports 5 dtype: int32
Three categories, Fashion, Electronics and Home & Kitchen, bring in almost 80% of the revenue. Home & Kitchen has the highest median order because its products (mixer grinders, pressure cookers) are expensive. The mixer grinder and the smartwatch are the top two products, each close to ₹1.9 lakh. idxmax() on the monthly pivot shows when each category peaks: Electronics and Fashion in October, Books in the exam season and Sports in the summer.
Action: plan category promotions on their own calendars, not one store-wide calendar.
Step 6: where and how do customers buy?
Answer questions 3 and 4 together, because payment habits may differ between cities.
import pandas as pd
PAY = {"upi": "UPI", "card": "Card", "cod": "Cash on Delivery", "cash on delivery": "Cash on Delivery",
"netbanking": "Net Banking", "net banking": "Net Banking", "wallet": "Wallet"}
def load_sales(path="sales.csv"):
df = pd.read_csv(path, parse_dates=["order_date"]).drop_duplicates()
df["city"] = df["city"].str.strip().str.title().replace({"Bangalore": "Bengaluru"})
df["payment_mode"] = df["payment_mode"].str.strip().str.lower().map(PAY).fillna("Unknown")
df.loc[df["quantity"] < 1, "quantity"] = None
df.loc[df["order_id"] == "ORD11014", "quantity"] = 1
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
return df
sales = load_sales()
city = sales.groupby("city").agg(orders=("order_id", "count"), revenue=("revenue", "sum"),
median_order=("revenue", "median"))
city["cod_pct"] = (sales.assign(cod=sales["payment_mode"] == "Cash on Delivery")
.groupby("city")["cod"].mean() * 100).round(1)
print(city.sort_values("revenue", ascending=False).round(0))
pay = pd.crosstab(sales["payment_mode"], sales["revenue"] > 3000, normalize="columns").round(2)
pay.columns = ["up to ₹3,000", "above ₹3,000"]
print(pay) orders revenue median_order cod_pct
city
Mumbai 184 317139.0 1298.0 10.0
Delhi 188 294885.0 999.0 15.0
Bengaluru 154 210063.0 1009.0 13.0
Hyderabad 124 204527.0 1169.0 14.0
Chennai 123 178588.0 989.0 13.0
Pune 105 166772.0 1188.0 14.0
Kolkata 96 148900.0 1099.0 15.0
Lucknow 87 128008.0 960.0 21.0
Jaipur 74 113568.0 1164.0 18.0
Kochi 74 94283.0 989.0 32.0
up to ₹3,000 above ₹3,000
payment_mode
Card 0.15 0.38
Cash on Delivery 0.16 0.05
Net Banking 0.08 0.05
UPI 0.52 0.43
Unknown 0.01 0.01
Wallet 0.08 0.08Mumbai and Delhi lead on revenue simply because they place the most orders; the median order is broadly similar everywhere. Cash on delivery is used for a larger share of orders in Lucknow, Kochi and Jaipur than in the metros. The crosstab compares payment mix for small and large orders: card payments make up a much bigger share of orders above ₹3,000, and cash on delivery falls to about 5%.
Action: COD is costly to handle (failed deliveries, cash collection), so test a small UPI discount in the cities where COD is most common.
Step 7: do discounts work?
The store gives discounts of 0 to 20%. Do discounted orders contain more items? First describe, then test, as in Hypothesis Testing Basics.
import pandas as pd
from scipy import stats
def load_sales(path="sales.csv"):
df = pd.read_csv(path, parse_dates=["order_date"]).drop_duplicates()
df.loc[df["quantity"] < 1, "quantity"] = None
df.loc[df["order_id"] == "ORD11014", "quantity"] = 1
return df.dropna(subset=["quantity"])
sales = load_sales()
sales = sales[sales["quantity"] < 50] # leave out the one bulk order
print(sales.groupby("discount")["quantity"].agg(["count", "mean"]).round(2))
with_disc = sales.loc[sales["discount"] > 0, "quantity"]
no_disc = sales.loc[sales["discount"] == 0, "quantity"]
res = stats.mannwhitneyu(with_disc, no_disc, alternative="two-sided")
print(f"Mean items: discounted {with_disc.mean():.2f}, full price {no_disc.mean():.2f}, p = {res.pvalue:.2f}")
rho = stats.spearmanr(sales["discount"], sales["quantity"])
print(f"Spearman rho = {rho.statistic:.3f}, p = {rho.pvalue:.2f}")count mean discount 0 650 1.50 5 177 1.53 10 183 1.51 15 112 1.43 20 79 1.54 Mean items: discounted 1.50, full price 1.50, p = 0.88 Spearman rho = -0.007, p = 0.80
The average number of items is about the same at every discount level, the Mann-Whitney test finds no significant difference, and the rank correlation is close to zero. In this data, bigger discounts are not followed by bigger baskets. That does not prove discounts are useless (they might bring in customers who would not have ordered at all, which this data cannot show), but it is a reason to test discount levels properly before spending more on them.
Action: run a controlled experiment: randomly give half of the customers a 10% coupon for a month and compare.
Step 8: a one-page dashboard
Owners rarely read tables. Put the four key charts on one figure, each with a title that states its finding.
import pandas as pd
import matplotlib.pyplot as plt
PAY = {"upi": "UPI", "card": "Card", "cod": "Cash on Delivery", "cash on delivery": "Cash on Delivery",
"netbanking": "Net Banking", "net banking": "Net Banking", "wallet": "Wallet"}
def load_sales(path="sales.csv"):
df = pd.read_csv(path, parse_dates=["order_date"]).drop_duplicates()
df["city"] = df["city"].str.strip().str.title().replace({"Bangalore": "Bengaluru"})
df["payment_mode"] = df["payment_mode"].str.strip().str.lower().map(PAY).fillna("Unknown")
df.loc[df["quantity"] < 1, "quantity"] = None
df.loc[df["order_id"] == "ORD11014", "quantity"] = 1
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
return df
sales = load_sales()
weekly = sales.set_index("order_date")["revenue"].resample("W").sum() / 1000
cat = (sales.groupby("category")["revenue"].sum() / 1e5).sort_values()
city = (sales.groupby("city")["revenue"].sum() / 1e5).sort_values()
pay = sales["payment_mode"].value_counts(normalize=True).sort_values() * 100
fig, axes = plt.subplots(2, 2, figsize=(11, 7), layout="constrained")
axes[0, 0].plot(weekly.index, weekly.values, color="#c2410c")
axes[0, 0].set_title("Weekly revenue peaks in Oct-Nov (₹ thousand)", loc="left")
axes[0, 1].barh(cat.index, cat.values, color="#2563eb")
axes[0, 1].set_title("Three categories earn most (₹ lakh)", loc="left")
axes[1, 0].barh(city.index, city.values, color="#64748b")
axes[1, 0].set_title("Mumbai and Delhi lead (₹ lakh)", loc="left")
axes[1, 1].barh(pay.index, pay.values, color="#0891b2")
axes[1, 1].set_title("Half of all orders are paid by UPI (%)", loc="left")
for ax in axes.flat:
for side in ["top", "right"]:
ax.spines[side].set_visible(False)
fig.suptitle("Online store, 2025 at a glance", fontsize=15)
fig.autofmt_xdate()
plt.show()
print(f"UPI share: {pay['UPI']:.1f}%")UPI share: 50.9%
The dashboard has four panels: a weekly revenue line rising to its peak in late October and early November; sorted bars for categories with Fashion, Electronics and Home & Kitchen far ahead; sorted bars for cities with Mumbai and Delhi at the top and Kochi at the bottom; and payment modes with UPI's bar longest at about half of orders. Each title states the point, so the owner can read the page in under a minute.
Step 9: write the findings
The final step is a short written report. Code and charts support it, but the words are what people read and act on.
Online store: 2025 sales review
1. Revenue was about ₹18.6 lakh from 1,209 orders; the typical (median) order is about ₹1,100.
2. October and November brought in 27% of the year's revenue. Order festive stock by September
and add packing staff for October, November and weekends.
3. Fashion, Electronics and Home & Kitchen earn almost 80% of revenue. Each category
peaks at a different time of year: plan promotions per category.
4. Mumbai and Delhi lead because of order volume, not order size. Cash on delivery is used more
in Lucknow, Kochi and Jaipur: test a small UPI incentive there.
5. Bigger discounts were not linked to bigger baskets. Test coupons with a controlled experiment
before increasing discount spend.
Data notes: 8 duplicate rows removed; city and payment spellings standardised; one negative
quantity set to missing; order ORD11014 corrected from 120 to 1 after checking; 7 orders
without a quantity excluded from revenue. Data covers one year, so seasonality is not confirmed
across years.Notice the data notes. Listing your cleaning decisions and limits makes the report trustworthy, and lets someone else repeat or challenge your work.
Extend the project
- Repeat the analysis by state instead of city, and map the results to regions with
merge(). - Use
rolling()to compare each week with the same week's 4-week average and flag unusual weeks. - Simulate the festive season with a Poisson model of daily orders and estimate the busiest day's order count.
- Apply the same workflow to
students.csv: which factors are most strongly associated with marks?
Key points
- A complete EDA follows the workflow: questions, inspection, cleaning, exploration, testing and communication.
- Put cleaning decisions in one documented function and check the result with assertions.
- Answer each question with a table or chart, and turn each answer into an action.
- Use statistics to check claims before acting on them, and be clear about what the data cannot show.
- Finish with a short written report that includes data notes and limitations.
Exercise
Add a sixth question to the project: "Is there a difference between weekend and weekday orders?" Using the cleaning function, compare the number of orders per day, the median order value and the share of cash on delivery for weekends and weekdays. Print a small table and write one sentence of findings as a comment.
Show solution
import pandas as pd
PAY = {"upi": "UPI", "card": "Card", "cod": "Cash on Delivery", "cash on delivery": "Cash on Delivery",
"netbanking": "Net Banking", "net banking": "Net Banking", "wallet": "Wallet"}
def load_sales(path="sales.csv"):
df = pd.read_csv(path, parse_dates=["order_date"]).drop_duplicates()
df["payment_mode"] = df["payment_mode"].str.strip().str.lower().map(PAY).fillna("Unknown")
df.loc[df["quantity"] < 1, "quantity"] = None
df.loc[df["order_id"] == "ORD11014", "quantity"] = 1
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["weekend"] = df["order_date"].dt.dayofweek >= 5
return df
sales = load_sales()
days = sales.groupby("weekend")["order_date"].nunique()
table = sales.groupby("weekend").agg(orders=("order_id", "count"), median_order=("revenue", "median"),
cod_share=("payment_mode", lambda s: (s == "Cash on Delivery").mean()))
table["orders_per_day"] = (table["orders"] / days).round(2)
table["cod_share"] = (table["cod_share"] * 100).round(1)
table.index = ["Weekday", "Weekend"]
print(table)
# Weekends bring more orders per day, but orders are no bigger and payment habits are similar.orders median_order cod_share orders_per_day Weekday 781 1099.0 16.0 3.20 Weekend 428 1098.0 14.3 4.16
Dividing by the number of distinct dates in each group turns totals into a fair per-day rate, because there are five weekdays for every two weekend days.
Quick Quiz
1. Why should the cleaning steps live in one documented function?
A function with comments can be rerun on new data and reviewed by others.
2. The mean order is much higher than the median. Which should the report call "typical"?
In right-skewed data, the median represents a typical order better.
3. The test finds no link between discount size and items per order. What is the right conclusion?
Observational data cannot show customers that discounts may have attracted; an experiment can.
4. What belongs in the data notes of a report?
Notes on cleaning and limitations let readers trust and repeat the analysis.
Learn Data Science with a mentor
Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
