Remove Subtotal and Total Rows from an Excel Export
Reports exported from accounting packages, BI tools and Excel's own Data → Subtotal command interleave
detail rows with rollups: two rows for the North region, then a North Total, two rows for South, a
South Total, and a Grand Total at the bottom. Read that sheet with pandas and sum the revenue column,
and the answer is three times the truth — every euro is counted once in its detail row, once more in its
region's subtotal, and a third time in the grand total. This guide, part of
Extracting Data from Messy Excel Sheets,
removes those rows reliably and then puts them to work as a free correctness check.
Prerequisites
pip install pandas openpyxl
The first file mirrors the diagram, with totals typed as values the way most exports write them:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Sales"
ws.append(["Region", "Rep", "Revenue"])
for row in [("North", "Ana", 1200), ("North", "Ben", 800), ("North Total", None, 2000),
("South", "Cara", 950), ("South", "Dev", 650), ("South Total", None, 1600),
("Grand Total", None, 3600)]:
ws.append(list(row))
wb.save("sales_subtotals.xlsx")
Step 1 — Confirm the problem with a quick sum
Before removing anything, measure it. Comparing the column sum with the value in the grand-total row tells you immediately whether rollups are present and roughly how many layers there are: a sum of three times the grand total means one layer of subtotals plus the grand total, and each extra layer of grouping — region, then country, then continent — adds another multiple.
import pandas as pd
df = pd.read_excel("sales_subtotals.xlsx", sheet_name="Sales")
column_sum = df["Revenue"].sum()
grand = df.loc[df["Region"] == "Grand Total", "Revenue"].item()
print(column_sum, grand, column_sum / grand) # 10800 3600 3.0
A ratio that is not a whole number is informative too. It usually means some groups have subtotals and others do not, or that a filter was applied to the detail after the totals were calculated — both reasons to look at the file before trusting any automatic cleanup.
Step 2 — Remove rows whose label says "total"
Most exports label their rollups, so the simplest filter matches the word in the label column. Use a word
boundary so that a customer called Totally Tiles Ltd survives, and allow Subtotal and Sub-total as
well as Total.
TOTAL_LABEL = r"\b(?:sub-?)?total\b"
is_total = df["Region"].astype(str).str.contains(TOTAL_LABEL, case=False, regex=True)
detail = df[~is_total].reset_index(drop=True)
print(detail)
print(detail["Revenue"].sum()) # 3600
astype(str) matters more than it looks. A label column that is otherwise empty on detail rows — common
when a report prints the region once per group — holds NaN, and .str.contains on a float NaN returns
NaN rather than False, which breaks the boolean mask.
Step 3 — Detect rollups by their empty detail fields
Some tools do not label totals at all; they print a bold number with nothing next to it. A rollup still has a tell: it has no value in the fields that identify a single record. Every real sale has a rep, so a row with a revenue figure and no rep is not a sale.
is_rollup = df["Rep"].isna() & df["Revenue"].notna()
print(df[is_rollup])
detail_by_shape = df[~is_rollup].reset_index(drop=True)
print(detail_by_shape.equals(detail)) # True for this file
Pick the identifying column carefully. It must be mandatory for every genuine detail row — an order ID, an invoice number, an employee code. A column that is usually filled, such as a comment or a secondary contact, will silently drop real records the day one of them is blank. When no single column is reliable, require several to be empty at once.
Step 4 — Find SUBTOTAL formulas with openpyxl
Excel's Data → Subtotal command and most hand-built reports write the rollups as SUBTOTAL formulas.
openpyxl can read the formula text, which identifies rollup rows exactly, without any assumption about
labels or empty cells. This file writes the totals as formulas and leaves the group label on the first
detail row only, as many reports do:
from openpyxl import Workbook, load_workbook
wb = Workbook()
ws = wb.active
ws.append(["Region", "Rep", "Revenue"])
ws.append(["North", "Ana", 1200])
ws.append([None, "Ben", 800])
ws.append(["North Total", None, "=SUBTOTAL(9,C2:C3)"])
ws.append(["South", "Cara", 950])
ws.append([None, "Dev", 650])
ws.append(["South Total", None, "=SUBTOTAL(9,C5:C6)"])
wb.save("formula_subtotals.xlsx")
book = load_workbook("formula_subtotals.xlsx") # formulas, not cached values
sheet = book.active
subtotal_rows = {
cell.row for cell in sheet["C"]
if isinstance(cell.value, str) and cell.value.upper().startswith("=SUBTOTAL(")
}
print(sorted(subtotal_rows)) # [4, 7]
raw = pd.read_excel("formula_subtotals.xlsx")
excel_row = pd.RangeIndex(len(raw)) + 2
clean = raw[~excel_row.isin(subtotal_rows)].copy()
clean["Region"] = clean["Region"].ffill() # label printed once per group
print(clean)
The forward fill at the end repairs the other half of this layout: the region is only written on the first
row of each group, so without it half of the detail rows would have no region. A file written by openpyxl
has no cached results for its formulas, which is why pandas shows NaN in those rows here; a file saved by
Excel carries the computed totals.
Step 5 — Reconcile your sums against the removed totals
Deleting the totals throws away information that the export's author computed for you. Keep them in a separate frame and compare them with your own group sums. If they match, you know both that the right rows were removed and that nothing was lost in the read.
totals = df[is_total].copy()
totals["Region"] = totals["Region"].str.replace(r"\s*total$", "", case=False, regex=True)
reported = totals.set_index("Region")["Revenue"]
grand_total = reported.pop("Grand")
computed = detail.groupby("Region")["Revenue"].sum()
mismatch = (computed - reported.reindex(computed.index)).abs() > 0.005
if mismatch.any() or abs(computed.sum() - grand_total) > 0.005:
raise ValueError(f"totals do not reconcile:\n{pd.concat([computed, reported], axis=1)}")
print("reconciled:", computed.to_dict(), "grand total", grand_total)
The small tolerance absorbs floating-point rounding in currency columns. A mismatch larger than that usually means one of three things: a detail row was filtered out by mistake, the export applied a filter before it calculated its totals, or the totals are formatted as rounded numbers while the detail keeps decimals.
Step 6 — Wrap it as a reusable cleaner
Put the rules in one function so every export passes through the same logic, and return both halves so the caller can reconcile or log them.
def split_rollups(frame: pd.DataFrame, label_col: str, id_col: str, value_col: str):
label_hit = frame[label_col].astype(str).str.contains(TOTAL_LABEL, case=False, regex=True)
shape_hit = frame[id_col].isna() & frame[value_col].notna()
rollup = label_hit | shape_hit
return frame[~rollup].reset_index(drop=True), frame[rollup].reset_index(drop=True)
detail_rows, rollup_rows = split_rollups(df, "Region", "Rep", "Revenue")
print(len(detail_rows), "detail rows,", len(rollup_rows), "rollup rows")
Combining the label rule with the shape rule with | catches both labelled and unlabelled rollups. Log the
number of rollup rows per file; a sudden change in that count is an early signal that the export's grouping
has changed.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| A real customer disappears | Label regex matched inside a name | Use word boundaries: \btotal\b |
Boolean mask error on .str.contains | Label column has NaN | Call astype(str) first |
| Detail rows removed by the shape rule | The identifying column is optional | Choose a mandatory field, or require several empty |
| Totals do not reconcile by a small amount | Rounded totals versus unrounded detail | Compare with a tolerance, or round both |
Subtotal rows read as NaN | Formulas written by a script, never calculated | Detect by formula, or recalculate as in Recalculate Excel Formulas Without Excel in Python |
| Half the rows have no group | Group label printed once per block | ffill() the label column after removing rollups |
Performance and scale notes
Both filters are vectorised string and null checks, so they are effectively free next to the read itself. The
openpyxl formula scan costs a full workbook load; on a large file, restrict it to the one value column with
sheet["C"] as shown, or use read_only=True, where cell values still expose formula strings and the scan
streams through the sheet instead of building it in memory. If the same
report format arrives every day, run the reconciliation every day too — it is a few milliseconds, and it is
the check that notices when an upstream filter changed.
Conclusion
Rollup rows are data about the data, and they have to come out before any aggregation. Find them by their
label, by the absence of a record identifier, or — most precisely — by their SUBTOTAL formulas, and use a
combination when the export is inconsistent. Then keep the removed rows long enough to reconcile your own
group sums against them. It turns a cleaning step into a correctness test that costs nothing.
Frequently asked questions
Why is my pandas sum a whole multiple of the real total? Because the sheet contains rollups that repeat the detail values: one layer of subtotals plus a grand total triples the sum. Removing the rollup rows brings it back to the true figure.
Can I just drop the last row to remove the grand total? Only if you are certain it is always the last row and there are no subtotals. Matching the label or the formula is safer and costs no more.
How do I keep the subtotals as a separate summary table? Select the rollup rows instead of discarding them, strip the word Total from the label, and write them to their own sheet. They make a convenient check against your own groupby.
Does pandas pivot_table produce the same kind of total rows? Only when margins=True, which adds an All row and column. Those are easy to remove by label, but it is simpler not to request margins when the data is going on to further processing.
Related
- Up one level: Extracting Data from Messy Excel Sheets — the full set of techniques for report-shaped sheets.
- Find the Header Row in an Excel Sheet with a Shifting Layout — cleaning the top of the same kind of export.
- Read Multiple Tables from One Excel Sheet — when blocks rather than totals share the sheet.
- Create a Pivot Table from Excel with Pandas — rebuilding the rollups properly from clean detail.
- SUMIF and SUMIFS Equivalent in pandas — conditional totals once the detail is clean.
More on messy spreadsheets
- How-toFreeze the Header Row in Excel with openpyxl
- How-toHandle Merged Cells When Reading Excel with pandas
- How-toUnmerge Cells and Fill the Values with openpyxl
- How-toCombine Sheets with Different Layouts into One Table
- How-toExtract Hyperlink URLs from Excel Cells with openpyxl
- How-toExtract Report Header Fields from an Excel Sheet with Python