How-to
Formatting And Charting Excel Reports With PythonDeep dive

Freeze Panes and Set Zoom with XlsxWriter

Control how each sheet of an XlsxWriter report opens: freeze the header row and the first column, split panes instead of freezing, set the zoom per sheet, hide gridlines, choose which sheet and cell are active, scroll the view to a starting cell, colour the tabs, and check every view setting by reading the file back with openpyxl.

The first few seconds with a report decide whether people find it easy to use. If they open it to a sheet scrolled halfway down, at a zoom where the numbers are tiny, and the column headings disappear as soon as they scroll, they will struggle with it however good the figures are. All of that is controlled by a sheet's view settings, which are saved in the file: which panes are frozen, the zoom level, whether gridlines show, which sheet is active and which cell is selected. XlsxWriter sets each one with a single method call. This page goes through them on a three-sheet report — a summary, a wide detail table and a notes sheet — and reads the file back to confirm what Excel will show. It belongs to Building Excel Reports with XlsxWriter.

Prerequisites

Bash
pip install xlsxwriter pandas openpyxl

The view settings are written into each sheet's XML, so they work the same in Excel, LibreOffice and most online viewers. openpyxl is used only to inspect the result.

Step 1 — Freeze the header row and the first column

Python
import numpy as np
import pandas as pd

rng = np.random.default_rng(8)
months = pd.period_range("2025-10", periods=12, freq="M").strftime("%b %y")
detail = pd.DataFrame(rng.integers(20_000, 90_000, (40, 12)), columns=months)
detail.insert(0, "Product", [f"Product {i:02d}" for i in range(1, 41)])

xw = pd.ExcelWriter("sales_views.xlsx", engine="xlsxwriter")
wb = xw.book
summary = wb.add_worksheet("Summary")        # added first, so it is the first tab
detail.to_excel(xw, sheet_name="Detail", index=False, startrow=2)
ws = xw.sheets["Detail"]
ws.write("A1", "Monthly sales by product", wb.add_format({"bold": True, "font_size": 14}))
ws.freeze_panes(3, 1)                        # rows above row 4 and the column left of B stay put
ws.set_column("A:A", 14)
ws.set_column("B:M", 10, wb.add_format({"num_format": "#,##0"}))

freeze_panes(row, col) takes the zero-based position of the first cell that scrolls: everything above that row and left of that column stays on screen. Here the table starts at row 3 (zero-based 2) after a title and a blank line, so freeze_panes(3, 1) keeps the title, the blank row and the column headings fixed, and the product name column pinned on the left, while twelve months of numbers scroll freely beneath and beside them. The arguments also accept an A1 reference — freeze_panes("B4") means the same thing — which is easier to read when the position is fixed. The most common mistake is counting from one: freeze_panes(1, 0) freezes only the first row, which is right for a table whose headings are in row 1 and wrong for this one. A wide table needs both directions frozen; a long narrow one needs only the header row.

freeze_panes(3, 1) on the detail sheet The title row, the blank row and the heading row stay at the top, and the Product column stays at the left. The month columns and the product rows scroll underneath and beside them. A B C D E 1 Monthly sales by product 2 3 Product Oct 25 Nov 25 Dec 25 … 4 Product 01 48,212 71,034 36,950 scrolls → 5 Product 02 62,118 29,404 55,617 scrolls → 6 scrolls ↓

Step 2 — Zoom, gridlines and where the view starts

Python
ws.set_zoom(90)                              # fit more of a wide table on screen
ws.set_top_left_cell("A1")
ws.set_selection("B4")                       # the cursor starts on the first number

summary.write("A1", "Sales summary", wb.add_format({"bold": True, "font_size": 18}))
summary.write_row("A3", ["Total", "Best month", "Products"])
summary.write_row("A4", [int(detail[months].to_numpy().sum()), months[int(detail[months].sum().argmax())], len(detail)])
summary.set_zoom(130)                        # a sparse sheet reads better larger
summary.hide_gridlines(2)                    # 2 = hide on screen and in print
summary.set_tab_color("#1F3864")
summary.activate()                           # opens on this sheet

Zoom is set per sheet, between 10 and 400 percent. A wide detail table benefits from slightly less than 100% so a full year of columns fits on a normal screen; a summary sheet with a few large numbers reads better at 120–130%. hide_gridlines(2) removes the grid on screen and in print, which makes a summary look like a page rather than a spreadsheet; hide_gridlines(1) hides them in print only, which is XlsxWriter's default.

Three methods decide where the reader starts. activate() chooses the sheet that is open when the file is opened — by default it is the first. set_selection() puts the cursor on a cell, here the first number of the table rather than the title. set_top_left_cell() scrolls the view so a given cell is in the top-left corner, useful for a sheet whose interesting part is not at the top. Tabs appear in the order the sheets are created, which is why the summary sheet was added to the workbook before pandas wrote the detail table — activate() chooses the open sheet but does not move it. Tab colours help readers find the sheet they need in a workbook with many tabs, and work best when they carry meaning — one colour for summaries, another for detail.

Step 3 — Split panes, the alternative to freezing

Python
notes = wb.add_worksheet("Notes")
for r in range(60):
    notes.write(r, 0, f"Note {r + 1}: assumptions and definitions for the report")
notes.split_panes(15 * 10, 0)                # split 10 default rows down (height in points)
notes.set_tab_color("#808080")
xw.close()

Split panes divide the window into two or four independently scrollable areas, without locking anything in place. They suit a long sheet where the reader wants to compare two distant parts — the definitions at the top with a note sixty rows down. Unlike freeze_panes, split_panes takes the position of the split in points and character widths, not rows and columns, because the user can drag the split bar afterwards; 15 points is the default row height, so 150 points places the split about ten rows down. Freeze and split are alternatives: a sheet can have one or the other, not both.

Freeze panes or split panes Frozen panes lock the header rows and columns in place while the rest scrolls, which suits tables. Split panes give two or four independently scrolling views of the same sheet, which suits comparing distant parts of a long sheet. freeze_panes headings stay on screen position in rows and columns best for tables user cannot drag it split_panes two or four scrolling views position in points and widths best for comparing distant parts user can drag the split

Step 4 — Check the view settings

Python
from openpyxl import load_workbook

wb2 = load_workbook("sales_views.xlsx")
print("tab order:", wb2.sheetnames, "| active:", wb2.active.title)
for ws in wb2.worksheets:
    v = ws.sheet_view
    pane = v.pane
    print(f"{ws.title:8} zoom={v.zoomScale} gridlines={v.showGridLines} tab={ws.sheet_properties.tabColor.rgb if ws.sheet_properties.tabColor else None} "
          f"pane={(pane.state, pane.topLeftCell, pane.xSplit, pane.ySplit) if pane else None} selection={v.selection[-1].activeCell}")   # last entry = the active pane

Reading the file back shows every setting as Excel will apply it: the summary first in the tab order and active, at 130% with gridlines off and a navy tab; the detail sheet at 90% with a frozen pane whose top-left scrolling cell is B4; the notes sheet with a split pane measured in twips (twentieths of a point) and a grey tab. With frozen panes a sheet stores one selection per pane — the top-right, bottom-left and bottom-right areas — and the one that matters is the active, scrolling pane, which is why the code reads the last entry: B4, as set. Gridlines show as None where they were left at the default, which means visible, and the notes sheet's zoom is None for the default of 100%. A check like this is a cheap test for a report that people rely on, because nothing else would notice if a refactor dropped a frozen header.

The view settings read back Summary is first and active, zoom 130, gridlines off, navy tab. Detail is zoom 90 with frozen panes starting at B4. Notes has a split pane and a grey tab. A B C D E 1 Sheet Zoom Gridlines Pane Tab 2 Summary (active) 130 off none navy 3 Detail 90 on frozen at B4 — 4 Notes 100 on split grey

A sensible default for reports

When pandas writes the sheets, as here, the view settings go on the XlsxWriter worksheet objects that ExcelWriter exposes through xw.book and xw.sheets, and they must be set before the writer is closed — after close() the file is finished and nothing more can be added. The same applies to any formatting done through the writer.

Most reports can use the same few rules. Freeze the heading row on every table, and the first column too if the table is wider than one screen. Open on the summary, with the cursor in a sensible place and the view scrolled to the top. Use 100% for tables, a little less for very wide ones, and a little more for sparse summary sheets. Hide gridlines on presentation sheets and keep them on data sheets, where they help the eye follow a row. Putting these rules in one helper function that every report calls keeps a family of reports consistent, and a reader who learns one of them knows how to use the rest.

Common pitfalls

  • Counting rows from one. freeze_panes takes zero-based positions; (1, 0) freezes row 1 only.
  • Freezing below a title block. Freeze at the first data row, not after row 1.
  • Using split units as rows. split_panes is in points and character widths.
  • Opening on the last sheet written. Call activate() on the sheet readers should see first.
  • Zoom set once for all sheets. Zoom is per sheet; set it where it matters.

Conclusion

XlsxWriter sets each sheet's view with one call: freeze_panes(row, col) to keep headings and the first column on screen, split_panes for independent scrolling views, set_zoom per sheet, hide_gridlines(2) for presentation sheets, activate, set_selection and set_top_left_cell for where the reader starts, and set_tab_color for navigation. Read the file back with openpyxl to confirm the settings in a test.

Frequently asked questions

How do I freeze the top row with XlsxWriter? Call worksheet.freeze_panes(1, 0); to freeze the top row and first column, use freeze_panes(1, 1).

How do I set the zoom level in XlsxWriter? Call worksheet.set_zoom(120) on each sheet; the value is a percentage between 10 and 400.

How do I choose which sheet is open when the file opens? Call activate() on that worksheet; otherwise the first sheet is active.

What is the difference between freeze_panes and split_panes? Frozen panes lock rows and columns in place; split panes create independently scrolling areas that the user can resize, positioned in points rather than rows.

More on xlsxwriter

More on xlsxwriter →