Combine All Sheets in an Excel Workbook
The regional workbook has twelve monthly sheets, a Notes sheet, a Lookup sheet feeding dropdowns and a hidden _calc sheet. The obvious one-liner produces a dictionary rather than a table, and concatenating it goes wrong in several ways at once:
>>> frames = pd.read_excel("in/sales-2026.xlsx", sheet_name=None)
>>> combined = pd.concat(frames.values(), ignore_index=True)
>>> combined.shape
(14210, 27) # expected ~11,800 rows and 9 columns
Twenty-seven columns because one sheet spells a header differently and the notes sheet contributes free text; extra rows because totals rows and the lookup sheet came along; and no way to tell which month a row came from.
Root Cause
sheet_name=None returns every worksheet in the file, including sheets that are not data: notes, lookup tables feeding data validation, hidden calculation sheets, and any sheet an analyst left behind. pd.concat then aligns frames by column name, so Order Date and order_date become two columns with half the values missing in each, and a sheet with a title row above the header contributes columns named Unnamed: 1. Sheet identity is lost entirely — after concatenation there is nothing to say a row came from Mar — and rows that are not data, such as a bold Total line at the bottom of each month, are indistinguishable from real records. The fix is to decide which sheets are data, normalise their headers to a common schema, and tag each row with its source before combining.
Minimal Diagnostic
Inspect the workbook's structure before reading any data: which sheets exist, their visibility, dimensions, header row and column names.
# pip install "pandas>=2.2" openpyxl
from pathlib import Path
import pandas as pd
from openpyxl import load_workbook
SOURCE = Path("in/sales-2026.xlsx")
def workbook_report(path: Path, probe_rows: int = 6) -> pd.DataFrame:
wb = load_workbook(path, read_only=True)
rows = []
try:
for ws in wb.worksheets:
first_cells = []
for row in ws.iter_rows(min_row=1, max_row=probe_rows, values_only=True):
filled = [str(c).strip() for c in row if c is not None and str(c).strip()]
first_cells.append(filled)
header_row = next((i for i, cells in enumerate(first_cells) if len(cells) >= 3), None)
rows.append({
"sheet": ws.title,
"state": ws.sheet_state, # visible / hidden / veryHidden
"rows": ws.max_row,
"cols": ws.max_column,
"header_row": None if header_row is None else header_row + 1,
"header_preview": ", ".join(first_cells[header_row][:4]) if header_row is not None else "",
})
finally:
wb.close()
return pd.DataFrame(rows)
if __name__ == "__main__":
print(workbook_report(SOURCE).to_string(index=False))
sheet state rows cols header_row header_preview
Jan visible 988 9 2 Order ID, Order Date, Customer, Region
Feb visible 902 9 2 Order ID, Order Date, Customer, Region
Mar visible 1043 9 2 Order ID, order date, Customer, Region
...
Notes visible 14 2 1 Sheet, Purpose
Lookup visible 41 3 1 region, manager
_calc hidden 200 12 1 tmp, x
Every month sheet has a title row above its header (header on row 2), Mar spells one header differently, and three sheets are not data at all — one of them hidden.
Fix: Select Sheets, Normalise Headers, Tag Rows, Then Combine
Read only the sheets that are data, find each one's header row, map its columns onto a declared schema, and add the sheet name before concatenating. Changed lines carry comments.
# pip install "pandas>=2.2" openpyxl
import re
from pathlib import Path
import pandas as pd
from openpyxl import load_workbook
SOURCE = Path("in/sales-2026.xlsx")
MONTHS = ("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec")
SCHEMA = { # changed: canonical name per header variant
"order id": "order_id", "order no": "order_id",
"order date": "order_date", "date": "order_date",
"customer": "customer", "customer name": "customer",
"region": "region", "qty": "qty", "quantity": "qty",
"amount": "amount", "net amount": "amount", "revenue": "amount",
}
REQUIRED = {"order_id", "order_date", "customer", "region", "amount"}
def data_sheets(path: Path) -> list[str]:
wb = load_workbook(path, read_only=True)
try:
return [ws.title for ws in wb.worksheets
if ws.sheet_state == "visible" # changed: skip hidden sheets
and ws.title.strip().lower()[:3] in MONTHS # changed: only month sheets
and ws.max_row > 2]
finally:
wb.close()
def find_header_row(path: Path, sheet: str, probe: int = 8) -> int:
preview = pd.read_excel(path, sheet_name=sheet, header=None, nrows=probe, dtype="string")
for i, row in preview.iterrows():
labels = [str(v).strip().lower() for v in row if pd.notna(v)]
if sum(label in SCHEMA for label in labels) >= 3: # changed: the row that looks like a header
return int(i)
raise ValueError(f"{sheet}: no header row found in the first {probe} rows")
def read_sheet(path: Path, sheet: str) -> pd.DataFrame:
header_row = find_header_row(path, sheet)
frame = pd.read_excel(path, sheet_name=sheet, header=header_row, dtype={"Order ID": "string"})
renamed = {}
for col in frame.columns:
key = re.sub(r"\s+", " ", str(col)).strip().lower()
if key in SCHEMA:
renamed[col] = SCHEMA[key] # changed: map onto the canonical schema
frame = frame.rename(columns=renamed)
missing = REQUIRED - set(frame.columns)
if missing:
raise ValueError(f"{sheet}: missing columns {sorted(missing)} (headers: {list(frame.columns)[:8]})")
frame = frame[list(REQUIRED | (set(frame.columns) & {"qty"}))]
frame = frame[frame["order_id"].notna()] # changed: drop total and blank rows
return frame.assign(source_sheet=sheet) # changed: keep provenance
def combine(path: Path) -> pd.DataFrame:
sheets = data_sheets(path)
frames = [read_sheet(path, sheet) for sheet in sheets]
combined = pd.concat(frames, ignore_index=True)
combined["order_date"] = pd.to_datetime(combined["order_date"], errors="coerce")
return combined
if __name__ == "__main__":
sales = combine(SOURCE)
print(sales.shape, sales["source_sheet"].nunique(), "sheets")
print(sales.groupby("source_sheet", sort=False).size().to_string())
(11812, 7) 12 sheets
Jan 986
Feb 900
Mar 1041
...
Raising when a sheet lacks a required column — rather than letting concat fill it with NaN — is what turns a renamed header into an immediate, specific error. The order_id.notna() filter removes the bold total row at the bottom of each sheet, which has an amount but no order id; check your own workbooks for what distinguishes those rows before copying the rule.
Variant Fix 1: Many Workbooks, One per Branch
The same pattern extends to a folder of workbooks, adding the file as a second provenance column:
# pip install "pandas>=2.2" openpyxl
from pathlib import Path
import pandas as pd
def combine_folder(folder: Path) -> tuple[pd.DataFrame, list[str]]:
frames, problems = [], []
for path in sorted(folder.glob("*.xlsx")):
if path.name.startswith("~$"):
continue # Excel lock files
try:
frame = combine(path)
except Exception as exc:
problems.append(f"{path.name}: {exc}")
continue
frames.append(frame.assign(source_file=path.stem))
combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
return combined, problems
Collecting problems instead of raising lets one malformed branch workbook be reported without stopping the other eleven — with the file name in the message, so the right person can be asked. Where files also differ in structure, keep a per-file schema override next to the shared SCHEMA, rather than making the shared mapping ever more permissive.
Variant Fix 2: Sheets with Different Columns on Purpose
Some workbooks genuinely differ per sheet — a Services sheet with hours where Products has qty. Concatenating them is right, but the result should make the difference explicit rather than leaving silent NaN columns:
# pip install "pandas>=2.2"
import pandas as pd
def combine_heterogeneous(frames: dict[str, pd.DataFrame]) -> tuple[pd.DataFrame, pd.DataFrame]:
all_columns = sorted({c for f in frames.values() for c in f.columns})
coverage = pd.DataFrame(
{name: [c in f.columns for c in all_columns] for name, f in frames.items()},
index=all_columns,
)
combined = pd.concat(
[f.assign(source_sheet=name) for name, f in frames.items()],
ignore_index=True, sort=False,
)
return combined, coverage
The coverage table — columns against sheets — is worth printing in the job's log every run. When a supplier adds a column to one sheet only, it appears as a new row in the coverage table rather than as mysterious missing values in a report three weeks later.
Verification
Reconcile the combined frame against the workbook: row counts per sheet, totals per sheet, and no rows from non-data sheets.
# pip install "pandas>=2.2" openpyxl
from pathlib import Path
import pandas as pd
from openpyxl import load_workbook
def verify_combined(path: Path, combined: pd.DataFrame) -> None:
expected_sheets = set(data_sheets(path))
assert set(combined["source_sheet"]) == expected_sheets, \
f"sheets in result differ: {set(combined['source_sheet']) ^ expected_sheets}"
wb = load_workbook(path, read_only=True)
try:
for sheet in expected_sheets:
ws = wb[sheet]
header_row = find_header_row(path, sheet) + 1
filled = sum(1 for row in ws.iter_rows(min_row=header_row + 1, values_only=True)
if row and row[0] not in (None, ""))
got = int((combined["source_sheet"] == sheet).sum())
assert abs(filled - got) <= 1, f"{sheet}: {got} rows combined, {filled} data rows in the sheet"
finally:
wb.close()
assert combined["order_id"].notna().all(), "rows without an order id survived the filter"
assert combined["amount"].notna().all(), "rows with no amount survived the filter"
duplicates = combined[combined.duplicated(subset=["order_id"], keep=False)]
if not duplicates.empty:
print(f"note: {len(duplicates)} row(s) share an order id across sheets, e.g. "
f"{duplicates['order_id'].iloc[0]} in {duplicates['source_sheet'].unique()[:3]}")
print(f"verified {len(combined):,} rows from {len(expected_sheets)} sheets")
The tolerance of one row absorbs a total row that the openpyxl count includes and the filter removed. Duplicate order ids across sheets are reported rather than asserted against: in a monthly workbook they usually mean an order was restated in a later month, which is a business question, not a bug — but one that has to reach a person before the numbers are used, as it affects any sum.
FAQ
How do I read only some sheets?
Pass a list: pd.read_excel(path, sheet_name=["Jan", "Feb"]) returns a dictionary with those keys, and sheet_name=0 reads only the first.
Is reading all sheets slow? Reading a workbook parses the whole file regardless, so reading several sheets costs little more than one. The expensive part is very large sheets.
Why do some sheets come back with Unnamed: 0 columns?
Their header row is not row 1. Detect it per sheet, as the fix does, or pass header= and skiprows= per sheet.
Can I keep the sheet order?data_sheets returns them in workbook order, and pd.concat preserves input order, so the result follows the tabs — useful when sheets are months.
Related
- Merging Multiple Spreadsheets — combining files, joins and alignment
- Fix pandas concat Columns Misaligned — what happens when headers differ
- Write Multiple Sheets to One Excel File — the writing side of multi-sheet workbooks
- Reading Excel Files with Python — engines, dtypes and header handling
Part of Merging Multiple Spreadsheets.