Data Analysis with Python (pandas, polars, DuckDB) Module 5 – Summaries, group-by and pivot tables
Pivot tables and crosstabs in pandas
Build spreadsheet-style pivot tables with totals in pandas, count pairs of labels with crosstab and its shares, and turn SUMIFS and COUNTIFS habits into code.
What you will learn
- Build spreadsheet-style pivot tables with pivot_table, margins and fill_value
- Compute two-way frequency tables with row or column shares using crosstab
- Map SUMIFS, COUNTIFS and XLOOKUP habits to pandas operations
- Decide when a pivot is a report and when tidy long data is better
Before you start
What you’ll learn · Before you start
What you will learn
- Build spreadsheet-style pivot tables with pivot_table, margins and fill_value
- Compute two-way frequency tables with row or column shares using crosstab
- Map SUMIFS, COUNTIFS and XLOOKUP habits to pandas operations
- Decide when a pivot is a report and when tidy long data is better
Before you start
On this page
Sixteen authored sales rows total 15500, while a report grid with both margins sums to 62000. A pivot table: A grid whose cells summarize source rows sharing specified row and column keys, with a declared aggregation and missing-value policy. rearranges long format: A representation whose row grain is stated and whose measured variables remain in columns, rather than spreading a reporting dimension into separate columns. into a grid. You choose a column whose values become the rows, a column whose
values become the columns, a column of numbers and a way to combine them, and every cell of the grid summarises the
rows that share its row and column values. Pivot tables and crosstab: A table of combinations of categorical labels, commonly showing occurrence counts or shares under a declared denominator. in pandas reproduce the two summaries that
the authored spreadsheet-style examples use. pivot_table(index, columns, values, aggfunc) builds the grid, with margins=True for
totals and fill_value=0 for empty cells. pd.crosstab(a, b) counts how often each pair of labels occurs, and its
normalize option turns the counts into shares of each row, each column or the grand total. For the shown literal-equality criteria and declared types, SUMIFS and COUNTIFS can be expressed as matching pandas operations, and one rule keeps the analysis correct: pivot at the end, for reading, and keep the
data itself as long rows.
Each grid cell reduces its matching source rows
The authored 16-row sales fixture spans four branches and four calendar quarters: one sale per row, with its branch, its date and its revenue.
branch,date,revenue
North,2026-01-12,1200
South,2026-01-20,900
East,2026-02-03,700
North,2026-02-25,800
South,2026-03-15,1100
North,2026-04-08,1500
East,2026-04-22,650
West,2026-05-05,400
South,2026-05-19,1000
North,2026-07-02,1300
West,2026-07-21,550
East,2026-08-14,900
South,2026-09-09,1250
North,2026-10-06,1600
West,2026-11-11,700
South,2026-12-01,950 From long rows to a branch-by-quarter grid
Text description of the diagram
The input has one sale per row, with its branch, its quarter and its revenue: for example North in Q1 with 1,200, North in Q1 with 800, and West in Q2 with 400.
pivot_table is called with the branch as the index, the quarter as the columns, the revenue as the values and sum as the aggregation.
The result is a grid with one row per branch and one column per quarter:
- Each cell adds up the sales that share its branch and quarter. North's two sales in Q1, 1,200 and 800, make a cell of 2,000.
- West had no sales in Q1, so its cell has nothing to add up; fill_value=0 writes 0 there instead of leaving it empty.
- margins=True adds a total row and a total column, computed from the rows themselves. The corner, the total of all 16 sales, is 15,500.
Zero filling here represents a known empty source cell. It is not a declaration that every unrecorded observed amount was zero. The grid combines transaction values, so a later reshape preserves its aggregate cells rather than recovering individual source sales.
import pandas as pd
sales = pd.read_csv("sales.csv", parse_dates=["date"]) # 16 sales of four branches over one year
sales["quarter"] = "Q" + sales["date"].dt.quarter.astype(str)
grid = sales.pivot_table(
index="branch", # one row per branch
columns="quarter", # one column per quarter
values="revenue", # the numbers in the cells
aggfunc="sum", # how to combine the rows of a cell
fill_value=0, # a cell with no sales shows 0
margins=True, # add totals
margins_name="Total", # instead of the default "All"
)
print(grid) Output
quarter Q1 Q2 Q3 Q4 Total branch East 700 650 900 0 2250 North 2000 1500 1300 1600 6400 South 2000 1000 1250 950 5200 West 0 400 550 700 1650 Total 4700 3550 4000 3250 15500
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 branch_quarter.py
Each cell adds up the sales that share its branch and quarter: North sold 1,200 and 800 in the first quarter, so its
Q1 cell is 2,000. West opened in the second quarter and East sold nothing in the last one, and fill_value=0 writes 0
where the grid would otherwise show a missing value. Zero is justified here by a known empty cell in this authored fixture; it is not a universal replacement for unrecorded revenue. margins=True adds a total row and a total column, computed from
the rows themselves, and margins_name renames them from the default “All”. Write aggfunc out every time. Its
default is the mean, which is rarely what a grid of revenue should show, and a grid of means looks just as plausible
as a grid of sums. Quarters of a financial year that does not end in December come from the date column too, for
example with dt.to_period("Q-MAR") for a year that ends in March.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Matching long rows. Two source rows share the North branch and first-quarter label.
- Declared reduction. A sum cell is 2000, while a mean cell would be 1000. Both can look plausible, so the report must name its aggregation.
- A reporting grid. The sum pivot's margin corner matches the recorded source total 15500. This agreement is an explicit source control.
Key idea
Checkpoint
Check: Grid grain and aggregation
A crosstab frequency and its share need a declared population
import pandas as pd
admissions = pd.read_csv("admissions.csv") # 20 admissions: the course and the mode of study
counts = pd.crosstab(admissions["course"], admissions["mode"], margins=True, margins_name="Total")
print(counts)
print()
shares = pd.crosstab(admissions["course"], admissions["mode"], normalize="index") # each row adds up to 1
print(shares.round(3)) Output
mode full-time online part-time Total course Commerce 5 1 2 8 Data science 2 5 1 8 Design 3 0 1 4 Total 10 6 4 20 mode full-time online part-time course Commerce 0.625 0.125 0.250 Data science 0.250 0.625 0.125 Design 0.750 0.000 0.250
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 admissions_crosstab.py
Crosstabs in pandas count occurrences of label combinations. Crosstabs in pandas can also normalize those counts into shares of a declared row, column or whole-table denominator. Called as pd.crosstab(course, mode), it gives the
number of admissions for each pair of course and mode of study, and margins=True adds the totals: 20 admissions, 8
of them in Commerce. With normalize="index" every row is divided by its own total, so each row adds up to 1. That reads as
“how do the students of each course study?”: 62.5 % of Commerce students study full-time, while 62.5 % of Data
science students study online. normalize="columns" and normalize="all" divide by the column totals and by the grand
total instead. Pivot tables can count as well, with aggfunc="size", but a crosstab says what it does in one call.
Its grand total is the number of rows in which both labels are present, which is a useful count control for this declared complete-label population. It does not by itself prove exact source-row identity.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The fixture population. The authored admissions fixture has twenty rows, eight labelled Commerce, with both labels present.
- A cell count. Five Commerce rows carry the full-time mode label.
- A row-normalized share. Dividing by the Commerce row's eight observations gives 62.5 percent. A column or whole-table denominator answers another question.
Key idea
Checkpoint
Check: Frequency-table denominators
Matching predicates translate the shown spreadsheet criteria
| Spreadsheet | pandas | Where it is taught |
|---|---|---|
| SUMIFS | df.loc[mask, "revenue"].sum(), or groupby(keys)["revenue"].sum() for every combination |
This module |
| COUNTIFS | groupby(keys).size() or value_counts() |
This module |
| AVERAGEIFS | groupby(keys)["revenue"].mean() |
This module |
| A pivot’s percentage of the row total | pd.crosstab(..., normalize="index") |
This lesson |
| XLOOKUP | An exact lookup can use merge(..., how="left") after its key and multiplicity rules are aligned |
The joins module |
| Conditional formatting | Styler |
The reporting module |
The source and matching contracts matter more than a superficial syntax translation. Confirm wildcard, blank, case and lookup-mode behaviour before generalising beyond these literal-equality examples. A spreadsheet formula answers one cell, while a group-by answers every combination at once, so one line of pandas replaces a column of copied formulas, and a new branch appears in the result without anyone extending a range.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- First criterion. The first equality predicate selects the North source rows.
- Second criterion. The second predicate selects first-quarter rows. Combining them with AND selects the intersection.
- Sum or count the match. The two matched rows sum to 2000, while counting them gives 2. Different formula matching modes need a separately verified contract.
Key idea
Checkpoint
Check: Literal criteria translation
A report grid and source rows serve different calculations
The shown pivot is a reporting representation. Keep the data long, one row per sale, while you clean, join, filter and summarise it,
and usually pivot at the reporting boundary: a grid has one column per quarter, and every step that follows has to know those
columns. When data arrives already pivoted, melt turns the grid back into long rows. The representation also defines what later calculations can recover: a cell has no individual sale identifier or within-cell distribution. Keep the transaction source for later joins or statistics that need those observations. A pivot that goes into a
report should say in its title what each cell holds, such as “revenue, sum, by branch and quarter”, because a grid of
sums and a grid of means look exactly alike.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Transaction grain. The source retains each of sixteen authored sale rows.
- Grouped grid grain. A sum pivot combines all rows belonging to the same branch-quarter cell.
- Representation change. Melting the grid returns one row per cell, not one row per original transaction. Preserve the source when later work needs individual sales.
Key idea
Checkpoint
Check: Representation and source grain
Practise the reading core
- In
branch_quarter.py, request a mean; compare each populated cell with the sum report. - In
admissions_crosstab.py, switch the denominator to columns; name the changed question.
Practice that adapts to you
Practise this lesson
8 questions about 11 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Pivot tables that match the spreadsheet cell by cell, with the traps reproduced Pro: opens with a Pro, Premium or Ultimate pass
A pivot is trusted when its cells, its totals and its layout match what the reader’s spreadsheet shows. This part checks a pivot against SUMIFS and COUNTIFS formulas cell by cell, reproduces the totals that differ when the cells are averages, and fixes the order of the columns and the rows that go missing.
In this part:
- Each fixture cell can be compared with its matching source rows
- A mean margin uses observations rather than equally weighted cells
- A declared universe fixes empty categories and column order
- Quarter labels follow the chosen financial-year ending month
4 diagrams · 4 runnable examples (Python) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Reports or analysis tables: summing a pivot, going back to long rows, and shares Pro: opens with a Pro, Premium or Ultimate pass
For this reporting workflow, build the pivot at the output boundary. A wide matrix can be appropriate for a different calculation when its grain and columns are explicitly defined. This part reproduces the most expensive mistake made with a finished pivot, reshapes its aggregate cells into long rows while keeping the lost transaction detail explicit, and compares the three kinds of share that a crosstab gives.
In this part:
- Including both margins counts this additive report repeatedly
- Melting preserves grouped cells and their missing-cell policy
- Three denominators describe three questions about the same counts
- Practise a collection report from raw rows
3 diagrams · 7 runnable examples (CSV, Python) · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher · Warm-up
When do you use pd.crosstab instead of pivot_table, and what does each one take as input?
pivot_table works on a DataFrame: you name the columns for the rows, the columns and the values, and it combines the values of each cell with aggfunc, so it answers "how much" questions such as revenue by branch and quarter. crosstab takes two or more Series or arrays and by default counts how often each combination of their values occurs, so it answers "how often" questions such as admissions by course and mode of study. crosstab can also aggregate a values array with aggfunc, and pivot_table can count with aggfunc="size", but each is shorter for its own job. Only crosstab has normalize, which turns the counts into shares of each row, each column or the grand total.
-
Fresher to mid
Your pivot of average order value by city and month has an All column. A manager averages the twelve monthly cells of one city, gets a different number from its All cell, and says the report is wrong. Who is right, and how do you explain it?
-
Fresher to mid
A slide says the year's revenue was 3.1 million, but finance reports 1.55 million. The slide's figure came from adding up a column of a pivot report copied out of your notebook. What happened, and how do you build reports so that it cannot happen again?
-
Mid to senior
An analyst's spreadsheet model builds a monthly report with SUMIFS, COUNTIFS and XLOOKUP across three sheets, and it takes 20 minutes to recalculate. How would you move it to pandas, and how do you prove that the new numbers match the old ones?
Key takeaways
pivot_table(index, columns, values, aggfunc="sum")builds the grid;margins=Trueadds totals andfill_value=0fills empty cells.- Always pass
aggfunc: the default is the mean. pd.crosstab(a, b)counts pairs of labels, andnormalize="index"gives shares that add up to 1 per row.- With the shown literal-equality criteria and declared types, SUMIFS, COUNTIFS and AVERAGEIFS map to grouped summaries of matching rows.
- Pivot at the end, for reading; keep the data as long rows while you analyse it.
Terms in this lesson
- crosstab
- A table of combinations of categorical labels, commonly showing occurrence counts or shares under a declared denominator.
- long format
- A representation whose row grain is stated and whose measured variables remain in columns, rather than spreading a reporting dimension into separate columns.
- pivot table
- A grid whose cells summarize source rows sharing specified row and column keys, with a declared aggregation and missing-value policy.
Corrections
- Clarified statistical inference and source-row or reporting contracts; existing explanations remain in their original reading parts.
- Qualified lookup modes, aggregate reshaping and diagnostic revenue ratios.
References
- Reshaping and pivot tables (pandas user guide) (pandas development team (NumFOCUS))
- pandas.pivot_table (pandas development team (NumFOCUS))
- pandas.crosstab (pandas development team (NumFOCUS))
- Comparison with spreadsheets (pandas documentation) (pandas development team (NumFOCUS))
- pandas.DataFrame.melt (pandas development team (NumFOCUS))
- Time series: periods and fiscal years (pandas user guide) (pandas development team (NumFOCUS))
- What's new in 3.0.0 (pandas) (pandas development team (NumFOCUS))
- Income-tax Act: definition of tax year, section3 (Government of India, Gazette of India)
- XLOOKUP function: exact and first-match defaults (Microsoft)
- pandas.merge: matching and cardinality validation (pandas development team (NumFOCUS))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress