Iterate over Rows and Columns with openpyxl
Sometimes pandas is the wrong tool. You need the cell's number format, or its fill colour, or you are writing values into a template and must not disturb anything else. That means walking the sheet yourself, and openpyxl gives you two methods — iter_rows and iter_cols — plus a handful of options that make the difference between a loop that finishes in a second and one that takes a minute. This guide covers the mechanics, the max_row trap that catches everyone, and the patterns worth reusing. It is part of Using openpyxl for Excel File Manipulation.
Prerequisites
pip install openpyxl
A sheet to walk:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Sales"
ws.append(["region", "branch", "units", "revenue"])
for i in range(1, 26):
ws.append([f"Region {i % 4}", f"Branch {i}", 100 + i, 12.5 * (100 + i)])
wb.save("sales.xlsx")
Step 1 — Iterate rows
iter_rows yields one tuple of Cell objects per row:
from openpyxl import load_workbook
wb = load_workbook("sales.xlsx")
ws = wb["Sales"]
for row in ws.iter_rows(min_row=2, max_row=6):
for cell in row:
print(cell.coordinate, cell.value)
When you only want the data, values_only=True yields plain tuples instead — no Cell objects are constructed at all:
for region, branch, units, revenue in ws.iter_rows(min_row=2, values_only=True):
print(f"{region:<10} {branch:<10} {units:>5} {revenue:>10.2f}")
That tuple unpacking is the pattern to reach for by default. It is faster, it uses far less memory, and it reads better than indexing into a tuple of cells.
Bound the range when you know it. Passing min_col and max_col avoids walking columns you will ignore:
# Only the units and revenue columns, rows 2 to 26.
for units, revenue in ws.iter_rows(min_row=2, max_row=26, min_col=3, max_col=4,
values_only=True):
print(units, revenue)
Two shorthands are worth knowing. Indexing with a range string yields the same row tuples, which reads nicely for a fixed block:
for row in ws["A2:D6"]:
print([cell.value for cell in row])
And ws.rows is an alias for the whole sheet with no options — convenient interactively, but it offers neither bounds nor values_only, so prefer iter_rows in real code.
Step 2 — Iterate columns
iter_cols is the transpose, yielding one tuple per column. It is the natural shape when you want to summarise a column or check its type:
for column in ws.iter_cols(min_row=2, min_col=3, max_col=4, values_only=True):
numbers = [v for v in column if isinstance(v, (int, float))]
print(f"n={len(numbers)} total={sum(numbers):,.2f} max={max(numbers):,.2f}")
One important limitation: iter_cols does not work in read-only mode. Read-only mode streams the file row by row, and producing a column would require holding the whole sheet. If you need column-wise access on a large file, iterate rows and transpose in memory, or read with pandas instead.
Step 3 — The max_row trap
ws.max_row reports the extent of the used range, not the number of populated rows. A cell that was formatted and then cleared, a stray space in row 40,000, or rows that were deleted without clearing their formatting all extend it — so a sheet with 25 data rows can report max_row of 1,048,576 and your loop runs for a very long time over nothing.
Stop on the data instead:
from openpyxl import load_workbook
def iter_data_rows(ws, key_col=1, min_row=2, stop_after_blanks=1):
"""Yield row tuples until the key column has been blank N times running."""
blanks = 0
for row in ws.iter_rows(min_row=min_row, values_only=True):
if row[key_col - 1] in (None, ""):
blanks += 1
if blanks >= stop_after_blanks:
return
continue
blanks = 0
yield row
wb = load_workbook("sales.xlsx", read_only=True)
ws = wb["Sales"]
rows = list(iter_data_rows(ws))
print(len(rows)) # 25, not 40,000
wb.close()
The stop_after_blanks parameter matters for sheets with a deliberate blank separator row between blocks — set it to 2 or 3 and a single gap does not end the read early.
Step 4 — Key rows by their header
Positional unpacking breaks the day somebody inserts a column. Build a dict per row instead, keyed by the header text:
from openpyxl import load_workbook
def read_records(path, sheet_name=None, header_row=1):
"""Yield each data row as a dict keyed by its column header."""
wb = load_workbook(path, read_only=True, data_only=True)
ws = wb[sheet_name] if sheet_name else wb.active
try:
rows = ws.iter_rows(min_row=header_row, values_only=True)
headers = [
str(h).strip() if h is not None else f"column_{i}"
for i, h in enumerate(next(rows))
]
for values in rows:
if all(v is None for v in values):
continue
yield dict(zip(headers, values))
finally:
wb.close()
for record in read_records("sales.xlsx"):
if record["units"] > 120:
print(record["branch"], record["revenue"])
data_only=True returns the cached result of a formula rather than the formula text, which is what a reading pass almost always wants — the distinction is covered in reading formula results with openpyxl data_only.
Step 5 — Write while you iterate
Iterating over Cell objects lets you write back in the same pass, which is the core of any template-filling or formatting job:
from openpyxl import load_workbook
from openpyxl.styles import Font, PatternFill
wb = load_workbook("sales.xlsx") # NOT read_only — we are writing
ws = wb["Sales"]
flag = PatternFill("solid", fgColor="FDEFD8")
bold = Font(bold=True)
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=3, max_col=4):
units, revenue = row
if units.value and units.value > 120:
units.fill = flag
revenue.fill = flag
revenue.font = bold
wb.save("sales_flagged.xlsx")
Note the constraint: writing needs a normal (not read-only) workbook, and modifying cells while iterating the same range is safe only because you are changing values and styles, not the sheet's shape. Inserting or deleting rows mid-iteration invalidates the iterator — do that in a separate pass, as described in inserting and deleting rows and columns with openpyxl.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Loop runs for minutes over an empty sheet | max_row reflects the used range | Break on a blank key column. |
AttributeError: 'tuple' object has no attribute 'value' | values_only=True yields values, not cells | Drop .value, or drop values_only. |
iter_cols raises in read-only mode | Column access needs the whole sheet | Iterate rows and transpose. |
Formulas come back as =SUM(...) strings | Workbook opened without data_only | Load with data_only=True. |
Values are None with data_only=True | No cached result — never opened in Excel | Compute in Python, or open and save once. |
| Loop is very slow on a big file | Cell objects built for every cell | read_only=True plus values_only=True. |
| Row unpacking breaks after a column is added | Positional access | Key rows by header name. |
| Blank rows appear in the output | Sheet has interior gaps | Skip rows where every value is None. |
Performance and scale notes
Two flags dominate iteration cost, and they compose:
import time
from openpyxl import load_workbook
for label, kwargs, values_only in [
("normal, cells", {}, False),
("normal, values", {}, True),
("read-only, values", {"read_only": True}, True),
]:
start = time.perf_counter()
wb = load_workbook("sales.xlsx", **kwargs)
total = sum(
r[2] for r in wb["Sales"].iter_rows(min_row=2, values_only=values_only)
if values_only and isinstance(r[2], (int, float))
) if values_only else 0
wb.close()
print(f"{label:<20} {time.perf_counter() - start:6.3f}s")
read_only=True streams the sheet instead of building the whole workbook in memory, and values_only=True skips constructing a Cell per cell. On a workbook of a few hundred thousand rows the pair is the difference between a job that fits in a container's memory limit and one that does not — the fuller treatment is in speeding up openpyxl with read-only mode.
Three further habits. Bound the columns, not just the rows — a sheet with sixty columns where you need four wastes most of its parse on the rest. Close read-only workbooks explicitly with wb.close(); they hold an open file handle that is not released by garbage collection alone, and a loop over hundreds of files will exhaust the descriptor limit. And do not iterate at all when pandas will do: for a plain read-and-aggregate, pd.read_excel followed by a vectorised operation beats any Python-level loop by a wide margin. Reach for iter_rows when you need what pandas cannot see — the styling, the formulas, the coordinates — or when you are writing into an existing sheet.
Conclusion
iter_rows and iter_cols are the two ways through a sheet, and the options matter more than the choice between them. Bound the range on both axes, pass values_only=True whenever you only need data, and open with read_only=True for anything large. Never trust max_row as a row count — it reports the used range, which formatting alone can extend by tens of thousands of rows — so break on a blank key column instead. And key your rows by header name rather than position, so the day a column is inserted upstream your loop keeps working.
Frequently asked questions
What does values_only=True actually change?
It yields plain tuples of cell values instead of Cell objects. That skips constructing one object per cell, which is markedly faster and lighter — use it whenever you only need the data and not the styling or coordinates.
Why does max_row report more rows than my data has?max_row is the extent of the used range, not the count of populated rows. Formatting, a stray space, or a deleted-but-not-cleared row extends it. Break on a blank key column rather than trusting the number.
Should I use ws.rows or iter_rows?iter_rows, because it accepts bounds and values_only. ws.rows is a convenience alias for the whole sheet with no options, so it materialises Cell objects for every cell whether you need them or not.
How do I iterate a specific range like B2 to D50?
Pass min_row, max_row, min_col and max_col to iter_rows, or index the sheet with a range string such as ws["B2:D50"]. Both yield tuples of cells row by row.
Why is my loop so slow on a large sheet?
You are almost certainly building Cell objects you do not need. Open the workbook with read_only=True and iterate with values_only=True; together they turn a whole-workbook parse into a streaming read.
Related
- Up to the parent: Using openpyxl for Excel File Manipulation — the wider openpyxl toolkit.
- Insert and Delete Rows and Columns with openpyxl — changing the sheet's shape, which iteration cannot do safely.
- Read a Cell Value from Excel with openpyxl — single-cell access before you reach for a loop.
- Speed up openpyxl with Read-Only Mode — the streaming mode that makes big iterations viable.
- Read Formula Results with openpyxl data_only — getting values rather than formula text as you iterate.