Datagrunt 4.2.0: First-Class Excel Reading and Writing
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 dictsA 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 positionYou 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’ ownread_excelparameter (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 backsquery_datais 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 datagruntfrom datagrunt import ExcelReader, ExcelWriter