Build a Crosstab from Excel Data with pandas
"How many orders from each channel were refunded?" "What share of complaints in each region were resolved?" Questions like these need a cross-tabulation: one category down the side, another across the top, and a count in every cell. In Excel you would build a pivot table with a count of rows; in pandas, pd.crosstab does it in one call and adds percentages, totals and banding without helper columns. This guide, part of Creating Pivot Tables from Excel Data, builds crosstabs from an Excel sheet and covers the traps that make counts quietly wrong.
Prerequisites
pip install pandas openpyxl
import pandas as pd
orders = pd.DataFrame({
"Channel": ["Web", "Web", "Store", "Phone", "Web", "Store", "Store", "Web"],
"Status": ["Paid", "Refunded", "Paid", "Paid", "Paid", "Paid", "Refunded", "Paid"],
"Amount": [50, 20, 300, 80, 65, 120, 40, 95],
"Rating": [5, 2, 4, None, 4, 5, 1, 3],
})
orders.to_excel("orders.xlsx", index=False)
df = pd.read_excel("orders.xlsx")
Step 1 — Counts with totals
Pass the column for the rows and the column for the columns. margins=True adds totals on both axes:
counts = pd.crosstab(df["Channel"], df["Status"], margins=True, margins_name="Total")
print(counts)
Every cell is the number of rows with that combination, and the combinations that never occur — Phone orders that were refunded — show 0 rather than being left out. That is the main practical difference from groupby(["Channel", "Status"]).size(), which lists only the combinations that exist.
Step 2 — Percentages with normalize
Counts answer "how many"; most crosstab questions are really "what share". The normalize argument divides by the right total:
by_row = pd.crosstab(df["Channel"], df["Status"], normalize="index")
print(by_row.round(3))
# Status Paid Refunded
# Phone 1.000 0.000
# Store 0.667 0.333
# Web 0.750 0.250
normalize="index" makes each row add up to 1, answering "what share of Web orders were refunded?" (25%). normalize="columns" makes each column add up to 1, answering "what share of refunds came from Web?" (50%). normalize="all" divides by the grand total. Picking the wrong one produces numbers that look plausible and answer a different question, so name the direction in the sheet title: "Refund rate by channel" for rows, "Refunds by channel" for columns.
Step 3 — Sums and averages instead of counts
To put a measure in the cells rather than a count, pass values and aggfunc. Empty combinations then come back as NaN, because there is nothing to sum — fill them explicitly when zero is the right answer:
revenue = pd.crosstab(df["Channel"], df["Status"], values=df["Amount"], aggfunc="sum").fillna(0)
print(revenue)
# Status Paid Refunded
# Phone 80.0 0.0
# Store 420.0 40.0
# Web 210.0 20.0
For an average, leave the gaps as NaN: an average of no orders is not zero. At this point crosstab and pivot_table are interchangeable; Create a Pivot Table from Excel with pandas covers the pivot_table form.
Step 4 — Band numbers before counting
A crosstab of raw amounts would have one column per distinct value. Group numbers into bands with pd.cut first:
bands = pd.cut(df["Amount"], bins=[0, 50, 100, 500], labels=["up to 50", "51-100", "over 100"])
print(pd.crosstab(bands, df["Status"]))
# Status Paid Refunded
# Amount
# up to 50 1 2
# 51-100 3 0
# over 100 2 0
The bins are right-inclusive by default, so 50 falls in "up to 50" and 100 in "51-100". State the edges in the labels so readers do not have to guess, and make sure the outer edges cover every value — anything outside the bins becomes NaN and, as the next step shows, drops out of the count.
Step 5 — Do not lose rows to missing values
crosstab silently drops any row where one of the grouping values is missing. The order with no rating disappears from a channel-by-rating table — and with it, the only Phone order:
print(pd.crosstab(df["Channel"], df["Rating"]).index.tolist()) # ['Store', 'Web']
rating = df["Rating"].astype("Int64").astype("string").fillna("No rating")
full = pd.crosstab(df["Channel"], rating, margins=True)
print(full.loc["All", "All"], len(df)) # 8 8
Turning the missing values into an explicit label keeps every record in the table, and comparing the grand total with the row count is a one-line check that nothing went missing. Make that check part of every crosstab you publish. The same trap applies to the amount bands from Step 4: a negative amount or a refund above 500 falls outside the bins, becomes NaN and vanishes from the table without any warning.
Step 6 — Show categories with no records
A table that should always list Paid, Refunded and Cancelled loses the Cancelled column in a month with no cancellations. Declare the categories once and pass dropna=False:
status = pd.Categorical(df["Status"], categories=["Paid", "Refunded", "Cancelled"])
fixed = pd.crosstab(df["Channel"], status, dropna=False)
print(fixed.columns.tolist()) # ['Paid', 'Refunded', 'Cancelled']
Fixed categories keep the layout identical from month to month, so formulas and charts that point at the sheet do not break, and the column order follows the business order rather than the alphabet.
Step 7 — Two variables down the side
Pass a list of columns to either axis to nest them — channel and amount band down the side, for example — which answers "is the refund pattern different for small web orders?" in one table:
nested = pd.crosstab([df["Channel"], bands.rename("Band")], df["Status"])
print(nested)
flat = nested.reset_index()
flat.columns.name = None
print(flat.columns.tolist()) # ['Channel', 'Band', 'Paid', 'Refunded']
print(len(flat)) # 5 — only the combinations that occur
Nested crosstabs list only the row combinations that actually occur, so a channel with no large orders simply has no "over 100" row. That keeps the table short, but it also means two months can have different rows; if the layout must stay fixed, make both columns categorical, as in Step 6. Resetting the index turns the nested labels into ordinary columns, which is the easiest shape to filter in Excel and to join with other tables. Keep nesting to two levels in a report; beyond that, a long table with a filter is easier to read than a crosstab.
Step 8 — Write counts and percentages to Excel
Write the counts and the row percentages side by side, and let Excel format the percentages instead of multiplying by 100 in pandas:
from openpyxl import load_workbook
shares = pd.crosstab(df["Channel"], df["Status"], normalize="index")
with pd.ExcelWriter("crosstab.xlsx") as writer:
counts.to_excel(writer, sheet_name="Orders by status", startrow=1)
shares.to_excel(writer, sheet_name="Orders by status", startrow=1, startcol=5)
wb = load_workbook("crosstab.xlsx")
ws = wb["Orders by status"]
ws["A1"], ws["F1"] = "Order count", "Share of channel's orders"
for row in ws.iter_rows(min_row=3, min_col=7, max_col=8):
for cell in row:
cell.number_format = "0.0%"
wb.save("crosstab.xlsx")
print(ws["G5"].value, ws["G5"].number_format) # 0.75 0.0%
Keeping the stored values as fractions means anyone who builds on the sheet can use them in formulas directly, while readers see 75.0%. The startrow and startcol offsets leave room for a title above each table.
Common pitfalls
- Normalising a table that has margins. With
margins=Trueandnormalize, theAllrow and column are proportions too; label them clearly or add totals after normalising. - Counting text that differs only in spelling. "web", "Web " and "WEB" become three rows. Normalise the text first — see Strip Whitespace and Normalise Text Columns with pandas.
- Comparing crosstabs from different months by position. Row and column order follow the data unless the categories are fixed; align tables by label with
reindexbefore subtracting one from another. - Averaging percentages across rows. A mean of row shares ignores how many orders each row has. Recompute from counts.
Conclusion
pd.crosstab builds frequency tables straight from Excel columns, fills impossible combinations with zero and adds totals with margins=True. Choose normalize by the question you are answering, fill gaps explicitly when using values and aggfunc, band numbers with pd.cut, label missing values so no record is dropped, and declare categories so the layout stays stable. Write fractions and format them as percentages in Excel.
Frequently asked questions
What is the difference between crosstab and pivot_table? crosstab starts from separate columns and counts combinations by default, which suits frequency tables. pivot_table starts from a DataFrame and aggregates a value column. With values and aggfunc, crosstab can aggregate too, so the choice is mostly about which reads better.
How do I show percentages instead of counts? Pass normalize="index" for percentages within each row, normalize="columns" for each column, or normalize="all" for shares of the whole table. Multiply by 100 or apply an Excel percentage format when writing.
Why did some rows disappear from my crosstab? crosstab drops any record with a missing value in one of the grouping columns. Fill missing values with a label such as "Unknown" before building the table so every record is counted.
How do I include categories that have no records? Convert the column to a pandas Categorical with the full list of categories and pass dropna=False. Categories with no matching rows then appear with a count of zero.
Related
- Up one level: Creating Pivot Tables from Excel Data — pivots from Excel sheets.
- Add Subtotals and Grand Totals to a pandas Pivot Table — group totals for multi-level tables.
- COUNTIF and COUNTIFS Equivalent in pandas — single counts with conditions.
- Unpivot a Wide Excel Sheet with pandas melt — turning a crosstab-shaped sheet back into rows.
More on pivots & summaries
- How-toBuild a Customer Churn Report from Subscription Data
- How-toRefresh Excel Pivot Tables and Queries with Python
- How-toAdd a Native Excel Pivot Table with Python
- How-toAdd Subtotals and Grand Totals to a pandas Pivot Table
- How-toBin Values into Ranges Like Excel FREQUENCY with pd.cut
- How-toBuild a Cohort Retention Table from Excel Data