xlsx-parsing
Excel workbooks are the lingua franca of operational documents that nobody bothered to put in a database — playbooks, rate cards, deviation policies, finance models, SLAs. They show up in tasks with three properties that trip up naive readers:
- Multiple sheets, only one of which is the data you actually want.
- Sparse cells — a row that uses a column may sit next to a row that doesn't, leaving
Nonecells. Empty is meaningful (the rule does not apply), not an error. - Composite cells — a single cell that contains a comma-separated list, a JSON blob, or a sentence rather than an atomic value.
Treat the workbook as a typed table with declared columns, not a free-form spreadsheet. Read every sheet you need, normalise it to list[dict[str, Any]], then operate on that.
When to Use
- You need to ingest data from a Microsoft Excel
.xlsxfile. - The workbook contains multiple sheets, sparse rows, merged cells, or cells with embedded lists/JSON.
- You prefer a pure-Python solution (
openpyxl>= 3.1) or already havepandas>= 2.2 available for richer data manipulation.
Do NOT use this skill when:
- The file is
.csv— usecsv.DictReaderdirectly. - The file is
.jsonor.jsonl— usejson.loads. - The file is
.xls(legacy binary) —openpyxlwill refuse; usexlrd<2or convert to.xlsxfirst. - Do not mix
openpyxlandpandasobjects in the same processing pipeline; this can cause subtle type-conversion bugs. - Avoid loading workbooks with
read_only=Falseon large files; it can exhaust memory and expose you to DoS-style attacks if untrusted files are processed.
Prerequisites
Install the required library. On a Windows PowerShell host:
pip install "openpyxl>=3.1"
# OR, if you prefer pandas:
pip install "pandas>=2.2" "openpyxl>=3.1"
Verify installation:
python -c "import openpyxl; print(openpyxl.__version__)"
Procedure
Step 1 — Load the workbook with safe flags
Always use data_only=True, read_only=True, and keep_links=False. These three flags are mandatory for security and performance:
from pathlib import Path
import openpyxl
from typing import Any, List, Dict
def load_sheet_as_records(path: Path, sheet_name: str) -> List[Dict[str, Any]]:
wb = openpyxl.load_workbook(path, data_only=True, read_only=True, keep_links=False)
ws = wb[sheet_name]
rows = ws.iter_rows(values_only=True)
header = [str(c).strip() if c else "" for c in next(rows)]
return [
dict(zip(header, row))
for row in rows
if any(cell is not None for cell in row)
]
# Example usage
records = load_sheet_as_records(Path("workbook.xlsx"), "Rules")
Why these flags:
data_only=Truereturns the cached value of formula cells instead of the formula expression.read_only=Trueis faster on big workbooks and avoids loading styles you don't need.keep_links=Falsedrops external link metadata, mitigating a class of XML-entity attacks.- The
any(cell is not None ...)filter drops entirely-blank rows that Excel preserves at the bottom of a sheet. dict(zip(header, row))handles trailing empty columns gracefully when a row is shorter than the header.
Step 2 — (Alternative) Load with pandas
If pandas is installed and you need grouping, joins, or numeric aggregation:
import pandas as pd
from pathlib import Path
from typing import Any, List, Dict
def load_excel_sheets(path: Path) -> Dict[str, List[Dict[str, Any]]]:
sheets = pd.read_excel(
path,
sheet_name=None,
dtype=object,
engine="openpyxl", # explicit engine for security
keep_default_na=False, # treat empty strings as empty, not NaN
)
result: Dict[str, List[Dict[str, Any]]] = {}
for name, df in sheets.items():
df = df.dropna(how="all")
records: List[Dict[str, Any]] = (
df.where(df.notna(), None)
.to_dict(orient="records")
)
result[name] = records
return result
# Example usage
all_records = load_excel_sheets(Path("workbook.xlsx"))
rules = all_records["Rules"]
HARD RULE: Do not mix openpyxl and pandas objects in the same module. Pick one library per processing pipeline.
Step 3 — Handle empty cells safely
Compare against is None or call .strip() rather than truthiness — 0 and False are valid values that fail truthy tests:
def get(rec: dict, key: str, default: Any = None) -> Any:
"""Return a safe value for possibly-empty cells."""
val = rec.get(key)
return default if val is None or (isinstance(val, str) and not val.strip()) else val
Step 4 — Parse composite cells
A single cell may contain a comma-separated list, a JSON blob, or a single token. Try the simple split first, then fall back to JSON:
import json
from typing import List
def split_list_cell(value: Any) -> List[str]:
"""Split a comma-separated list cell, falling back to JSON if appropriate."""
if value is None:
return []
text = str(value).strip()
if "," in text:
return [item.strip() for item in text.split(",") if item.strip()]
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return [str(item).strip() for item in parsed]
except json.JSONDecodeError:
pass
return [text] # single token
If you see a cell with curly-brace text, it is probably an embedded JSON document; parse with json.loads.
Step 5 — Propagate merged cells
Merged cells in Excel only store the value in the top-left cell; the rest are None. Fill down:
def propagate_merged_column(records: List[dict], column: str) -> None:
"""Fill down values for a column that was merged in Excel."""
last = None
for row in records:
if row.get(column) is None:
row[column] = last
else:
last = row[column]
To check whether a cell is in a merged range, inspect ws.merged_cells.ranges.
Step 6 — Read all needed sheets before processing
Use the metadata sheet (often named Metadata, Info, or README) for workbook-level fields, and the data sheet(s) for per-record rows. Read all sheets you need before processing — do not assume the schema of one sheet is described inside another sheet you have not opened.
Step 7 — Full example: configuration-style workbook
from pathlib import Path
import openpyxl
from typing import Any, Dict, List
def load_sheet_as_records(wb: openpyxl.Workbook, sheet_name: str) -> List[Dict[str, Any]]:
ws = wb[sheet_name]
rows = ws.iter_rows(values_only=True)
header = [str(c).strip() if c else "" for c in next(rows)]
return [
dict(zip(header, row))
for row in rows
if any(cell is not None for cell in row)
]
path = Path("workbook.xlsx")
wb = openpyxl.load_workbook(path, data_only=True, read_only=True, keep_links=False)
metadata = {
row[0]: row[1]
for row in wb["Metadata"].iter_rows(min_row=2, values_only=True)
if row[0] is not None
}
definitions = load_sheet_as_records(wb, "Definitions")
rules = load_sheet_as_records(wb, "Rules")
propagate_merged_column(rules, "section")
After this, rules[0]["key"], rules[0]["rule_type"], etc. are plain Python values you can branch on. The rest of your code does not need to know the input was Excel.
Examples
# Print each rule's key and type
for rule in rules:
print(f"{rule['key']}: {rule['rule_type']}")
# Split a composite cell containing a state list
states = split_list_cell(rules[0]["states"])
print(states) # ['Delaware', 'New York', 'California']
# Safe access to a possibly-empty cell
threshold = get(rules[0], "threshold", default=0)
Pitfalls
- Truthiness on
0orFalse— These are valid cell values. Always compare withis Noneor.strip(), never bareif cell:. - Mixing
openpyxlandpandasin one pipeline — Causes subtle type-conversion bugs. Pick one per module. - Forgetting
keep_links=False— Exposes you to XML-entity attacks from untrusted workbooks. - Using
read_only=Falseon large files — Exhausts memory. Always useread_only=Trueunless you need to write. - Assuming sheet order — Sheet indices can change. Always reference sheets by name, not position.
- Merged cells producing
None— Only the top-left cell of a merge range holds the value. Callpropagate_merged_columnbefore processing. - Formula cells without
data_only=True— You get the formula string (e.g.,=A1+B1) instead of the computed value. .xlsfiles —openpyxlwill refuse them. Usexlrd<2or convert to.xlsxfirst.- Trailing blank rows — Excel preserves them. The
any(cell is not None ...)filter removes them, but only if you include it. - Header row not on row 1 — If headers start on a different row, adjust
min_rowiniter_rowsor usepandaswithheader=<row_index>.
Verification
Run these checks after implementing the parser:
# 1. Verify openpyxl is installed and importable
python -c "import openpyxl; print(f'openpyxl {openpyxl.__version__}')"
# 2. Run a quick parse test on a sample workbook
python -c "from pathlib import Path; import openpyxl; wb = openpyxl.load_workbook(Path('workbook.xlsx'), data_only=True, read_only=True, keep_links=False); print(wb.sheetnames)"
Checklist:
- Records are correctly parsed from a sample workbook — sheet names print as expected.
- Empty cells become
None(notNaN, not empty string unless explicitly kept). - Merged cells are propagated correctly — no
Nonevalues in columns that were merged. - Composite list cells are split as expected —
split_list_cellreturns a list, not a string. -
0andFalsevalues are preserved — not dropped by truthiness checks. - No
openpyxl/pandasmixing in the same module.
Related skills
- csv-parsing — for handling comma-separated value files.
- json-parsing — for reading JSON or JSONL inputs.
- xls-parsing — for legacy Excel
.xlsfiles (requiresxlrd).