Guide

Formatting And Charting Excel Reports With PythonDeep dive

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

Bash
pip install openpyxl

Build a sheet with enough rows that scrolling matters:

Python
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:

Python
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":

AnchorFrozenUse case
"A2"Row 1Pin the header row
"B1"Column APin a label column
"B2"Row 1 and column APin both at once
"C2"Row 1 and columns A–BPin a header plus two label columns
NoneNothingRemove 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:

Python
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:

Python
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:

Python
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:

Python
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

SymptomCauseFix
Header still scrolls awayAnchored at "A1""A1" freezes nothing; use "A2" to pin row 1
Two header rows wanted, only one frozenOff-by-one anchorAnchor below the last header row: "A3" freezes rows 1–2
Freeze missing on some tabsfreeze_panes is per worksheetSet it on every sheet, e.g. loop wb.worksheets
Froze the wrong columnLetter is one past the last frozen column"B1" freezes column A; "C1" freezes A–B
Freeze ignored after a pandas writepandas overwrote the sheetApply 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