Datagrunt 4.2.0: First-Class Excel Reading and Writing

June 25, 2026by Martin Graham

Datagrunt has been able to write .xlsx files for a while — CSVWriter.write_excel() has shipped since the early releases. What it could never do was read one. If your data started life in a spreadsheet, you were on your own.

Datagrunt 4.2.0 closes that gap with a full, symmetric pair: ExcelReader and ExcelWriter, designed to feel exactly like the CSV and PDF classes you already know.


Reading a workbook

from datagrunt import ExcelReader

reader = ExcelReader("workbook.xlsx")

reader.to_dataframe()      # first sheet as a Polars DataFrame
reader.to_arrow_table()    # ... as a PyArrow Table
reader.to_dicts()          # ... as a list of row dicts

A path that is not an Excel file raises a ValueError at construction, and a missing path raises FileNotFoundError — mistakes surface immediately, not deep inside a parse.

Reading is powered by a single canonical engine — Polars + calamine (via fastexcel) — so there is no engine argument to choose. It is fast, and fastexcel ships as a core dependency, so ExcelReader works on a plain pip install datagrunt.


Workbooks have sheets

The defining difference from CSV is that a workbook holds multiple worksheets. ExcelReader is workbook-scoped: .sheets lists the tabs, and every method takes an optional sheet argument — a name or a zero-based position — defaulting to the first sheet.

reader.sheets                          # ['People', 'Products', ...]
reader.to_dataframe(sheet="Products")  # by name
reader.to_dicts(sheet=1)               # by position

You can even run SQL against a sheet — the selected sheet is registered into an on-demand DuckDB connection under db_table:

reader.query_data(f"SELECT * FROM {reader.db_table}", sheet="Products")

The full Polars read surface, passed straight through

Because reading is Polars-only, you get the entire read_excel option set for free via **read_options — set once on the constructor (applied to every read) or per call (per-call wins):

# Treat the first row as data rather than a header
reader.to_dataframe(has_header=False)

# Skip the leading rows and cap the read (calamine read_options)
reader.to_dataframe(read_options={"skip_rows": 2, "n_rows": 100})

The keys source, sheet_id, and sheet_name are reserved — sheet selection is controlled exclusively through sheet= — and passing them raises a ValueError.

One mechanism, two signatures. You always pass read options as keyword arguments, and Datagrunt forwards them verbatim to the underlying Polars function — and this works the same way for every Datagrunt reader (CSV, Excel, and Parquet). The read_options={...} dict you see above is Polars’ own read_excel parameter (the calamine engine’s option bag), not a Datagrunt convention. Other Polars functions have different signatures: pl.read_parquet, for instance, exposes its options as plain top-level keyword arguments (columns, n_rows, …) with no such dict. Same passthrough, different Polars parameters.


Writing it back out

ExcelWriter reads a workbook and exports its sheet(s) to any of Datagrunt’s formats. By default it operates on the first sheet (or the sheet you name); all_sheets=True exports everything at once.

from datagrunt import ExcelWriter

writer = ExcelWriter("workbook.xlsx")

writer.write_csv("out.csv")                       # first sheet
writer.write_parquet("out.parquet", sheet="Products")

# One file per sheet for single-table formats...
writer.write_csv("out.csv", all_sheets=True)      # out_People.csv, out_Products.csv, ...

# ...and a single multi-tab workbook for Excel
writer.write_excel("all.xlsx", all_sheets=True)

Consistent with the rest of Datagrunt

  • Same normalize_columns. Set it on the constructor (or per call) to get normalized, collision-safe column names everywhere — using the exact same implementation as the CSV API.
  • Automatic resource lifecycle. You never have to call .close(). The DuckDB connection that backs query_data is opened on demand and released when the reader goes out of scope; close() and the context-manager protocol are available for early release.
  • Preserve, not transform. Cell values are written verbatim. As with CSV, a value beginning with =, +, -, or @ is treated as a formula when opened in a spreadsheet application (CSV/formula injection, CWE-1236) — Datagrunt documents this and leaves sanitization to the application layer rather than silently mutating your data.

Upgrade today

Datagrunt 4.2.0 is available on PyPI:

uv pip install --upgrade datagrunt
from datagrunt import ExcelReader, ExcelWriter