All 57 lessons
Lesson 36 of 57 · Data Analysis with Pandas
Pandas Pivot Tables, Reshaping, Strings and apply
By Skillancy Editorial Team
Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).
By now you can filter, group, join and handle dates. This lesson rounds out your pandas toolkit with the techniques that turn a correct analysis into a presentable one. Pivot tables reshape grouped results into the matrix layouts that managers expect: cities down the side, months across the top. Melt and stack go the other way, converting wide tables to the "long" shape that plotting libraries prefer. The .str accessor cleans text at scale, categoricals save memory and control sort order, and apply handles the odd cases that no built-in covers.
We finish with a modern habit that separates tidy analysts from messy ones: writing the analysis as a method chain (a pipeline of steps) so that the whole transformation from raw data to final table reads top to bottom, can be re-run in one go, and has no confusing intermediate variables.
Worked examples
Example 1: pivot_table
Question: Orders by city (rows) and month (columns).
import pandas as pd
import numpy as np
orders = pd.read_csv("orders.csv", parse_dates=["order_date"])
stores = pd.read_csv("stores.csv")
o = orders.merge(stores[["store_id", "city"]], on="store_id")
o["month"] = o["order_date"].dt.to_period("M").astype(str)
pt = o.pivot_table(index="city", columns="month", values="order_id", aggfunc="count", fill_value=0)
print(pt.shape)
print(pt.iloc[:4, -4:])
pt2 = o.pivot_table(index="city", values="delivery_minutes", aggfunc=["mean", "median", "count"]).round(1)
print(pt2.head(3))
totals = o.pivot_table(index="city", columns="order_status", values="order_id", aggfunc="count", margins=True, margins_name="Total")
print(totals.tail(2))Output
(8, 18)
month 2026-03 2026-04 2026-05 2026-06
city
Ahmedabad 36 35 36 45
Bengaluru 135 99 130 115
Chennai 54 59 45 43
Delhi 122 125 133 130
mean median count
delivery_minutes delivery_minutes delivery_minutes
city
Ahmedabad 23.9 24.0 369
Bengaluru 23.8 24.0 1212
Chennai 23.0 23.0 430
order_status Cancelled Delivered Returned Total
city
Pune 35 467 19 521
Total 370 5391 239 6000How it works: pivot_table(index, columns, values, aggfunc) is a groupby followed by unstack, in one call. fill_value=0 replaces empty cells, aggfunc can be a list (giving multi-level columns), and margins=True appends row and column totals. It is the pandas equivalent of an Excel pivot table.
Reading the result: The matrix has 8 cities and 18 months. The grand total in the margins row is 6,000, equal to the number of orders.
Example 2: Percentages and crosstab
Question: What share of each city's orders falls in each payment method?
ct = pd.crosstab(o["city"], o["payment_method"])
print(ct.head(3))
share = pd.crosstab(o["city"], o["payment_method"], normalize="index") * 100
print(share.round(1).head(3))
print(share.sum(axis=1).round(6).unique())
overall = pd.crosstab(o["payment_method"], o["order_status"], normalize="all") * 100
print(overall.round(2))
print((pd.crosstab(o["city"], o["order_status"], normalize="columns") * 100).round(1).iloc[:2])Output
payment_method COD Card UPI Wallet
city
Ahmedabad 66 81 215 31
Bengaluru 211 280 720 90
Chennai 76 88 251 38
payment_method COD Card UPI Wallet
city
Ahmedabad 16.8 20.6 54.7 7.9
Bengaluru 16.2 21.5 55.3 6.9
Chennai 16.8 19.4 55.4 8.4
[100.]
order_status Cancelled Delivered Returned
payment_method
COD 1.08 14.52 0.67
Card 1.23 18.43 0.75
UPI 3.37 49.83 2.30
Wallet 0.48 7.07 0.27
order_status Cancelled Delivered Returned
city
Ahmedabad 6.5 6.5 7.1
Bengaluru 24.1 21.4 23.4How it works: crosstab counts the combinations of two categorical columns. normalize="index" makes each row sum to 1 (so cells are shares within a city), "columns" normalises each column, and "all" makes the whole table sum to 1. Multiplying by 100 turns shares into percentages.
Reading the result: Each city's payment shares sum to 100 percent. The most used method in Ahmedabad is UPI at 54.7%.
Example 3: Wide to long with melt
Question: Turn the monthly matrix into a long table with one row per city and month.
wide = pt.reset_index()
print(wide.shape)
long = wide.melt(id_vars="city", var_name="month", value_name="orders")
print(long.shape, long.columns.tolist())
print(long.sort_values(["city", "month"]).head(3).to_string(index=False))
back = long.pivot(index="city", columns="month", values="orders")
print(back.shape, (back.values == pt.values).all())Output
(8, 19)
(144, 3) ['city', 'month', 'orders']
city month orders
Ahmedabad 2025-01 9
Ahmedabad 2025-02 7
Ahmedabad 2025-03 10
(8, 18) TrueHow it works: melt unpivots: columns other than id_vars are stacked into two columns, one holding the old column name (var_name) and one holding the values (value_name). pivot reverses it. The long ("tidy") shape, where each row is one observation, is what seaborn, plotly and groupby prefer; the wide shape is what humans like to read.
Reading the result: The wide table had 18 month columns; the long table has 144 rows (8 cities × 18 months). Pivoting back restored the original values exactly.
Example 4: stack, unstack and multi-level columns
Question: Work with a table that has two header levels.
multi = o.groupby(["city", "delivery_slot"]).agg(orders=("order_id", "count"), avg=("delivery_minutes", "mean")).round(1)
wide2 = multi.unstack("delivery_slot")
print(wide2.columns.nlevels, wide2.shape)
print(wide2["orders"].head(2))
flat = wide2.copy()
flat.columns = [f"{a}_{b.split(' ')[0].lower()}" for a, b in flat.columns]
print(flat.columns.tolist()[:4])
back = wide2["orders"].stack()
print(back.head(3).to_dict())Output
2 (8, 8)
delivery_slot Evening (4-8) Midday (10-2) Morning (6-10) Night (8-11)
city
Ahmedabad 148 64 94 87
Bengaluru 507 223 279 292
['orders_evening', 'orders_midday', 'orders_morning', 'orders_night']
{('Ahmedabad', 'Evening (4-8)'): 148, ('Ahmedabad', 'Midday (10-2)'): 64, ('Ahmedabad', 'Morning (6-10)'): 94}How it works: unstack moves an index level into the columns, producing two header levels (the measure and the slot). Select one top-level group with wide2["orders"]. To flatten the headers into plain strings, rebuild columns with a comprehension. stack is the inverse of unstack.
Reading the result: The unstacked table has 2 column levels and 8 columns. Flat column names such as orders_evening are easier to use in later code.
Example 5: Binning with cut and qcut
Question: Group customers by order count into fixed bands and into equal-sized groups.
per_cust = o.groupby("customer_id").size().rename("orders")
bands = pd.cut(per_cust, bins=[0, 2, 5, 10, 20, 1000], labels=["1-2", "3-5", "6-10", "11-20", "21+"])
print(bands.value_counts().sort_index().to_dict())
quartiles = pd.qcut(per_cust, q=4, labels=["Q1 low", "Q2", "Q3", "Q4 high"])
print(quartiles.value_counts().sort_index().to_dict())
print(per_cust.groupby(quartiles, observed=True).agg(["min", "max", "sum"]))
print(pd.qcut(per_cust.rank(method="first"), 4, labels=False).value_counts().sort_index().to_dict())Output
{'1-2': 71, '3-5': 185, '6-10': 194, '11-20': 84, '21+': 54}
{'Q1 low': 196, 'Q2': 117, 'Q3': 137, 'Q4 high': 138}
min max sum
orders
Q1 low 1 4 549
Q2 5 6 642
Q3 7 10 1128
Q4 high 11 232 3681
{0: 147, 1: 147, 2: 147, 3: 147}How it works: pd.cut uses edges you choose (equal width or custom), while pd.qcut puts roughly equal numbers of rows into each bin (quantiles). Heavily repeated values can make quantile edges collide; ranking first (rank(method="first")) forces equal-sized groups. The last example shows that trick.
Reading the result: The top quartile of customers (by order count) places 61% of all orders. That kind of concentration is common in retail.
Example 6: The str accessor
Question: Pull structured pieces out of text.
customers = pd.read_csv("customers.csv")
parts = customers["name"].str.split(" ", n=1, expand=True)
parts.columns = ["first", "last"]
print(parts.head(3).to_string(index=False))
customers["email_user"] = customers["email"].str.extract(r"^([a-z]+)\.")
customers["email_digits"] = customers["email"].str.extract(r"(\d+)@").astype(int)
print(customers[["email", "email_user", "email_digits"]].head(2).to_string(index=False))
print(customers["name"].str.len().describe()[["min", "max"]].to_dict())
print(customers["name"].str.replace(r"[aeiou]", "", regex=True).head(2).tolist())
print(customers["city"].str.upper().str[:3].value_counts().head(3).to_dict())Output
first last
Isha Joshi
Priya Sharma
Suresh Singh
email email_user email_digits
isha.joshi1@example.com isha 1
priya.sharma2@example.com priya 2
{'min': 8.0, 'max': 18.0}
['Ish Jsh', 'Pry Shrm']
{'DEL': 124, 'BEN': 124, 'MUM': 118}How it works: split(..., expand=True) returns a DataFrame with one column per piece. str.extract uses a regular expression with a capture group (the brackets) to pull out a part of each string. Other staples are str.len, str.replace, str.cat, str.zfill and slicing with str[:3].
Reading the result: There are 20 distinct surnames among 600 customers; the most common is Chopra with 37 customers.
Example 7: Categorical dtype
Question: Make weekdays sort in calendar order and save memory.
days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
o["weekday"] = pd.Categorical(o["order_date"].dt.day_name(), categories=days, ordered=True)
print(o["weekday"].dtype)
print(o.groupby("weekday", observed=True).size().to_dict())
print((o["weekday"] >= "Saturday").sum())
before = o["payment_method"].memory_usage(deep=True)
after = o["payment_method"].astype("category").memory_usage(deep=True)
print(before > after, round(before / after, 1))
print(o["payment_method"].astype("category").cat.categories.tolist())Output
category
{'Monday': 796, 'Tuesday': 771, 'Wednesday': 769, 'Thursday': 776, 'Friday': 767, 'Saturday': 1042, 'Sunday': 1079}
2121
True 49.6
['COD', 'Card', 'UPI', 'Wallet']How it works: A categorical column stores each distinct value once and keeps small integer codes per row. That saves a lot of memory on columns with few distinct values, and with ordered=True the categories have a meaningful order, so sorting and comparisons (>= "Saturday") follow the calendar instead of the alphabet.
Reading the result: The categorical version is about 50 times smaller in memory, and groupby results now appear in calendar order from Monday to Sunday.
Example 8: map, where and apply
Question: Apply custom logic, and see why row-wise apply is slow.
import time
fee_by_slot = {"Morning (6-10)": 15, "Midday (10-2)": 10, "Evening (4-8)": 25, "Night (8-11)": 20}
print(sorted(o["delivery_slot"].unique()))
o["slot_fee"] = o["delivery_slot"].map(fee_by_slot).fillna(15)
def surge(row):
return row["slot_fee"] * (1.5 if row["is_peak"] else 1)
o["is_peak"] = o["order_date"].dt.dayofweek >= 5
t0 = time.perf_counter(); a = o.apply(surge, axis=1); t_apply = time.perf_counter() - t0
t0 = time.perf_counter(); b = o["slot_fee"] * np.where(o["is_peak"], 1.5, 1); t_vec = time.perf_counter() - t0
print(np.allclose(a, b), t_vec < t_apply)
print(b.groupby(o["delivery_slot"]).mean().round(2).to_dict())
print(o["delivery_minutes"].where(o["delivery_minutes"] < 40, 40).max())Output
['Evening (4-8)', 'Midday (10-2)', 'Morning (6-10)', 'Night (8-11)']
True True
{'Evening (4-8)': 29.3, 'Midday (10-2)': 11.77, 'Morning (6-10)': 17.64, 'Night (8-11)': 23.7}
40.0How it works: map with a dictionary is the fast way to translate values. apply(func, axis=1) calls a Python function for every row, which is flexible but slow (thousands of times slower than vectorised code on big tables). The vectorised np.where version gives identical results. Series.where(cond, other) keeps values where the condition holds and replaces the rest, here capping times at 40.
Reading the result: The row-wise and vectorised versions agree (True), and the vectorised one is faster (True). Use apply only when you cannot express the logic with column operations.
Example 9: Method chaining
Question: Express a whole analysis as one readable pipeline.
result = (
orders
.query("order_status == 'Delivered'")
.merge(stores[["store_id", "city"]], on="store_id")
.assign(
month=lambda d: d["order_date"].dt.to_period("M"),
late=lambda d: d["delivery_minutes"] > 30,
)
.groupby(["city", "month"], as_index=False)
.agg(orders=("order_id", "count"), late_pct=("late", lambda s: s.mean() * 100))
.query("orders >= 30")
.sort_values("late_pct", ascending=False)
.head(5)
.round({"late_pct": 1})
)
print(result.to_string(index=False))Output
city month orders late_pct
Kolkata 2026-05 40 27.5
Mumbai 2025-08 49 26.5
Ahmedabad 2026-04 31 25.8
Pune 2026-01 39 25.6
Bengaluru 2025-10 59 23.7How it works: Each method returns a new DataFrame that the next step uses, so the whole analysis reads like a recipe from top to bottom, with no throwaway variables. assign creates columns (using lambda d: to refer to the current table), query filters, and merge, groupby, sort_values and head do the rest. Wrapping the chain in brackets lets you break lines freely.
Reading the result: The five city-months with the highest late share (among those with at least 30 delivered orders) start with Kolkata in 2026-05 at 27.5% late.
Example 10: pipe and a reusable pipeline
Question: Package steps as functions and chain them with pipe.
def only_delivered(d):
return d[d["order_status"] == "Delivered"]
def add_city(d, stores=stores):
return d.merge(stores[["store_id", "city"]], on="store_id", validate="many_to_one")
def add_flags(d, late_after=30):
return d.assign(late=d["delivery_minutes"] > late_after)
def city_report(d):
return (
d.groupby("city")
.agg(orders=("order_id", "count"), late_pct=("late", "mean"))
.assign(late_pct=lambda r: (r["late_pct"] * 100).round(1))
.sort_values("late_pct", ascending=False)
)
report = orders.pipe(only_delivered).pipe(add_city).pipe(add_flags, late_after=25).pipe(city_report)
print(report.head(3))
print(len(report), report["orders"].sum())
print(orders.pipe(only_delivered).pipe(add_city).pipe(add_flags).pipe(city_report)["late_pct"].max())Output
orders late_pct
city
Ahmedabad 352 41.2
Bengaluru 1156 40.0
Kolkata 358 39.9
8 5391
17.3How it works: pipe(func, *args) passes the DataFrame as the first argument to your function, letting you chain your own named steps. Small functions are easy to test and reuse, and parameters such as late_after make the pipeline configurable. Changing the late threshold from 30 to 25 minutes was a one-argument change.
Reading the result: With a 25-minute threshold, Ahmedabad has the highest late share at 41.2%. The pipeline covered 5,391 delivered orders across 8 cities.
Common mistakes
1. Using a pivot table with duplicate index/column pairs and no aggfunc, which raises an error or averages silently.
2. Forgetting observed=True when grouping by a categorical column, creating empty groups.
3. Using apply(axis=1) on big tables when vectorised code exists.
4. Chaining with a variable that has been modified in between, so the result depends on the order you ran cells.
5. Normalising a crosstab along the wrong axis.
o.pivot(index="city", columns="month", values="order_id")Output
Traceback (most recent call last):
File "example.py", line 1, in <module>
o.pivot(index="city", columns="month", values="order_id")
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "example.py", line 10988, in pivot
return pivot(self, index=index, columns=columns, values=values)
File "example.py", line 910, in pivot
result = cast("DataFrame", indexed.unstack(columns_listlike)) # type: ignore[arg-type]
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
File "example.py", line 4501, in unstack
return unstack(self, level, fill_value, sort)
File "example.py", line 595, in unstack
return _unstack_extension_series(obj, level, fill_value, sort=sort)
File "example.py", line 648, in _unstack_extension_series
result = df.unstack(level=level, fill_value=fill_value, sort=sort)
File "example.py", line 11581, in unstack
result = unstack(self, level, fill_value, sort)
File "example.py", line 583, in unstack
return _unstack_frame(obj, level, fill_value=fill_value, sort=sort)
File "example.py", line 606, in _unstack_frame
unstacker = _Unstacker(
obj.index, level=level, constructor=obj._constructor, sort=sort
)
File "example.py", line 176, in __init__
self._make_selectors()
~~~~~~~~~~~~~~~~~~~~^^
File "example.py", line 232, in _make_selectors
raise ValueError("Index contains duplicate entries, cannot reshape")
ValueError: Index contains duplicate entries, cannot reshapePractice questions
Practice 1 (Easy): Payment by status pivot
Build a crosstab of payment method versus order status with row percentages.
Show solution
tab = pd.crosstab(o["payment_method"], o["order_status"], normalize="index") * 100
print(tab.round(1))Output
order_status Cancelled Delivered Returned
payment_method
COD 6.7 89.2 4.1
Card 6.0 90.3 3.7
UPI 6.1 89.8 4.1
Wallet 6.2 90.4 3.4Each row sums to 100. The share of cancelled orders is highest for COD at 6.7%.
Practice 2 (Medium): Long format for plotting
Convert the delivered-orders-per-city-per-month pivot into long format, sorted by month and city, and give the first rows.
Show solution
d = o[o["order_status"] == "Delivered"]
p = d.pivot_table(index="city", columns="month", values="order_id", aggfunc="count", fill_value=0)
long = p.reset_index().melt(id_vars="city", var_name="month", value_name="delivered").sort_values(["month", "city"])
print(long.head(3).to_string(index=False))
print(long["delivered"].sum() == len(d))Output
city month delivered
Ahmedabad 2025-01 8
Bengaluru 2025-01 18
Chennai 2025-01 0
TrueThe melt keeps every count, so the total equals the number of delivered orders (True).
Practice 3 (Hard): Customer value segments
Compute the total spend per customer (quantity × unit price of delivered orders), split customers into four equal groups with qcut, and report each group's customer count and share of total spend.
Show solution
items = pd.read_csv("order_items.csv")
delivered_ids = orders.loc[orders["order_status"] == "Delivered", ["order_id", "customer_id"]]
spend = (
items.merge(delivered_ids, on="order_id")
.assign(value=lambda d: d["quantity"] * d["unit_price"])
.groupby("customer_id")["value"].sum()
)
seg = pd.qcut(spend, 4, labels=["Q1", "Q2", "Q3", "Q4"])
out = spend.groupby(seg, observed=True).agg(customers="count", spend="sum")
out["share_pct"] = (out["spend"] / out["spend"].sum() * 100).round(1)
print(out.round(0))Output
customers spend share_pct
value
Q1 146 177300.0 5.0
Q2 146 400979.0 11.0
Q3 146 687744.0 20.0
Q4 146 2267811.0 64.0The top quartile of customers generates 64.2% of spend. This concentration is exactly what loyalty programmes target.
Key takeaways
pivot_tableandcrosstabbuild summary matrices;normalizeturns counts into shares.meltandpivotconvert between long and wide;stackandunstackmove index levels.cutbins by value edges;qcutbins into equal-sized groups.- The
.straccessor,mapandwherehandle text and conditional logic at scale. - Categoricals save memory and give ordered sorting.
- Avoid row-wise
apply; vectorise instead. - Method chains and
pipeturn an analysis into a readable, repeatable pipeline.
Frequently asked questions
What is the difference between pivot and pivot_table?
pivot only reshapes and fails on duplicates. pivot_table aggregates duplicates with an aggfunc, so it is the safer everyday choice.
When should I use long versus wide format?
Use long for analysis and plotting (one observation per row) and wide for presenting to humans or exporting to Excel.
Why are my pivot columns multi-level?
You passed several values or aggfuncs. Flatten with a list comprehension on columns, or select one level.
Is apply always slow?
Row-wise apply(axis=1) is slow. Column-wise or Series apply with simple functions is acceptable, but built-in methods and NumPy are faster.
What is a method chain's downside?
Long chains are harder to debug. Insert .pipe(print) or split the chain at a suspicious step to inspect an intermediate result.
How do categoricals help?
They save memory, enforce a set of allowed values, and give custom sort orders. Use them for columns with few distinct values.
Keep learning Python
One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.
Ready to practise? Try the Python interview questions