Project: Sales Data EDA

Data Science · Lesson 32 of 32 9 min read Updated

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:

  1. When do we sell? How does revenue change over the year and the week?
  2. What do we sell? Which categories and products bring in the revenue?
  3. Where do we sell? Which cities matter most, and do they behave differently?
  4. How do customers pay? How common is cash on delivery, and where?
  5. 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.

PythonRun it yourself
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"]])
Output
(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 Banking

The 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.

PythonRun it yourself
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}")
Output
(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.

PythonRun it yourself
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()
Output
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?

PythonRun it yourself
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()
Output
                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.

PythonRun it yourself
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)
Output
           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.08

Mumbai 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.

PythonRun it yourself
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}")
Output
          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.

PythonRun it yourself
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}%")
Output
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.

Text
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
Python
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.
Output
         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?

2. The mean order is much higher than the median. Which should the report call "typical"?

3. The test finds no link between discount size and items per order. What is the right conclusion?

4. What belongs in the data notes of a report?

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