Fix NoneType Errors When Reading Cells with openpyxl
A script that reads cells with openpyxl runs fine on one workbook and fails on the next with one of these:
TypeError: unsupported operand type(s) for +=: 'int' and 'NoneType'
AttributeError: 'NoneType' object has no attribute 'strip'
TypeError: '<' not supported between instances of 'NoneType' and 'float'
All three mean the same thing: a cell's .value was None, and the code assumed it would be a number or a string. openpyxl returns None for four different reasons, and the right fix depends on which one it is. This page shows how to find the cell, tells the four cases apart and fixes each. It is part of Troubleshooting Common Python Excel Errors.
Prerequisites
pip install openpyxl
sales.xlsx is a small sheet of quarterly sales per rep, written by a Python script: a header row, five data rows, a Total column of formulas, a merged region cell for North, one rep missing a Q2 figure, one row with no rep name, and some shaded but empty rows below the data — the kind of sheet people hand over.
Step 1 — Find the cell that is None
The traceback names the line, not the cell. Before changing anything, print which cells are None in the range the code reads; the pattern usually identifies the cause at once:
from openpyxl import load_workbook
ws = load_workbook("sales.xlsx")["Sales"]
print("max_row:", ws.max_row)
for row in ws.iter_rows(min_row=2):
empty = [c.coordinate for c in row if c.value is None]
if empty:
print(row[0].row, empty, type(row[0]).__name__)
The pattern is the diagnosis. Four different pictures appear here: row 3 has a None in column A (a merged cell) and D (a missing figure); row 4 has no rep in B; and rows 7 to 20 are entirely empty yet counted, because rows 8 to 20 carry a fill and that extends the sheet's used range.
Step 2 — Empty cells: decide what they mean
A blank Q2 figure could mean zero, "not reported yet" or "not applicable". The code should say which, in one place, rather than crashing or silently treating blanks as zero:
def number(value, default=None):
"""Return value as a float, or default for blank cells; raise for text that is not a number."""
if value is None or (isinstance(value, str) and not value.strip()):
return default
return float(value)
def text(value, default=""):
return default if value is None else str(value).strip()
for row in ws.iter_rows(min_row=2, max_row=6, values_only=True):
region, rep, q1, q2, _ = row
print(f"{text(rep, '(no rep)'):9} Q1={number(q1)} Q2={number(q2, default=0.0)}")
Treating missing Q2 as zero is a business decision; for an average it would be wrong, since a zero pulls the mean down. Keep the default explicit at each call site, so a reviewer can see what each blank turns into. Converting text with float() still raises for a cell like "n/a", which is what you want: a surprising value should stop the script rather than become zero.
Step 3 — Formulas that read as None
The Total column holds formulas. Loaded normally, openpyxl returns the formula text; loaded with data_only=True, it returns the cached result, the value the spreadsheet application stored when it last calculated the file. A file written by openpyxl or xlsxwriter has never been calculated, so there is no cached result, and every formula cell reads as None:
print("formulas:", [c.value for c in load_workbook("sales.xlsx")["Sales"]["E"][1:4]])
print("data_only:", [c.value for c in load_workbook("sales.xlsx", data_only=True)["Sales"]["E"][1:4]])
Open the file in Excel and save it, or recalculate it headlessly with LibreOffice, and the cached values appear:
soffice -env:UserInstallation=file://$PWD/lo_profile --headless --convert-to xlsx --outdir recalculated sales.xlsx > /dev/null 2>&1
ls recalculated
calc = load_workbook("recalculated/sales.xlsx", data_only=True)["Sales"]
print("after recalculation:", [c.value for c in calc["E"][1:6]])
Row 3 now shows 980: LibreOffice treats the blank Q2 as zero in =C3+D3. Often the simplest fix is not to read formula results at all: compute the total in Python from the inputs, which you have anyway. Read Formula Results with openpyxl data_only and Recalculate Excel Formulas Without Excel in Python cover both routes.
Step 4 — Merged cells
A2:A3 is merged. The value lives in A2; A3 is a MergedCell placeholder whose value is None. Code that groups rows by region then puts row 3 in a None region. Fill the merged value into every cell of its range when reading:
def merged_value(ws, cell):
for rng in ws.merged_cells.ranges:
if cell.coordinate in rng:
return ws.cell(row=rng.min_row, column=rng.min_col).value
return cell.value
print([merged_value(ws, ws.cell(row=r, column=1)) for r in range(2, 7)])
For a whole sheet, build a lookup of placeholder coordinates to their range's value once, rather than scanning the ranges per cell. Unmerge Cells and Fill the Values with openpyxl does it for a whole sheet.
Step 5 — Formatted empty rows at the end
max_row is the last row with anything in it, including formatting. Shaded rows 8 to 20 have no values, but loops that run to max_row see thirteen rows of None. Stop at the last row with a value in a key column, or skip rows that are entirely empty:
data = [row for row in ws.iter_rows(min_row=2, values_only=True) if any(v is not None for v in row)]
print(len(data), "data rows out of", ws.max_row - 1)
Fix openpyxl max_row Counting Empty Rows goes further, including finding the true last row per column.
Read-only mode: EmptyCell
Large files are often opened with read_only=True for speed. The None values are the same, but two things change. Blank cells come back as EmptyCell objects, which have a value of None but no coordinate, so the diagnostic loop in Step 1 fails on them. And a read-only worksheet does not load merged ranges at all:
ro = load_workbook("sales.xlsx", read_only=True)
rows = ro["Sales"].iter_rows(min_row=3, max_row=3)
print([type(c).__name__ for c in next(rows)])
print("merged ranges available:", hasattr(ro["Sales"], "merged_cells"))
ro.close()
In read-only mode, work with values_only=True and positions instead of cell objects, and if the sheet has merged cells that matter, read it once in normal mode to collect the ranges, or fill the merged values forward with pandas after loading. Close read-only workbooks explicitly; they keep the file open until you do.
Putting it together
A reader that handles all four cases, and reports what it defaulted, so nothing is silent:
records, notes = [], []
for row in ws.iter_rows(min_row=2):
values = [merged_value(ws, c) for c in row]
if all(v is None for v in values):
continue
region, rep, q1, q2, _ = values
if q2 is None:
notes.append(f"row {row[0].row}: Q2 blank, treated as 0")
records.append({"region": text(region), "rep": text(rep, "(no rep)"),
"total": number(q1, 0.0) + number(q2, 0.0)})
print(records)
print(notes)
Common pitfalls
- Wrapping everything in
or 0.value or 0also turns a legitimate0or empty string into the default, and hides missing data; testis Noneexplicitly. data_only=Trueon Python-written files. There are no cached results; recalculate first or compute in Python.- Saving a workbook loaded with
data_only=True. The formulas are replaced by their cached values, or lost where there were none. Never save over the original from a data-only load. - Merged ranges. Only the top-left cell has a value.
- Looping to
max_row. Formatting extends it; skip empty rows.
Conclusion
When openpyxl returns None, first print which cells are empty to see the pattern. Handle genuinely blank cells with explicit defaults in small helper functions; for formula cells read with data_only=True, recalculate the file first or compute the value in Python; read merged ranges from their top-left cell; and skip rows that are entirely empty instead of trusting max_row.
Frequently asked questions
Why does openpyxl return None for a cell that has a formula? With data_only=True openpyxl returns the cached result saved by the last application that calculated the file. Files written by Python have none, so the value is None. Open and save the file in Excel or recalculate it with LibreOffice first.
How do I treat empty cells as zero in openpyxl? Check for None explicitly, for example 0 if cell.value is None else cell.value. Avoid value or 0, which also replaces legitimate zeros and empty strings.
Why are some cells in a merged range None? A merged range stores its value only in the top-left cell; the others are MergedCell placeholders. Look up the range containing the cell and read its top-left cell.
Why does my loop read empty rows at the end of the sheet? ws.max_row includes rows that only have formatting. Skip rows whose values are all None, or stop at the last row with a value in a key column.
Related
- Up one level: Troubleshooting Common Python Excel Errors — the other common errors.
- Fix openpyxl max_row Counting Empty Rows — the trailing-rows case in depth.
- Fix "MergedCell object attribute value is read-only" in openpyxl — the writing side of merged cells.
- Read Formula Results with openpyxl data_only — data_only in detail.
- Read Excel Rows into Dictionaries with openpyxl — a reader built on these helpers.
More on errors & fixes
- FixHandle Permission Denied When Writing Excel in Python
- How-toWhat openpyxl Loses When You Save an Existing Workbook
- FixFix Excel Serial Numbers Showing Instead of Dates
- How-toHandle Daylight Saving Time in Excel Timestamps
- How-toHandle Microsoft Graph Throttling in Excel Automation
- GuideError Handling and Logging in Excel Automation