Guide
Advanced Data Transformation And CleaningDeep dive

Read an Excel File with polars.read_excel

Read xlsx into Polars: pick sheets, set the header row, control column types with schema_overrides, handle dates and nulls, and convert to pandas when you need to.

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.

The arguments that shape a read_excel call Sheet selection chooses what to read, read_options control the header and skipped rows, and schema_overrides decide the column types before any data is materialised. Three decisions, made once, at the read which sheet sheet_name="Q3" sheet_id=2 sheet_id=None → dict where the data starts read_options= {"header_row": 2} {"n_rows": 5000} what the types are schema_overrides= {"order_id": pl.String} declared, not repaired Getting these right at the read removes most downstream cleaning

Prerequisites

Bash
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

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

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

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

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

What inference does to three common columns Left to inference an order id loses leading zeros, a mixed money column becomes a string, and a date stays a serial number; declaring the schema fixes all three at the read. inferred declared order_id "00417" → 417 (Int64) leading zeros gone, joins now miss pl.String keeps "00417" revenue "1,234.50" → String sums silently concatenate or fail strip commas, cast to Float64 order_date 45900 → Int64 an Excel serial, not a date pl.Date parses on the way in

When a column arrives as formatted text, clean and cast in one expression after the read:

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

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

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

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

Three checks and the defect each one finds glimpse reveals wrong types, null_count reveals a mis-set header row, and describe reveals a totals row that was read as data. A three-line audit after every new file glimpse() types and sample values finds: a numeric id null_count() nulls per column finds: wrong header row describe() min, max, mean finds: a totals row Each of these has shipped a wrong report at least once

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:

Python
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_id is one-based. Porting sheet_name=0 from pandas to sheet_id=0 reads nothing useful.
  • No index. Anything that relied on a pandas index becomes an explicit column plus a join.
  • null is not NaN. fill_null(0) leaves NaN values untouched; use fill_nan() for those.
  • Expressions do not evaluate on their own. pl.col("x") * 2 is a plan; it computes inside select, with_columns, filter or agg.
  • read_excel ignores 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.