Project: Sales Case Study, Question to Dashboard
By the end of this project you will have completed one full analysis from start to finish, exactly as an analyst does at work: take a manager's request, sharpen it into a clear question, clean the data, answer it with SQL and pandas, write a one-page insight report and sketch the dashboard that will track it. Put the finished result in your portfolio.
Every step uses skills from earlier lessons, so you will see how the whole path fits together. Take your time. Run each step, read what it prints, and write down what you notice before reading the explanation.
The brief
You are the data analyst at an online store that sells across ten Indian cities. On a Monday morning, the Head of Sales, Priya Nair, sends you this message:
Hi, we are starting to plan the festive season for 2026. Can you tell me
how the festive season went last year? I need to decide where to put stock
and marketing money, and whether the 10% festive discount is worth it.The data is sales.csv: one row per order for 2025, with city, category, product, quantity, price, discount and payment mode.
Step 1: sharpen the question
The request is vague. Using the five-part checklist from Asking Good Business Questions, you reply to Priya to confirm:
Metric: Revenue (after discount, before GST), orders and average order value
Definition: Festive season = October and November 2025; "rest" = the other 10 months.
Exact duplicate rows and rows with blank or invalid quantity removed.
Bulk orders (more than 10 units) reported separately.
Time period: January to December 2025
Segments: Category, city, discount level
Decisions: 1. Which categories and cities get festive stock and marketing budget?
2. Should we keep, cut or test the festive discount?Priya replies "Yes, perfect". Now you have a question that two analysts would answer the same way. You also break it into smaller questions, a question tree:
How did the festive season perform, and what should we do in 2026?
How much bigger is a festive month than a normal month? -> SQL, Step 3
Which categories grow most in the festive season? -> SQL, Step 4
Which cities grow most? -> pandas, Step 5
Did bigger discounts bring bigger baskets? -> pandas, Step 6Step 2: clean the data and load it into a database
Work through the cleaning checklist from A Data Cleaning Checklist, keep a log, then load the clean table into a small SQLite database inside Python. That lets you use SQL on the CSV file, just as you would on a company database. sqlite3 is part of Python's standard library, and pandas can write a DataFrame to it with to_sql.
import sqlite3
import pandas as pd
raw = pd.read_csv("sales.csv")
log = [("raw rows", len(raw))]
df = raw.drop_duplicates()
log.append(("after removing exact duplicates", len(df)))
df = df[df["quantity"] > 0].copy()
log.append(("after removing blank or invalid quantity", len(df)))
df["city"] = df["city"].str.strip().str.title().replace({"Bangalore": "Bengaluru"})
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["month"] = df["order_date"].str[:7]
df["season"] = df["month"].str[5:].isin(["10", "11"]).map({True: "Festive", False: "Rest"})
df["bulk_order"] = (df["quantity"] > 10).astype(int)
assert df["order_id"].is_unique, "order IDs repeat"
assert df["city"].nunique() == 10, "unexpected city spelling"
for step, rows in log:
print(f"{step:42} {rows:5}")
con = sqlite3.connect(":memory:")
df.to_sql("sales", con, index=False)
print(pd.read_sql_query("SELECT season, COUNT(*) AS orders, SUM(bulk_order) AS bulk FROM sales GROUP BY season", con))raw rows 1217
after removing exact duplicates 1209
after removing blank or invalid quantity 1202
season orders bulk
0 Festive 295 1
1 Rest 907 1What you should see: the cleaning log (1,217 rows down to 1,202), then a quick SQL check: how many clean orders fall in each season, and how many of them are bulk orders. One of the two bulk orders (120 exam guides in Pune) falls in November, inside the festive season. That is exactly why you agreed to report bulk orders separately: one order must not distort the festive story.
Step 3: how much bigger is a festive month?
The festive season has 2 months and the rest has 10, so comparing totals would be unfair. Compare per month instead, and exclude bulk orders. Each following step rebuilds the small database in a few lines, so it runs on its own.
import sqlite3
import pandas as pd
df = pd.read_csv("sales.csv").drop_duplicates()
df = df[df["quantity"] > 0].copy()
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["month"] = df["order_date"].str[:7]
df["season"] = df["month"].str[5:].isin(["10", "11"]).map({True: "Festive", False: "Rest"})
df["bulk_order"] = (df["quantity"] > 10).astype(int)
con = sqlite3.connect(":memory:")
df.to_sql("sales", con, index=False)
query = """
SELECT season,
COUNT(DISTINCT month) AS months,
ROUND(SUM(revenue) / COUNT(DISTINCT month)) AS revenue_per_month,
ROUND(COUNT(*) * 1.0 / COUNT(DISTINCT month)) AS orders_per_month,
ROUND(SUM(revenue) / COUNT(*)) AS aov,
ROUND(AVG(discount), 1) AS avg_discount_pct
FROM sales
WHERE bulk_order = 0
GROUP BY season
ORDER BY season;
"""
print(pd.read_sql_query(query, con).to_string(index=False))season months revenue_per_month orders_per_month aov avg_discount_pct Festive 2 252106.0 147.0 1715.0 7.9 Rest 10 134129.0 91.0 1480.0 4.0
What you should see: a festive month brought in about ₹2.5 lakh against about ₹1.3 lakh for a normal month, nearly twice as much. Orders per month rose by about 60%, AOV was higher, and the average discount roughly doubled. The headline is clear, and so is the open question: how much of this came from the season itself, and how much from the discount? Hold that question for Step 6.
Step 4: which categories grow most?
Now break the festive month down by category, using a CTE, conditional aggregation and two window functions from Window Functions for Business Questions. Uplift is how much bigger a festive month is than a normal month for that category.
import sqlite3
import pandas as pd
df = pd.read_csv("sales.csv").drop_duplicates()
df = df[df["quantity"] > 0].copy()
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["season"] = df["order_date"].str[5:7].isin(["10", "11"]).map({True: "Festive", False: "Rest"})
df["bulk_order"] = (df["quantity"] > 10).astype(int)
con = sqlite3.connect(":memory:")
df.to_sql("sales", con, index=False)
query = """
WITH per_category AS (
SELECT category,
SUM(CASE WHEN season = 'Festive' THEN revenue ELSE 0 END) / 2.0 AS festive_pm,
SUM(CASE WHEN season = 'Rest' THEN revenue ELSE 0 END) / 10.0 AS rest_pm
FROM sales
WHERE bulk_order = 0
GROUP BY category
)
SELECT category,
ROUND(festive_pm) AS festive_per_month,
ROUND(rest_pm) AS normal_per_month,
ROUND((festive_pm / rest_pm - 1) * 100) AS uplift_pct,
ROUND(festive_pm * 100.0 / SUM(festive_pm) OVER (), 1) AS festive_share_pct,
RANK() OVER (ORDER BY festive_pm DESC) AS festive_rank
FROM per_category
ORDER BY festive_rank;
"""
print(pd.read_sql_query(query, con).to_string(index=False)) category festive_per_month normal_per_month uplift_pct festive_share_pct festive_rank
Electronics 76222.0 35042.0 118.0 30.2 1
Fashion 75932.0 37766.0 101.0 30.1 2
Home & Kitchen 61328.0 32744.0 87.0 24.3 3
Groceries 18402.0 13086.0 41.0 7.3 4
Sports 16580.0 10193.0 63.0 6.6 5
Books 3642.0 5298.0 -31.0 1.4 6What you should see: Electronics and Fashion each roughly double in a festive month and together bring about 60% of festive revenue. Home & Kitchen is third. Books actually shrinks in the festive season once the bulk order is excluded. That one line changes a recommendation: without the bulk-order rule, Books would have looked like a festive star. The same query runs unchanged on MySQL if you import the CSV into a table called sales.
Step 5: which cities grow most?
Here is the same idea for cities, this time in pandas, so you practise both tools. pivot_table gives revenue per season, and dividing by the number of months gives per-month figures.
import pandas as pd
df = pd.read_csv("sales.csv").drop_duplicates()
df = df[(df["quantity"] > 0) & (df["quantity"] <= 10)].copy()
df["city"] = df["city"].str.strip().str.title().replace({"Bangalore": "Bengaluru"})
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["season"] = df["order_date"].str[5:7].isin(["10", "11"]).map({True: "Festive", False: "Rest"})
t = df.pivot_table(index="city", columns="season", values="revenue", aggfunc="sum")
t["festive_pm"] = t["Festive"] / 2
t["normal_pm"] = t["Rest"] / 10
t["uplift_pct"] = (t["festive_pm"] / t["normal_pm"] - 1) * 100
t["festive_share_pct"] = t["festive_pm"] / t["festive_pm"].sum() * 100
print(t[["festive_pm", "normal_pm", "uplift_pct", "festive_share_pct"]]
.sort_values("festive_pm", ascending=False).round(0).astype(int))season festive_pm normal_pm uplift_pct festive_share_pct city Mumbai 49950 21724 130 20 Delhi 35964 21216 70 14 Hyderabad 29950 14463 107 12 Bengaluru 25248 15957 58 10 Pune 22139 12207 81 9 Chennai 21190 13621 56 8 Kolkata 18876 11115 70 7 Lucknow 18766 9048 107 7 Jaipur 15682 8220 91 6 Kochi 14342 6560 119 6
What you should see: Mumbai is the biggest festive market and also has the largest uplift. Delhi is second by size but grows less. Kochi, Hyderabad and Lucknow grow strongly from smaller bases. Two kinds of city matter for planning: big cities (where most festive money is) and fast-growing cities (where extra marketing may pay off most). Note that the excluded bulk order was in Pune; mention it in the report so nobody wonders why Pune looks smaller than in other reports.
Step 6: did bigger discounts bring bigger baskets?
This is the hardest question, so be careful. Compare festive orders at each discount level: the share of orders, the average order value and the average units.
import pandas as pd
df = pd.read_csv("sales.csv").drop_duplicates()
df = df[(df["quantity"] > 0) & (df["quantity"] <= 10)].copy()
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["season"] = df["order_date"].str[5:7].isin(["10", "11"]).map({True: "Festive", False: "Rest"})
share = pd.crosstab(df["season"], df["discount"], normalize="index") * 100
print("Share of orders at each discount % (rows add to 100):")
print(share.round(0).astype(int))
festive = df[df["season"] == "Festive"]
by_level = festive.groupby("discount").agg(orders=("order_id", "count"),
aov=("revenue", "mean"),
avg_units=("quantity", "mean"))
print("\nFestive orders by discount level:")
print(by_level.round(1))Share of orders at each discount % (rows add to 100):
discount 0 5 10 15 20
season
Festive 31 18 23 16 11
Rest 62 14 13 7 5
Festive orders by discount level:
orders aov avg_units
discount
0 92 1613.4 1.5
5 53 1882.0 1.6
10 69 1952.7 1.6
15 47 1592.6 1.4
20 33 1407.4 1.7What you should see: in the rest of the year most orders had no discount, while in the festive season about two thirds of orders were discounted. Within the festive season, average units per order stay around 1.4 to 1.7 at every discount level, and orders at 15% and 20% discount have the lowest AOV.
What can you honestly conclude? Bigger discounts did not come with bigger baskets. But you cannot say whether discounts brought extra customers, because there was no comparison group: everyone saw the festive offers at the same time. The right recommendation is an experiment, as in Reading A/B Test Results, not a guess.
Step 7: draw the key charts
Prototype the two charts that carry the story: the monthly trend with the festive months highlighted, and category uplift.
import pandas as pd
import matplotlib.pyplot as plt
df = pd.read_csv("sales.csv").drop_duplicates()
df = df[(df["quantity"] > 0) & (df["quantity"] <= 10)].copy()
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["month"] = df["order_date"].str[5:7]
df["season"] = df["month"].isin(["10", "11"]).map({True: "Festive", False: "Rest"})
monthly = df.groupby("month")["revenue"].sum() / 100000
colours = ["#0d9488" if m in ("10", "11") else "#cbd5e1" for m in monthly.index]
per_month = df.groupby(["category", "season"])["revenue"].sum().unstack()
uplift = ((per_month["Festive"] / 2) / (per_month["Rest"] / 10) - 1) * 100
fig, (left, right) = plt.subplots(1, 2, figsize=(12, 4))
left.bar(monthly.index, monthly.values, color=colours)
left.set_title("Revenue by month, 2025 (Rs lakh); festive months in teal")
right.barh(uplift.sort_values().index, uplift.sort_values().values, color="#0d9488")
right.axvline(0, color="#64748b", linewidth=1)
right.set_title("Festive uplift by category (%)")
plt.tight_layout()
plt.show()
print("Peak month:", monthly.idxmax(), " Highest uplift:", uplift.idxmax(), f"({uplift.max():.0f}%)")Peak month: 10 Highest uplift: Electronics (118%)
What you should see: a bar chart where October and November stand out in teal against grey months, and an uplift chart with Electronics on top and Books below zero. Each chart answers one branch of the question tree. Both start at zero and use one highlight colour, following Choosing the Right Chart.
Step 8: write the insight report
Now write the one-page memo from Storytelling & Insight Memos. Answer first, then findings, recommendations and limits. Round numbers so they are easy to read.
To: Priya Nair, Head of Sales From: Data Analytics
Subject: Festive season 2025 review and 2026 plan
ANSWER
A festive month (Oct-Nov) brought in nearly twice the revenue of a normal
month. Electronics, Fashion and Home & Kitchen drove the gain. Discounts
doubled but did not create bigger baskets, so we recommend testing a smaller
festive discount rather than repeating the same offer everywhere.
KEY FINDINGS (bulk orders excluded)
1. Festive month: about Rs 2.5 lakh revenue vs Rs 1.3 lakh in a normal month;
orders per month up about 60%; AOV Rs 1,715 vs Rs 1,480.
2. Electronics and Fashion each roughly doubled and made up about 60% of
festive revenue. Books shrank in the festive season.
3. Mumbai is the largest festive market and grew most (about 2.3 times a
normal month). Kochi, Hyderabad and Lucknow grew strongly from smaller bases.
4. About two thirds of festive orders were discounted (just over a third
in normal months). Units per order stayed at about 1.5 at every discount level.
RECOMMENDATIONS
1. Stock: plan festive stock at about twice normal levels for Electronics and
Fashion, and well above normal for Home & Kitchen. No festive push for Books.
2. Marketing: weight budget towards Mumbai, Delhi and Hyderabad (largest
festive revenue), and trial extra spend in Kochi and Lucknow (fast growth).
3. Discount: run an A/B test in October 2026, 10% versus 5%, on Electronics
and Fashion, measuring conversion, revenue per visitor and margin.
4. Reporting: show bulk orders (2 in 2025, including Rs 51,300 of exam
guides in Pune) separately from retail sales.
HOW WE KNOW, AND LIMITS
Source: sales.csv, 2025; 1,202 orders after removing 8 duplicates and 7
invalid rows. One year only, so we cannot separate festive effect from
yearly growth. No customer IDs, website traffic or product costs, so we
cannot measure new customers, conversion or margin.Read it as Priya would. Can she decide something after the first paragraph? Yes: where to stock and whether to test the discount. Every number in it comes from a step above, and the limits tell her what extra data would make next year's analysis stronger.
Step 9: sketch the dashboard
Finally, Priya wants to track the 2026 festive season live. Sketch the dashboard from Dashboard Design Principles before building it in Power BI.
+--------------------------------------------------------------------+
| Festive Season Tracker 2026 [Date range v] [City v] [Category v] |
+----------------+----------------+----------------+-----------------+
| Revenue | Orders | AOV | Discount rate |
| vs same day | vs same day | vs festive | vs plan |
| last year | last year | 2025 average | |
+----------------+----------------+----------------+-----------------+
| Daily revenue: 2026 line vs 2025 line | Revenue by category vs |
| (same festive day) | stock plan (bar) |
+----------------------------------------+----------------------------+
| City table: revenue, uplift vs normal month, share, flag if behind |
+--------------------------------------------------------------------+
| A/B test panel: 5% vs 10% discount, conversion and revenue/visitor |
+--------------------------------------------------------------------+
| Source: orders DB, refreshed hourly. Bulk orders (>10 units) shown |
| separately. Definitions: revenue after discount, before GST. |
+--------------------------------------------------------------------+The dashboard follows the four layers (KPI cards, trend, breakdowns, detail), compares every KPI with something, and states its definitions in the footer. In Power BI you would build it on a star schema with measures such as Revenue, Orders and AOV, as shown in Power BI & Tableau Overview.
Before you hand it in
- Every number in the memo matches the code output.
- The question, definitions and limits are written down.
- Charts start at zero, use one highlight colour and have finding-style titles.
- Bulk orders and cleaning steps are explained.
- No personal data appears anywhere in the report.
Ideas to extend the project
- Repeat the analysis by product within Electronics and Fashion to guide stock at item level.
- Add payment mode to the festive comparison: did Cash on Delivery rise in the festive season?
- Build the dashboard in Power BI Desktop with a date table and a
Festiveflag column. - Write a 90-second spoken summary and record yourself presenting it, following Presenting to Managers.
Key points
- A full case study runs from a vague request to a sharp question, clean data, analysis, a memo and a dashboard plan.
- Compare periods of different length per month, and handle one-off bulk orders separately.
- SQL and pandas answer the same questions; use whichever fits the step.
- Say what the data cannot prove, and recommend an experiment where cause matters.
- The memo leads with the answer, and every number in it traces back to code.
Exercise
Extend the project with one more branch of the question tree: which products should get extra festive stock? For Electronics and Fashion only, calculate each product's festive revenue per month, normal revenue per month and uplift (excluding bulk orders), sorted by festive revenue per month. Add one finding and one recommendation to the memo.
Show solution
import pandas as pd
df = pd.read_csv("sales.csv").drop_duplicates()
df = df[(df["quantity"] > 0) & (df["quantity"] <= 10)].copy()
df["revenue"] = df["quantity"] * df["unit_price"] * (1 - df["discount"] / 100)
df["season"] = df["order_date"].str[5:7].isin(["10", "11"]).map({True: "Festive", False: "Rest"})
two = df[df["category"].isin(["Electronics", "Fashion"])]
t = two.pivot_table(index=["category", "product"], columns="season", values="revenue", aggfunc="sum")
t["festive_pm"] = t["Festive"] / 2
t["normal_pm"] = t["Rest"] / 10
t["uplift_pct"] = (t["festive_pm"] / t["normal_pm"] - 1) * 100
print(t[["festive_pm", "normal_pm", "uplift_pct"]].sort_values("festive_pm", ascending=False).round(0))Smartwatch, Silk Saree and Running Shoes earn the most in a festive month, and each more than doubles. Laptop Stand has the biggest uplift, about three and a half times a normal month, while Wireless Mouse barely changes. Finding: "Three products bring more than half of festive Electronics and Fashion revenue." Recommendation: plan their festive stock first and confirm supplier lead times by August.
Quick Quiz
1. Why compare festive and normal periods per month instead of using totals?
Per-month figures put periods of different length on the same scale.
2. Why were bulk orders excluded from the festive comparison?
The 120-book order would have made Books look like a festive star.
3. The data shows bigger festive discounts did not bring bigger baskets. What is the right recommendation?
Without a comparison group, only an experiment can show whether discounts work.
4. What goes first in the insight report?
Busy readers decide from the first paragraph; method and limits come later.
Learn Data Analytics with a mentor
Free lessons get you started. Our Data Analytics & Power BI training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
