Read an Excel File with polars.read_excel
polars.read_excel() is the fastest simple way to get a spreadsheet into a DataFrame: it parses through the Rust calamine reader and produces Arrow-backed columns ready for a multi-threaded query engine. The API is close enough to pandas that porting is mostly mechanical, but the type handling is stricter — which is a feature once you know where to declare types. This guide covers every argument you will actually use, plus the traps that bite when a real workbook meets a strict reader. It belongs to Reading Excel with Polars and Arrow.
Prerequisites
pip install "polars[excel]"
That pulls polars plus fastexcel, the binding over the calamine parser. Add pyarrow if you plan to convert to pandas or write Parquet, and xlsxwriter if you will write workbooks back out.
The basic read
import polars as pl
df = pl.read_excel("sales.xlsx")
print(df.head())
print(df.schema)
print(df.shape)
With no arguments the first sheet is read and the first row becomes the header. schema prints the inferred type of every column, and it is worth a look on any new file — the types decided here follow the data through the rest of the job.
Choose the sheet
Three forms cover everything:
by_name = pl.read_excel("sales.xlsx", sheet_name="Q3 Summary")
by_index = pl.read_excel("sales.xlsx", sheet_id=2) # 1-based
every = pl.read_excel("sales.xlsx", sheet_id=None) # dict of DataFrames
for name, frame in every.items():
print(f"{name}: {frame.height} rows x {frame.width} cols")
sheet_id is one-based, unlike pandas' zero-based sheet_name=0 — the most common porting slip. If a name might not exist, check first rather than catching an exception, and remember that sheet names carry invisible whitespace surprisingly often — see Fix "Worksheet does not exist" KeyError in openpyxl.
Skip banner rows and limit what you read
Real workbooks rarely start with the header in row 1. read_options passes settings straight through to the underlying reader:
df = pl.read_excel(
"sales.xlsx",
read_options={
"header_row": 3, # zero-based: row 4 in Excel holds the headers
"skip_rows": 0,
"n_rows": 10_000, # read a sample while developing
},
)
n_rows is the fastest way to iterate on a transformation against a huge file: read ten thousand rows, get the pipeline right, then remove the argument. If the sheet has no header at all, pass has_header: False and name the columns afterwards with df.columns = [...].
Declare the types instead of repairing them
Type inference is the one place a strict reader creates work, and schema_overrides is the answer. It is applied during the read, so nothing is ever materialised with the wrong type:
import polars as pl
df = pl.read_excel(
"sales.xlsx",
schema_overrides={
"order_id": pl.String, # keep leading zeros
"revenue": pl.Float64,
"order_date": pl.Date,
"quantity": pl.Int32,
},
)
print(df.schema)
Three columns benefit almost universally. Identifiers — order numbers, product codes, postcodes — must be strings or leading zeros vanish. Money should be an explicit Float64 (or Decimal where exactness matters) so a column of whole numbers in one file and decimals in another does not change type between runs. And dates should be pl.Date or pl.Datetime so later date arithmetic works without a cast.
When a column arrives as formatted text, clean and cast in one expression after the read:
df = df.with_columns(
pl.col("revenue").cast(pl.String)
.str.replace_all(r"[,$£€\s]", "")
.cast(pl.Float64, strict=False)
)
strict=False turns unparseable values into nulls instead of raising — the right behaviour when one stray footnote should not abort a nightly job. Count them afterwards with df["revenue"].null_count() so a silent data problem still gets reported.
Read from bytes, not just from disk
read_excel accepts any file-like object, which makes downloads and uploads straightforward:
import io
import polars as pl
import requests
resp = requests.get("https://example.com/reports/latest.xlsx", timeout=30)
resp.raise_for_status()
df = pl.read_excel(io.BytesIO(resp.content))
The same applies to a file object from a web framework's upload handling, or to bytes pulled from object storage. If the response might not be a workbook at all, check it first — the failure modes are catalogued in Fix "Excel file format cannot be determined" in pandas.
Hand off to pandas when it suits you
Conversion runs through Arrow and is cheap enough to do casually:
pdf = df.to_pandas() # for existing formatting or plotting code
again = pl.from_pandas(pdf)
This is what makes adoption incremental: read and reshape with Polars where the file is large, then convert and reuse whatever openpyxl or xlsxwriter code you already have. to_pandas() needs pyarrow installed.
Inspect the frame before trusting it
A read that raises no error can still be wrong: the header row was off by one, a merged title cell became a column of nulls, or a footer row of totals is now a data row. Three cheap calls catch nearly all of it:
print(df.glimpse()) # every column, its type, and the first values
print(df.null_count()) # nulls per column — a solid wall means a wrong header row
print(df.describe()) # min/max/mean, which exposes totals rows and stray magnitudes
describe() is the one that finds the footer: a max of exactly the sum of the column means a totals row slipped into the data. null_count() finds the opposite problem — a column that is entirely null usually means the header row was mis-set and the real names are sitting in row 1 as data.
Keep the three calls behind a verbose flag in a scheduled job so a suspicious run can be re-run with the audit on, without printing a wall of statistics on every ordinary night.
Read many sheets into one frame
A workbook with one sheet per month is common, and stacking them is a two-line job once you tag each frame with where it came from:
import polars as pl
sheets = pl.read_excel("year.xlsx", sheet_id=None)
combined = pl.concat(
[frame.with_columns(sheet=pl.lit(name)) for name, frame in sheets.items()],
how="diagonal_relaxed",
)
print(combined.group_by("sheet").len().sort("sheet"))
diagonal_relaxed unions columns across sheets that do not agree, filling the gaps with nulls — the behaviour you want when a producer added a column halfway through the year. Use plain vertical instead when a schema change should stop the job rather than pass silently.
Common pitfalls and gotchas
sheet_idis one-based. Portingsheet_name=0from pandas tosheet_id=0reads nothing useful.- No index. Anything that relied on a pandas index becomes an explicit column plus a join.
nullis notNaN.fill_null(0)leavesNaNvalues untouched; usefill_nan()for those.- Expressions do not evaluate on their own.
pl.col("x") * 2is a plan; it computes insideselect,with_columns,filteroragg. read_excelignores formatting entirely — colours, merged cells, comments and formulas-as-written are invisible. Use openpyxl when those matter, as in Read cell value from Excel with openpyxl.
Performance and scale notes
The parse is the expensive part of any Excel read, and calamine is several times faster than a pure-Python engine on the same file while using markedly less memory. Two habits compound that gain. Read only the columns you need — select immediately after the read so later operations carry less data — and convert workbooks you read repeatedly into Parquet, after which a lazy scan reads only the columns and row groups a query touches. Both are covered in Convert Excel files to Parquet with Python. For workbooks too large to hold at all, the chunking strategies in Read a large Excel file in chunks with pandas still apply — Excel cannot be streamed, so the answer is always to convert once and stream the converted form.
Conclusion
pl.read_excel() gives you a fast, typed read in one call. Choose the sheet with sheet_name or the one-based sheet_id, point the reader at the real header row with read_options, and declare the columns that matter with schema_overrides rather than repairing them later. From there the data is Arrow-backed and every transformation runs in parallel — and conversion to pandas remains available whenever the rest of your pipeline expects it.
Frequently asked questions
Which reader does polars.read_excel use?
By default it uses calamine through the fastexcel package — a Rust parser that handles .xlsx, .xlsm, .xls and .ods. That is why the install extra is needed and why the read is fast compared with a pure-Python engine.
How do I read every sheet at once?
Pass sheet_id=None. You get a dictionary keyed by sheet name, each value a DataFrame, the same shape pandas returns for sheet_name=None.
Why did my order numbers become floats?
Type inference saw digits and chose a numeric type, which drops leading zeros. Pass schema_overrides={"order_id": pl.String} so the column is read as text from the start.
Can Polars read a workbook from memory rather than a path?
Yes. read_excel accepts a file-like object, so an io.BytesIO holding a downloaded response works exactly like a path.
Does read_excel see cell colours or comments? No. Polars reads values only. For formatting, comments, merged regions or anything else about presentation, use openpyxl.
Related
- Up: Reading Excel with Polars and Arrow — where this reader fits among the Arrow-backed tools.
- Speed up pandas Excel reads with the calamine engine — the same parser without changing DataFrame library.
- Write a Polars DataFrame to Excel with formatting — the other half of the round trip.
- How to read Excel with pandas step by step — the pandas equivalent, argument for argument.
- Read specific columns from Excel with pandas — narrowing a read, which matters more the larger the file gets.