Skip to main content

excelreader (Python)

Read and write XLSX, XLSB, XLS and CSV through ExcelReader's NativeAOT library. No .NET runtime required — the shared library is self-contained.

Install (from source)

python python/scripts/build_native.py   # requires the .NET 10 SDK, once per machine
pip install -e "python[dev]"

build_native.py publishes src/ExcelReader.Native for your platform and copies the resulting ExcelReader.Native.{dll,so,dylib} into excelreader/_lib/. To point at a binary you built elsewhere, set EXCELREADER_NATIVE_LIB to its full path.

Usage

from excelreader import open_workbook

with open_workbook("book.xlsx") as workbook:
    print(workbook.sheet_count, workbook.sheet_name)
    for row in workbook.rows():
        for cell in row:
            print(cell.column, cell.type.name, cell.value)

Formats

open_workbook sniffs XLS/XLSX/XLSB by file signature. CSV has no signature, so it is chosen by the .csv extension — or explicitly:

open_workbook("data.txt", format="csv")

Dates

cell.value is always the raw text as stored, so CellType.DATE cells hold Excel serial numbers. Use Cell.as_date() to convert, passing the workbook's epoch flag:

as_date = cell.as_date(workbook.is_date1904)

as_date() returns None for any cell that isn't CellType.DATE.

Reading everything at once

rows() iterates row-by-row; read_all() materializes the whole sheet in one call:

all_rows = workbook.read_all()

This holds every row in memory at once, so prefer rows() for very large sheets.

Reading everything at once, faster

read_all()/rows() build one Cell/str object per cell, which dominates wall-clock time on a large sheet. read_all_columnar() decodes the same data into parallel flat arrays instead — no per-cell object construction — and is several times faster on large sheets:

sheet = workbook.read_all_columnar()
# sheet.row_offsets[i]:row_offsets[i+1]  -> cell indices for row i
# sheet.columns[j] / sheet.types[j]      -> cell j's column index / CellType
# sheet.value_offsets[j]:[j+1]           -> cell j's byte slice into sheet.values

Materialize a single cell on demand instead of decoding every value up front:

from excelreader import decode_cell

first_cell = decode_cell(sheet, 0)

Each array is a stdlib array.array('i'), or a NumPy int32 array if NumPy is installed (pip install -e "python[numpy]") — NumPy is optional and never required.

Typed columns — the fastest path

Everything above hands back cell text, which means the library formats every value to a string on the way out. parse_typed() skips that entirely: you give it a schema, and the conversion happens natively, straight into typed column buffers. On a 65,536 × 14 sheet it is ~8× faster than read_all_columnar(), ~25× faster than read_all(), and faster than polars.read_excel() — see Reading below for the measured numbers.

from excelreader import ColumnSpec, ColumnType

with open_workbook("sales.xlsb") as workbook:
    table = workbook.parse_typed([
        ColumnSpec(ColumnType.STRING, name="Region"),
        ColumnSpec(ColumnType.DATE, name="Order Date"),
        ColumnSpec(ColumnType.F64, name="Total Revenue", nullable=True),
    ])

table.row_count            # rows read
table.names                # ["Region", "Order Date", "Total Revenue"]
region, day, revenue = table.columns
region[0]                  # "Asia" — strings decode on demand, not one str per row up front
day[0]                     # 15477 — days since 1970-01-01
revenue[0]                 # 14862.69
table.validity[2]          # bit-packed nulls, or None when the column has none

Leave name out to resolve a column by position instead: ColumnSpec(ColumnType.I64, index=3). header_row defaults to 1 (the first row names the columns); pass header_row=0 for a sheet with no header, where every spec must resolve by index.

A column that fails to convert is an error unless its spec sets nullable=True, which records the failure in table.validity and keeps reading.

Note that parse_typed() always reads the whole sheet from its first row, independent of how far rows() has advanced — and it leaves that cursor alone.

Reading a sheet a batch at a time

For a sheet too large to hold in memory at once, iter_parse_typed() is parse_typed() a batch at a time — a generator yielding one TypedTable per batch:

with open_workbook("sales.xlsb") as workbook:
    schema = [
        ColumnSpec(ColumnType.STRING, name="Region"),
        ColumnSpec(ColumnType.DATE, name="Order Date"),
        ColumnSpec(ColumnType.F64, name="Total Revenue", nullable=True),
    ]
    for batch in workbook.iter_parse_typed(schema, batch_size=10_000):
        region, day, revenue = batch.columns
        ...

batch_size is rows per batch; 0 means one unbounded batch, identical to parse_typed(), and a negative value is an error. A workbook serves one chunked read at a time — of either kind, typed or Arrow (see Arrow below) — and any other read on the workbook while one is live invalidates it: its next call then raises ExcelReaderError rather than silently resuming from the moved cursor. Finish the batches, or call .close() on the generator, before starting another read.

Being a generator, iter_parse_typed() opens nothing until the first iteration, so a bad batch_size or a rejected second reader is only raised there, not at the call.

Guessing a schema

Writing the ColumnSpec list by hand means already knowing every column's name and type. When you don't, infer_schema() samples the sheet and guesses one for you:

with open_workbook("sales.xlsb") as workbook:
    schema = workbook.infer_schema()   # header_row=1, sample_size=100 by default
    table = workbook.parse_typed(schema)

Each column's type comes from the CellType Excel already stored for its sampled cells — not text sniffing — so it costs nothing beyond the sample and is exact for XLSX/XLSB/XLS. A column with a real mix of kinds, only formula/error results, or nothing sampled falls back to ColumnType.STRING; nullable is set when any sampled row left the column empty. CSV cells carry no such type tag, so every CSV column is guessed ColumnType.STRING — inspect the result (or just try parsing) before trusting it, especially past the sample.

Writing

write_workbook() writes a TypedTable (what parse_typed() returns) back out as a single sheet, through the same xl_write_typed native export — one-shot, no writer handle before or after the call:

from excelreader import ColumnType, write_workbook

with open_workbook("sales.xlsb") as workbook:
    table = workbook.parse_typed(workbook.infer_schema())

types = [ColumnType.STRING, ColumnType.DATE, ColumnType.F64]  # one per table.columns, in order
write_workbook("sales_copy.xlsx", table, types)

types is required because a TypedTable column is a raw buffer (array/StringColumn/NumPy array) and nothing about the buffer alone tells I64 from TIME apart — both are 8-byte-per-row arrays. format is inferred from the path's extension (one of xlsx/xlsb/xls/csv) or set explicitly:

write_workbook("report.dat", table, types, format="csv")

write_pandas() and write_polars() build the table from a DataFrame instead (both go through write_arrow(), so pyarrow must be installed):

from excelreader import write_pandas, write_polars

write_pandas("report.xlsx", df)          # requires pandas + pyarrow
write_polars("report.xlsx", polars_df)   # requires polars + pyarrow

WriteOptions sets the sheet name and CSV dialect, mirroring xl_write_options — every field defaults to None, meaning "use the library default":

from excelreader import WriteOptions

write_workbook(
    "report.xlsx", table, types,
    options=WriteOptions(sheet_name="Q3 Results", use_shared_strings=True),
)

Phase-1 limits, stated plainly: a single sheet only (no multi-sheet workbooks); the whole table must already be in memory (no streaming/chunked writes); no styling beyond the temporal number formats xl_write_typed applies to DATE/TIME/TIMESTAMP columns. format="auto" is not accepted — sniffing reads a file's existing signature bytes, and a file being created has none.

Writing a sheet row by row

open_workbook's counterpart for writing one row at a time, instead of building a whole table first:

import datetime
from excelreader import open_writer

with open_writer("out.xlsx") as writer:
    writer.start_sheet("Data")
    writer.write_row(["name", "qty", "when"])
    writer.write_row(["widget", 7, datetime.date(2026, 1, 31)])
    writer.end_sheet()

write_row picks each cell's type from the Python value. For control over a column's type — or to write a typed blank — use the explicit methods: write_str, write_i64, write_f64, write_bool, write_date, write_time, write_timestamp, and write_null(ColumnType.I64). A None passed to write_row becomes a blank string cell.

To build a workbook without touching the filesystem, use open_writer_to_memory and read the result out with bytes():

from excelreader import open_writer_to_memory

with open_writer_to_memory("xlsx") as writer:
    writer.start_sheet("Data")
    writer.write_row(["name", "qty"])
    writer.end_sheet()
    payload = writer.bytes()

write_workbook_to_bytes() is the same idea for the columnar write_workbook() path.

Arrow

With pyarrow installed, to_arrow() runs the same read and hands the buffers to pyarrow zero-copy over the Arrow C Data Interface:

import pyarrow as pa

with open_workbook("sales.xlsb") as workbook:
    array = workbook.to_arrow(schema)

batch = pa.RecordBatch.from_struct_array(array)

pyarrow owns the buffers from that point on, so the result stays valid after the workbook is closed.

Streaming: RecordBatchReader, pandas, and polars

to_record_batch_reader() is to_arrow() a batch at a time, as a streaming pyarrow.RecordBatchReader — peak memory is one batch rather than one sheet:

with open_workbook("sales.xlsb") as workbook:
    reader = workbook.to_record_batch_reader(schema, batch_size=10_000)
    for batch in reader:
        ...

Same batch_size/one-chunked-read-at-a-time rules as iter_parse_typed() above, except to_record_batch_reader() raises immediately rather than on the first iteration.

iter_pandas()/iter_polars() build on it, yielding one DataFrame per batch (requires pyarrow, plus pandas or polars respectively):

with open_workbook("sales.xlsb") as workbook:
    for frame in workbook.iter_polars(schema, batch_size=10_000):
        ...

to_pandas()/to_polars() materialize the whole sheet as a single DataFrame and also take a batch_size, but the two spend it differently — do not blur them. to_polars() genuinely streams: polars consumes the reader batch by batch, so the whole sheet is never resident as Arrow buffers at once. to_pandas() does not bound peak memory the same way — RecordBatchReader.read_all() concatenates every batch before pandas ever sees them. What batch_size buys to_pandas() instead is the conversion: self_destruct=True frees each Arrow chunk as pandas consumes it, so the sheet is never held twice, once as Arrow and once as the DataFrame.

A faulted stream surfaces differently from every other native error in this library: pyarrow's RecordBatchReader reports a failed batch as a plain OSError carrying the native error message, not ExcelReaderError — the failure crosses the Arrow C Data Interface before this library's own exception wrapping ever runs.

From memory

from excelreader import open_bytes

with open_bytes(payload) as workbook:
    ...

Reader options

open_workbook()/open_bytes() take an optional OpenOptions for CSV dialect settings and reader resource limits. Every field defaults to None, meaning "use the library default", so you set only what you want to change.

from excelreader import OpenOptions, open_workbook

# A semicolon-delimited CSV, which the default comma dialect would read as one column per row.
with open_workbook("export.csv", format="csv", options=OpenOptions(csv_delimiter=ord(";"))) as workbook:
    for row in workbook.rows():
        ...

csv_delimiter and csv_quote are byte values, so pass ord(";") rather than ";".

The max_* fields are resource limits rather than tuning knobs: they bound what a malformed or hostile file can make the reader allocate, and exceeding one raises ExcelReaderError. Lower them when parsing untrusted uploads.

options = OpenOptions(
    max_total_decompressed_bytes=64 * 1024 * 1024,  # zip-bomb budget for XLSX/XLSB
    max_cell_bytes=1024 * 1024,
    max_zip_entries=1024,
)

prefetch_decompression=True overlaps inflating an XLSX/XLSB sheet with parsing it — worth it for single-file batch work, not for a server already reading many files in parallel. See the root README for the measured trade.

Encrypted workbooks

open_workbook()/open_bytes() take a password keyword to open a password-protected OOXML workbook (.xlsx/.xlsb/.xlsm):

from excelreader import PasswordIncorrectError, open_workbook

try:
    with open_workbook("protected.xlsx", password="hunter2") as workbook:
        ...
except PasswordIncorrectError:
    ...  # ask again

Omitting password for an encrypted file raises PasswordRequiredError; a wrong one raises PasswordIncorrectError — both subclass ExcelReaderError, so a caller that doesn't care about the distinction can just catch that. Any other native failure (an unsupported encryption scheme, a corrupt file) also raises ExcelReaderError but is not worth retrying.

An explicit format="xlsx"/format="xlsb" works for an encrypted file too, the same as leaving format unset — both routes decrypt correctly given the right password.

Writing an encrypted workbook is a second step: write the plaintext package with write_workbook (or any of the write_* helpers), then wrap it with encrypt_package, which produces an agile-encrypted (ECMA-376 4.4) CFB container — AES-256-CBC, SHA-512, 100,000 spin iterations, with a dataIntegrity HMAC:

from excelreader import encrypt_package, write_workbook

write_workbook("plain.xlsx", table, types)
encrypt_package("plain.xlsx", "secret.xlsx", "hunter2")

package_path is read twice (it is not disposed or removed), so it must already be a finished file. Encryption parameters are fixed at Excel's own defaults — there are no options — and only XLSX/XLSB packages can be encrypted, matching what open_workbook/open_bytes can decrypt.

Benchmarks

benchmarks/bench_read.py and benchmarks/bench_write.py over tests/ExcelReader.Benchmarks/Data/65K_Records_Data.xlsb (65,535 data rows, 14 columns), the same fixture the .NET, C++ and Rust suites use. Measured on Windows 10 (22H2), 16 logical CPUs @ 3.39 GHz, CPython 3.14.5, 10 runs each (medians shown; min is in the scripts' own output).

Reading

API Median What it produces
parse_typed() 54.1 ms typed columnar buffers, converted natively
to_arrow() 54.3 ms the same parse, handed to pyarrow zero-copy
to_polars() 56.1 ms typed columnar DataFrame, schema inferred
polars.read_excel() 137.7 ms typed columnar DataFrame, types inferred
read_all_columnar() 508.5 ms raw columnar cells, no per-cell Python objects
rows() 1,054.6 ms one Cell object per cell, streamed per row
read_all() 1,616.3 ms one Cell object per cell, all at once

Only the to_polars() / polars.read_excel() pair is a like-for-like comparison, and even that one is loose: both produce a typed columnar DataFrame with inferred types, but the inference rules are not identical. The rows above it produce different things and are listed to show what each API costs, not to rank them — read_all() is 30x slower than parse_typed() because it materializes 917,504 Python objects, which is the price of that shape, not a slow parser.

Writing

API Median Output
write_workbook() → xls 45.6 ms 17.7 MB
write_workbook() → csv 64.2 ms 8.2 MB
write_workbook() → xlsb 74.1 ms 5.2 MB
write_workbook() → xlsx 129.3 ms 5.1 MB
write_polars() → xlsx 507.5 ms 5.1 MB
write_pandas() → xlsx 514.6 ms 5.1 MB
polars.DataFrame.write_excel() 4,487.8 ms 5.6 MB
pandas.DataFrame.to_excel() 6,972.0 ms 5.5 MB

The two DataFrame comparisons are matched work — same DataFrame in, xlsx out both times: write_polars() is ~8.8x faster than polars' own write_excel(), and write_pandas() ~13.5x faster than to_excel(). Both of ours pay a conversion the raw path does not: the DataFrame goes through Arrow and then a Python list before reaching the native columns, which is most of the gap between the 507 ms row and the 129 ms one. Handing write_workbook() buffers that are already columnar — what parse_typed() returns — skips all of it.

write_workbook(xlsx) at 129.3 ms lands within a couple of milliseconds of the C++ binding's xl::write_columns on the same 14 columns, which is the expected result: both are thin wrappers over the same xl_write_typed call, and neither adds work per cell.

xls being the fastest and largest is not a paradox — BIFF8 writes fixed-width records with no compression, so it trades 3.5x the bytes for less work per cell.

Notes

  • A Workbook is not thread-safe. Use one per thread.
  • Empty cells are skipped, so cell.column may skip indices. Do not assume row[i].column == i.
  • The ABI is documented in src/ExcelReader.Native/include/excelreader.h.

Release files for excelreader-native 5.0.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distributions (wheels)

Table of built distributions (wheels) for excelreader-native 5.0.1
File Interpreter ABI Platform
excelreader_native-5.0.1-py3-none-win_amd64.whl Python 3 none Windows x86-64 Details
excelreader_native-5.0.1-py3-none-manylinux_2_39_x86_64.whl Python 3 none Linux glibc 2.39+ x86-64 Details
excelreader_native-5.0.1-py3-none-macosx_26_0_arm64.whl Python 3 none macOS 26.0+ ARM64 Details

Total release size: 10.0 MB

Release files / excelreader_native-5.0.1-py3-none-win_amd64.whl

Download URL excelreader_native-5.0.1-py3-none-win_amd64.whl
Size 3.1 MB
Tags Python 3 Windows x86-64
SHA-256 checksum
How to use checksums
721633c092a4617efaca2f2d11f7cdaccc325814843c92bf943bfa289d102aaf
BLAKE2b-256 checksum
How to use checksums
c15048c2ae798b3a4aa6c08d44b796cae40472c3a3df9946ab82af85e569d776
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / excelreader_native-5.0.1-py3-none-manylinux_2_39_x86_64.whl

Download URL excelreader_native-5.0.1-py3-none-manylinux_2_39_x86_64.whl
Size 3.8 MB
Tags Linux glibc 2.39+ x86-64 Python 3
SHA-256 checksum
How to use checksums
708a5e495ed87fdbc60a60f2d3eadb37e8f47c7403da79713af6492eb17d0728
BLAKE2b-256 checksum
How to use checksums
9f5ad6eb7970dfe0b92806d2064ca448de8a328177c8160ce66f82dabc12be5e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / excelreader_native-5.0.1-py3-none-macosx_26_0_arm64.whl

Download URL excelreader_native-5.0.1-py3-none-macosx_26_0_arm64.whl
Size 3.2 MB
Tags Python 3 macOS 26.0+ ARM64
SHA-256 checksum
How to use checksums
4a35054f19db9406e2148631def695768f1d4390641e28cf5b43b93c80768fd5
BLAKE2b-256 checksum
How to use checksums
bfe4005b1c59c02bc5e9893014a91a06464654cfafad0e569ff9b44b186d1a53
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release history Release notifications | RSS feed

5.0.2

3 release files

This release

5.0.1 This release

3 release files

4.1.0

3 release files

4.0.0

3 release files

3.1.0

3 release files

3.0.2

3 release files

3.0.1

3 release files

3.0.0

3 release files

2.3.0

3 release files

2.2.0

3 release files

2.1.3

3 release files

2.1.2

3 release files

2.1.1

3 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page