Read Multiple Excel Sheets with Polars
A workbook with one sheet per month — plus a README tab and a pivot someone added — is one of the most common inputs to a reporting job. Polars reads each sheet quickly with its Rust-based engine, but stacking them is where problems appear: sheets that gained a column halfway through the year, an ID column that Polars infers as an integer in January and as text in February, and tabs that are not data at all. This guide, part of Reading Excel with Polars and Arrow, builds a reliable multi-sheet read. The single-sheet basics are covered in Read an Excel File with Polars read_excel.
Prerequisites
pip install polars fastexcel pandas openpyxl
A sample workbook with the usual inconsistencies — written with pandas here, but in practice it comes from someone else:
import pandas as pd
import polars as pl
with pd.ExcelWriter("year.xlsx") as writer:
pd.DataFrame({"Info": ["Monthly sales workbook"]}).to_excel(writer, sheet_name="README", index=False)
pd.DataFrame({"region": ["North", "South"], "units": [10, 4], "order_id": [101, 102]}) \
.to_excel(writer, sheet_name="Jan 2026", index=False)
pd.DataFrame({"region": ["North", "East"], "units": [7, 3], "order_id": ["A-103", 104],
"discount": [0.1, None]}).to_excel(writer, sheet_name="Feb 2026", index=False)
pd.DataFrame({"region": ["West"], "units": [5.5], "order_id": [105]}) \
.to_excel(writer, sheet_name="Mar 2026", index=False)
pd.DataFrame({"x": [1]}).to_excel(writer, sheet_name="Pivot", index=False)
Step 1 — List the sheets and pick the data ones
Reading every sheet with sheet_id=None would also load the README and the pivot. List the names first — fastexcel, the engine Polars uses, reports them without reading any rows — and select the data sheets by a pattern:
import re
import fastexcel
names = fastexcel.read_excel("year.xlsx").sheet_names
print(names) # ['README', 'Jan 2026', 'Feb 2026', 'Mar 2026', 'Pivot']
month_sheets = [n for n in names if re.fullmatch(r"[A-Z][a-z]{2} \d{4}", n.strip())]
print(month_sheets) # ['Jan 2026', 'Feb 2026', 'Mar 2026']
Selecting by pattern, rather than by position or a fixed list, keeps working when April is added and when someone inserts a notes tab in the middle. strip() guards against the trailing spaces that sheet names pick up surprisingly often.
Step 2 — Read the sheets and see why they do not stack
Pass the list to sheet_name and Polars returns a dictionary of DataFrames. Look at the schemas before combining:
frames = pl.read_excel("year.xlsx", sheet_name=month_sheets)
for name, frame in frames.items():
print(name, dict(frame.schema))
# Jan 2026 {'region': String, 'units': Int64, 'order_id': Int64}
# Feb 2026 {'region': String, 'units': Int64, 'order_id': String, 'discount': Float64}
# Mar 2026 {'region': String, 'units': Float64, 'order_id': Int64}
Three differences, each typical. February has an extra discount column. order_id is an integer in January and March but a string in February, because one February ID is "A-103". And units is a float in March because of the 5.5. A plain vertical concat refuses:
try:
pl.concat(list(frames.values()), how="vertical")
except pl.exceptions.ShapeError as exc:
print(exc) # unable to append to a DataFrame of width 3 with a DataFrame of width 4
Step 3 — Declare the types once for every sheet
The robust fix is to decide the types rather than let each sheet guess. schema_overrides applies to every sheet read in the call:
TYPES = {"region": pl.String, "units": pl.Float64, "order_id": pl.String}
frames = pl.read_excel("year.xlsx", sheet_name=month_sheets, schema_overrides=TYPES)
for name, frame in frames.items():
print(name, frame.schema["order_id"], frame.schema["units"])
# Jan 2026 String Float64
IDs become text everywhere — "101" and "A-103" alike — which is right for identifiers: nobody adds order numbers together. Units become floats in every sheet. Declaring types keeps the column's type from changing month to month. It does not make bad values fail, though: a cell that cannot be converted — "n/a" in the units column, say — becomes null without an error, so count the nulls per column after reading and compare them with the source. Declaring types at read time is covered further in Check Excel Data Types with pandas, and the same reasoning applies to Polars.
Step 4 — Stack with the right concat mode
With the types aligned, only the extra column remains. how="diagonal" unions the columns and fills the gaps with nulls; tag each frame with its sheet first so every row keeps its origin:
combined = pl.concat(
[frame.with_columns(sheet=pl.lit(name)) for name, frame in frames.items()],
how="diagonal",
)
print(combined.select("sheet", "region", "units", "order_id", "discount"))
Choosing the concat mode is choosing what counts as an error. vertical demands identical columns and types. diagonal allows different columns but identical types. diagonal_relaxed also widens mismatched types to a common supertype — Int64 and String become String — which works but hides the change. With schema_overrides in place, diagonal is the safest choice that still tolerates a new column.
Step 5 — Turn the sheet name into a real date
The sheet name is text; the report needs a month. Parse it once, after stacking:
combined = combined.with_columns(
month=pl.col("sheet").str.strip_chars().str.to_date("%b %Y")
)
print(combined.group_by("month").agg(pl.col("units").sum()).sort("month").rows())
# [(datetime.date(2026, 1, 1), 14.0), (datetime.date(2026, 2, 1), 10.0), (datetime.date(2026, 3, 1), 5.5)]
A proper date sorts chronologically — text would put "Feb" before "Jan" — and works with Polars' date functions for quarters and fiscal years. If a sheet name does not match the format, to_date raises an error that names the value, which is the behaviour you want for a scheduled job.
Step 6 — Many workbooks, many sheets
The same function scales to a folder of yearly workbooks: list the files, select the sheets in each, read with the shared types and stack everything with a file column as well as a sheet column:
from pathlib import Path
def read_month_sheets(path):
names = [n for n in fastexcel.read_excel(path).sheet_names
if re.fullmatch(r"[A-Z][a-z]{2} \d{4}", n.strip())]
frames = pl.read_excel(path, sheet_name=names, schema_overrides=TYPES)
return [f.with_columns(sheet=pl.lit(n), file=pl.lit(Path(path).name)) for n, f in frames.items()]
all_frames = [f for path in sorted(Path(".").glob("year*.xlsx")) for f in read_month_sheets(path)]
everything = pl.concat(all_frames, how="diagonal")
print(everything.height, everything["file"].unique().to_list()) # 5 ['year.xlsx']
Polars reads each sheet in native code, which makes this noticeably faster than a pandas loop for large workbooks; the Python overhead is only the loop over files and sheets. Converting the combined result to Parquet for later jobs is covered in Convert Excel Files to Parquet with Python.
Step 7 — Check every sheet made it in
A stacked frame hides which sheets contributed. Before using it, compare the rows per sheet with what was read and flag sheets that are empty or missing a required column:
REQUIRED = {"region", "units", "order_id"}
report = []
for name, frame in frames.items():
report.append({"sheet": name, "rows": frame.height,
"missing": ", ".join(sorted(REQUIRED - set(frame.columns)))})
checks = pl.DataFrame(report)
print(checks.rows())
# [('Jan 2026', 2, ''), ('Feb 2026', 2, ''), ('Mar 2026', 1, '')]
assert checks["rows"].sum() == combined.height
A sheet with zero rows usually means someone created next month's tab early; a missing required column means the layout changed. Both are easy to spot in this small table and hard to spot in the combined data, where a null column looks like missing values rather than a missing sheet.
Common pitfalls
- Zero-based habits.
sheet_idis one-based in Polars; pandas'sheet_name=0becomessheet_id=1. Selecting by name avoids the question. - Empty sheets. A month sheet with only a header row reads as a zero-row frame with guessed types.
schema_overrideskeeps it compatible, so the concat does not fail on it. - Rows that vanish.
read_exceldrops empty rows by default. A row whose only value failed to convert becomes entirely null and is dropped with the blanks; passdrop_empty_rows=Falsewhen you need to account for every source row. - Header rows that move. If some sheets have a title above the table, pass
read_options={"header_row": n}— but only when every selected sheet shares the layout; otherwise read those sheets separately.
Conclusion
To read many sheets with Polars, list the sheet names with fastexcel and select data sheets by pattern, read them in one call with schema_overrides so every sheet has the same types, tag each frame with its sheet and file, and stack with how="diagonal" to tolerate new columns without hiding type changes. Parse sheet names into real dates once the data is combined.
Frequently asked questions
How do I read several sheets with Polars? Pass a list of names to sheet_name, or sheet_id=None for all sheets. Polars returns a dictionary keyed by sheet name with one DataFrame per sheet, which you can tag and concatenate.
How do I list the sheet names without reading the data? Open the file with fastexcel, the engine Polars uses, and read its sheet_names attribute. It returns the names in workbook order without loading any rows.
Why does pl.concat fail with a ShapeError or a schema error? The sheets do not have the same columns or the same types. Use how="diagonal_relaxed" to union the columns and widen types, or better, fix the types at read time with schema_overrides so every sheet agrees.
Why is an ID column an integer in one sheet and a string in another? Polars infers types per sheet. A sheet whose IDs are all numbers gets Int64, while a sheet containing a value like A-103 gets String. Declare the type with schema_overrides so all sheets match.
Related
- Up one level: Reading Excel with Polars and Arrow — fast Excel reading.
- Clean and Aggregate Excel Data with Polars Expressions — what to do with the combined frame.
- Read All Sheets from an Excel File into DataFrames — the pandas version.
- Pandas vs Polars for Excel Workflows — when Polars is worth it.
More on polars & arrow
- How-toClean and Aggregate Excel Data with Polars Expressions
- How-toClean Wide Excel Sheets with Polars Column Selectors
- How-toConvert pandas Excel Code to Polars
- FixFix Schema and Type Errors When Reading Excel with Polars
- How-toJoin and Pivot Excel Data with Polars
- How-toRead an Excel File with polars.read_excel