Guide
Freeze the Header Row in Excel with openpyxl
Freeze the header row in Excel from Python with openpyxl using ws.freeze_panes. Freeze rows, columns, or both, understand the anchor cell, and unfreeze — runnable.
When a report is taller than the screen, the column headers scroll out of view and every number loses its label. Freezing the header row pins it in place so it stays visible as you scroll. With openpyxl it is a single attribute — ws.freeze_panes — but the anchor-cell rule trips people up. This guide, part of Styling Excel Cells with openpyxl, covers freezing rows, columns, both, and removing the freeze. Every block runs in order against a sample workbook built first.
Install and create a sample workbook
pip install openpyxl
Build a sheet with enough rows that scrolling matters:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Ledger"
ws.append(["Date", "Account", "Description", "Amount"])
for i in range(1, 41):
ws.append([f"2024-08-{i:02d}", f"ACC-{i:03d}", f"Entry {i}", i * 100])
wb.save("frozen_report.xlsx")
print("Sample workbook created with", ws.max_row, "rows")
Freeze the header row
Set ws.freeze_panes to the cell below the rows you want frozen and right of the columns you want frozen. To freeze just row 1, anchor at "A2" — everything above row 2 (that is, row 1) stays pinned:
from openpyxl import load_workbook
wb = load_workbook("frozen_report.xlsx")
ws = wb["Ledger"]
ws.freeze_panes = "A2" # row 1 stays visible while scrolling
wb.save("frozen_report.xlsx")
print("Header row frozen")
How the anchor cell works
The anchor is the top-left cell of the unfrozen (scrolling) region. Everything above it freezes, and everything to its left freezes. Read it as "freeze up to here":
| Anchor | Frozen | Use case |
|---|---|---|
"A2" | Row 1 | Pin the header row |
"B1" | Column A | Pin a label column |
"B2" | Row 1 and column A | Pin both at once |
"C2" | Row 1 and columns A–B | Pin a header plus two label columns |
None | Nothing | Remove all freezing |
So the digit in the anchor is one greater than the last frozen row, and the letter is one greater than the last frozen column.
Freeze the first column
To keep a label column visible while scrolling sideways, anchor in column B of row 1:
from openpyxl import load_workbook
wb = load_workbook("frozen_report.xlsx")
ws = wb["Ledger"]
ws.freeze_panes = "B1" # column A stays visible while scrolling right
wb.save("frozen_report.xlsx")
print("First column frozen")
Freeze both the header row and the first column
Anchoring at "B2" freezes row 1 and column A together — the most common layout for a wide, tall report where you scroll in both directions:
from openpyxl import load_workbook
wb = load_workbook("frozen_report.xlsx")
ws = wb["Ledger"]
ws.freeze_panes = "B2" # row 1 and column A both pinned
wb.save("frozen_report.xlsx")
print("Header row and first column frozen")
Remove the freeze
Setting ws.freeze_panes = None clears any frozen panes so the whole sheet scrolls freely again:
from openpyxl import load_workbook
wb = load_workbook("frozen_report.xlsx")
ws = wb["Ledger"]
ws.freeze_panes = None # unfreeze everything
wb.save("frozen_report.xlsx")
print("Freeze removed")
Freeze panes on every sheet
freeze_panes is a per-worksheet attribute, so a multi-sheet workbook needs it set on each sheet you want pinned. Loop over wb.worksheets:
from openpyxl import load_workbook
wb = load_workbook("frozen_report.xlsx")
for sheet in wb.worksheets:
sheet.freeze_panes = "A2" # header row on every sheet
wb.save("frozen_report.xlsx")
print("Froze the header on", len(wb.worksheets), "sheet(s)")
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Header still scrolls away | Anchored at "A1" | "A1" freezes nothing; use "A2" to pin row 1 |
| Two header rows wanted, only one frozen | Off-by-one anchor | Anchor below the last header row: "A3" freezes rows 1–2 |
| Freeze missing on some tabs | freeze_panes is per worksheet | Set it on every sheet, e.g. loop wb.worksheets |
| Froze the wrong column | Letter is one past the last frozen column | "B1" freezes column A; "C1" freezes A–B |
| Freeze ignored after a pandas write | pandas overwrote the sheet | Apply freeze_panes after the pandas export, reopening with openpyxl |
A note on scale
Freezing is metadata on the worksheet, not per-cell data, so it costs nothing as the sheet grows — a 1,000,000-row export freezes its header just as cheaply as a 10-row one. There is no performance reason to skip it on large reports.
Frequently asked questions
Which cell do I anchor to freeze the header row?"A2". The anchor is the first scrolling cell, so everything above it — row 1 — stays frozen. "A1" freezes nothing.
How do I freeze the top two rows?
Anchor at "A3". The row digit is always one greater than the last row you want frozen, so "A3" pins rows 1 and 2.
Can I freeze a row and a column at the same time?
Yes. Anchor at the intersection of the first scrolling row and column. "B2" freezes row 1 and column A together.
Why is the freeze only on one sheet?freeze_panes is set per worksheet. Iterate wb.worksheets and assign it to each sheet that needs a pinned header.
How do I remove a freeze?
Set ws.freeze_panes = None and save. That clears all frozen panes and the sheet scrolls normally again.
Conclusion
Freezing panes in openpyxl is one line: ws.freeze_panes set to the top-left cell of the scrolling region. "A2" pins the header row, "B1" pins the first column, "B2" pins both, and None clears it. Remember the anchor is one row below and one column right of what you want frozen, and that the attribute lives on each worksheet — so loop your sheets when a workbook has several.
Where to go next
- Styling Excel Cells with openpyxl — the parent cluster on fonts, fills, borders, and alignment.
- Set Column Width and Row Height in openpyxl — size the columns whose headers you just froze.
- Using openpyxl for Excel File Manipulation — the workbook fundamentals behind
freeze_panes.