How-to
Advanced Data Transformation And CleaningDeep dive

Read Multiple Excel Sheets with Polars

Read the month sheets of a workbook into one Polars DataFrame — list sheet names without loading data, pick sheets by pattern, force consistent column types across sheets, stack them with the right concat mode, turn sheet names into dates and repeat across many workbooks.

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.

Month sheets stacked into one frame, other tabs skipped The workbook has README, Jan 2026, Feb 2026, Mar 2026 and Pivot tabs. Only the three month sheets are read, each tagged with its sheet name and read with the same column types, then stacked into a single Polars DataFrame. One DataFrame + sheet column Jan 2026 region, units, order_id Feb 2026 + discount column Mar 2026 units with decimals

Prerequisites

Bash
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:

Python
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:

Python
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:

Python
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:

Python
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
Types inferred separately for each sheet Polars infers the order_id column as Int64 in January and March but as String in February, where one ID is A-103. The units column is Int64 in January and February and Float64 in March. Only February has a discount column. A B C D 1 sheet units order_id discount 2 Jan 2026 Int64 Int64 — 3 Feb 2026 Int64 String Float64 4 Mar 2026 Float64 Int64 — README Jan 2026 Feb 2026 Mar 2026 Pivot gold types disagree with the other sheets; teal is a column only one sheet has

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:

Python
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:

Python
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.

What each concat mode accepts vertical requires identical columns and types. diagonal accepts different columns, filling gaps with nulls, but requires matching types. diagonal_relaxed accepts different columns and widens mismatched types to a common supertype, such as String for Int64 and String. Different columns Different types how="vertical" how="diagonal" how="diagonal_relaxed" yes, widened

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:

Python
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:

Python
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:

Python
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_id is one-based in Polars; pandas' sheet_name=0 becomes sheet_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_overrides keeps it compatible, so the concat does not fail on it.
  • Rows that vanish. read_excel drops empty rows by default. A row whose only value failed to convert becomes entirely null and is dropped with the blanks; pass drop_empty_rows=False when 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.

More on polars & arrow

More on polars & arrow →