pyhandlexl
Make an Excel file the database for your next project.
A spreadsheet is the most portable data store there is: every machine opens it,
anyone can read or edit it without knowing a query language, it versions as a
single file, and there is no server to run. pyhandlexl makes driving one from
Python dependable — read and write raw cell values in a known order, with writes
that leave the file intact even when things go wrong.
Built on openpyxl and built to grow.
Table— the main way in. Row 1 holds your column headers, column A holds your row labels, and everything fromB2on is data. Read it, edit it by name, write it back.read_sheet/write_sheet— direct grid access for sheets that aren't a labelled table.
Version 0.2.0. Usable today and under active development — expect new capabilities with each release, and some API changes as it matures.
Install
pip install pyhandlexl
Requires Python 3.10+.
Quickstart
Given budget.xlsx:
| q1 | q2 | |
|---|---|---|
| Alice | 10 | 20 |
| Bob | 30 | 40 |
from pyhandlexl import Table
t = Table.read("budget.xlsx")
t.read_cell(row="Alice", column="q2") # '20'
t.read_row("Bob") # ['30', '40']
t.read_column("q1") # ['10', '30']
t.set_cell(row="Alice", column="q1", value=99) # edit in place
t.add_row("Carol", [1, 2])
t.write("budget.xlsx") # one safe, atomic write
Values are always strings.
read_sheetandTablecoerce every cell tostr(empty cells become""). Convert to numbers yourself where you need to.
The Table class
Reading
Table.read(path, sheet=None, *, column_headers=True, row_labels=True)
sheet=Nonereads the active sheet; pass a name for a specific one.column_headers=False— row 1 is ordinary data,column_headersis empty.row_labels=False— column A is ordinary data,row_labelsis empty.
Row labels and column headers are always str — required if you build a
Table by hand, too (Table(..., column_headers=[1, 2]) raises TypeError).
The whole table at once
t.corner # value of cell A1 (settable: t.corner = "name")
t.data # a TableData snapshot
d = t.data
d.rows # [['10', '20'], ['30', '40']] (B2 onward, by row)
d.columns # [['10', '30'], ['20', '40']] (same data, by column)
d.row_labels # ['Alice', 'Bob'] (column A, from A2)
d.column_headers # ['q1', 'q2'] (row 1, from B1)
d.corner # value of cell A1
Every field is a fresh copy — mutating t.data.rows does not change the table.
Access by label
t.read_row("Bob") # a data row (no label)
t.read_column("q1") # a data column (no header)
Unknown labels raise KeyError. If a label appears twice, the first match wins.
read_cell / set_cell — a single value, by position or by label
Both take the same addressing: a ref like "B2", or row=/column= as a
matching pair — both ints for a 1-based Excel position (row 1 is the
header row, column 1 is the label column), or both strings for a row
label / column header pair. Mixing types raises TypeError.
t.read_cell("B2") # '10' — first data cell, by position
t.read_cell(row=2, column=2) # '10' — same thing, spelled out
t.read_cell(row="Alice", column="q1") # '10' — same value, by label
t.read_cell(row=1, column=2) # 'q1' — a column header
t.read_cell(row=2, column=1) # 'Alice' — a row label
t.read_cell("A1") # the corner
By position, read_cell can reach any cell — header, label, corner, or
data. By label it always reads data, wherever that row/column intersection
actually lives.
A string given through row=/column= is always a label lookup — it does
not accept a column letter like "B" for a position. Use a plain number
(column=2) or ref="B2" for letter-based positions.
Editing (in place, returns None)
t.set_cell("B2", value=99) # by position — ref
t.set_cell(row=2, column=2, value=99) # by position — row=/column= as ints
t.set_cell(row="Alice", column="q1", value=99) # by label — row=/column= as strings
t.set_row("Bob", [50, 60]) # replace a row (length must match)
t.set_column("q1", [1, 2]) # replace a column (length must match)
t.add_row("Carol", [1, 2]) # append a labelled row
t.add_column("q3", [5, 6]) # append a labelled column
t.drop_row("Bob")
t.drop_column("q2")
t.rename_row("Alice", "ALICE")
t.rename_column("q1", "Q1")
t.corner = "name"
set_cell only ever touches data — addressing a header, row label, or the
corner by position raises ValueError; use rename_row, rename_column, or
t.corner = value for those. Wrong-length values raise ValueError; unknown
labels raise KeyError; a non-str row label or column header (in add_row,
add_column, rename_row, rename_column) raises TypeError.
You can also build a table from nothing:
t = Table([], column_headers=["q1", "q2"])
t.add_row("Alice", [10, 20])
t.write("new.xlsx")
Writing
t.write(path, sheet=None)
Reassembles headers into row 1 and labels into column A, then writes the whole sheet. Other sheets in the file are left untouched.
Equality
t1 == t2 # compares data, headers, labels, corner
Row count and row-label membership go through t.data instead of len()/in,
so the call site says what's being checked: len(t.data.rows),
"Bob" in t.data.row_labels.
The raw layer
For sheets that are not a labelled table — plain grids, exports, odd layouts.
from pyhandlexl import read_sheet, write_sheet, append_rows
read_sheet(path, sheet=None, *, pad=False)
Returns list[list[str]]. Trailing empty cells are trimmed from each row (a
fully empty row becomes []); pad=True right-pads every row to the widest
row's length instead.
write_sheet(path, rows, sheet=None, *, orientation="rows")
Replaces the target sheet with rows (other sheets untouched), creating the
file and sheet if needed. Values are written as-is — str stays str, int
stays int, None leaves the cell empty; there is no string-to-number
conversion. orientation="columns" writes each inner list down a column
instead of across a row.
append_rows(path, rows, sheet=None)
Appends after the last row. Empty input is a no-op.
Sheet management
from pyhandlexl import (
list_sheets, sheet_exists, create_sheet, delete_sheet, rename_sheet,
)
list_sheets(path) # ['Sheet1', 'Data']
sheet_exists(path, "Data") # True
create_sheet(path, "Results") # ValueError if it already exists
delete_sheet(path, "Old") # refuses to delete the last sheet
rename_sheet(path, "Old", "New")
Sheet names are validated everywhere: max 31 characters, none of \ / ? * [ ] :,
and "History" is reserved by Excel.
Safe writes
Every write goes through the same steps:
- Save to a temporary file in the same directory.
- Verify it is a readable
.xlsx. - Atomically replace the original (
os.replace).
If any step fails the temporary file is removed and the original is left exactly
as it was. If the target is locked (open in Excel), writes retry briefly before
raising FileLockedError.
Errors
All raised exceptions derive from PyhandlexlError:
| Exception | Also a | Meaning |
|---|---|---|
SheetNameError |
ValueError |
invalid worksheet name |
DimensionError |
ValueError |
data exceeds Excel's 1,048,576 × 16,384 grid |
SheetNotFoundError |
KeyError |
no worksheet with that name |
FileLockedError |
OSError |
file stayed locked through every retry |
InvalidFileError |
— | file is missing or not a readable .xlsx |
Not in scope
pyhandlexl deliberately does not handle: cell formatting, styles, fonts,
formulas, charts, images, merged cells, .xls (old format), or password
protection / encryption. For any of that, use openpyxl directly.
Development
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
pip install -e ".[dev]"
pytest
ruff check . && ruff format --check .
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyhandlexl-0.2.1.tar.gz.
File metadata
- Download URL: pyhandlexl-0.2.1.tar.gz
- Upload date:
- Size: 23.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
43f0cd3f910ac7065d22c8bf9382050bbc31cc07bdf003e040bd459a6591e35c
|
|
| MD5 |
277d39f6fec851d71a115ac7a3726c58
|
|
| BLAKE2b-256 |
63951a21b518629d6b608b3421c4b948d712952208480a541f17be75abb8b4fa
|
Provenance
The following attestation bundles were made for pyhandlexl-0.2.1.tar.gz:
Publisher:
publish.yml on LewyAmendi/pyhandlexl
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyhandlexl-0.2.1.tar.gz -
Subject digest:
43f0cd3f910ac7065d22c8bf9382050bbc31cc07bdf003e040bd459a6591e35c - Sigstore transparency entry: 2726758051
- Sigstore integration time:
-
Permalink:
LewyAmendi/pyhandlexl@2ed2bb06001d17efd65f80081b337acd7247cb1a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/LewyAmendi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2ed2bb06001d17efd65f80081b337acd7247cb1a -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file pyhandlexl-0.2.1-py3-none-any.whl.
File metadata
- Download URL: pyhandlexl-0.2.1-py3-none-any.whl
- Upload date:
- Size: 16.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d985036f2a766c25601483a00c05ca27bcfaa36d8b6e6431753b7e41900d6afc
|
|
| MD5 |
eae497c5e568166d5cbc77dc194053f9
|
|
| BLAKE2b-256 |
7392b76a8413cc21efbeac95ddfe9938aa49411718d2c634ac43aae4c4dc91c8
|
Provenance
The following attestation bundles were made for pyhandlexl-0.2.1-py3-none-any.whl:
Publisher:
publish.yml on LewyAmendi/pyhandlexl
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyhandlexl-0.2.1-py3-none-any.whl -
Subject digest:
d985036f2a766c25601483a00c05ca27bcfaa36d8b6e6431753b7e41900d6afc - Sigstore transparency entry: 2726758200
- Sigstore integration time:
-
Permalink:
LewyAmendi/pyhandlexl@2ed2bb06001d17efd65f80081b337acd7247cb1a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/LewyAmendi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2ed2bb06001d17efd65f80081b337acd7247cb1a -
Trigger Event:
workflow_dispatch
-
Statement type: