Set the Print Area and Page Setup with openpyxl
A generated workbook that looks right on screen can print as fourteen pages of orphaned columns, and nobody discovers that until someone in the meeting tries. openpyxl exposes the whole page-setup surface — print area, orientation, scaling, repeated headers, margins, headers and footers — and setting it takes about ten lines. The same settings govern PDF export, which is where most generated reports actually end up. This guide configures a sheet for printing properly. It belongs to Styling Excel Cells with openpyxl.
Prerequisites
pip install openpyxl
Set the print area
Without one, Excel prints everything it considers used — including a stray value someone left in AZ900:
from openpyxl import load_workbook
wb = load_workbook("report.xlsx")
ws = wb["Summary"]
last_col = ws.cell(row=1, column=ws.max_column).column_letter
ws.print_area = f"A1:{last_col}{ws.max_row}"
wb.save("report.xlsx")
Computing the range from the data rather than hardcoding it means the setting stays correct as the report grows. For several blocks, assign a list — ws.print_area = ["A1:F20", "A30:F60"] — and each prints as its own page.
Orientation, paper and fit to width
The single most valuable setting is fit-to-width: it scales the sheet so no column is orphaned, while letting the rows flow over as many pages as they need.
from openpyxl.worksheet.properties import PageSetupProperties
ws.page_setup.orientation = "landscape" # 'portrait' is the default
ws.page_setup.paperSize = ws.PAPERSIZE_A4
ws.page_setup.fitToWidth = 1
ws.page_setup.fitToHeight = 0 # 0 = as many pages as needed
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True)
That last line is the one everybody misses. fitToWidth is ignored unless the sheet's page-setup properties say the sheet is in fit-to-page mode; without it Excel keeps whatever percentage scale was set and the setting appears to do nothing.
Repeat the header row on every page
ws.print_title_rows = "1:1" # repeat row 1 at the top of every page
ws.print_title_cols = "A:A" # and column A down the left
On a report longer than a page this is the difference between a printout somebody can read and one where page four is a grid of unlabelled numbers. If the report has a two-row header — a title and a column row — use "1:2".
Headers, footers and margins
Excel's header and footer strings use its own format codes, and openpyxl passes them straight through:
ws.oddHeader.left.text = "Regional sales"
ws.oddHeader.left.size = 11
ws.oddHeader.right.text = "&D &T" # date and time
ws.oddFooter.center.text = "Page &P of &N"
ws.oddFooter.right.text = "&F — &A" # filename — sheet name
ws.page_margins.left = 0.5
ws.page_margins.right = 0.5
ws.page_margins.top = 0.6
ws.page_margins.bottom = 0.6
The codes worth knowing are &P (page number), &N (total pages), &D (date), &T (time), &F (file name), &A (sheet name) and &B (bold). Margins are in inches regardless of the paper size.
Centre, gridlines and print quality
ws.print_options.horizontalCentered = True
ws.print_options.verticalCentered = False
ws.print_options.gridLines = False # True prints the sheet grid
ws.print_options.headings = False # row numbers and column letters
ws.page_setup.blackAndWhite = False
Printing gridlines is usually wrong for a styled report — the borders you applied already convey the structure, and the grid competes with them. For an unformatted data dump, turning them on helps.
Control where the pages break
Let Excel paginate unless a break belongs somewhere specific — between regions, or before a summary block:
from openpyxl.worksheet.pagebreak import Break
for row in (25, 50, 75):
ws.row_breaks.append(Break(id=row)) # break after this row
Manual breaks are worth adding when each page should be self-contained, for example one region per page in a pack that gets split up and handed out.
Apply it to every sheet in the workbook
Page setup is per sheet, and a multi-sheet report needs it everywhere:
from openpyxl.worksheet.properties import PageSetupProperties
def configure_printing(ws, landscape: bool = True) -> None:
last_col = ws.cell(row=1, column=max(ws.max_column, 1)).column_letter
ws.print_area = f"A1:{last_col}{ws.max_row}"
ws.page_setup.orientation = "landscape" if landscape else "portrait"
ws.page_setup.paperSize = ws.PAPERSIZE_A4
ws.page_setup.fitToWidth = 1
ws.page_setup.fitToHeight = 0
ws.sheet_properties.pageSetUpPr = PageSetupProperties(fitToPage=True)
ws.print_title_rows = "1:1"
ws.print_options.horizontalCentered = True
ws.oddFooter.center.text = "&A — page &P of &N"
for sheet in wb.worksheets:
configure_printing(sheet)
wb.save("report.xlsx")
Putting that in the same module as the report's style constants keeps every generated workbook consistent, in the spirit of Apply a reusable style theme across an Excel report.
Check the settings landed
Page setup is invisible until somebody prints, which is the worst moment to discover it is wrong. Read the properties back after saving:
from openpyxl import load_workbook
wb = load_workbook("report.xlsx")
for ws in wb.worksheets:
setup = ws.page_setup
props = ws.sheet_properties.pageSetUpPr
print(f"{ws.title:20s} area={ws.print_area} titles={ws.print_title_rows} "
f"orient={setup.orientation} fitW={setup.fitToWidth} "
f"fitToPage={getattr(props, 'fitToPage', None)}")
Summary area=A1:H42 titles=1:1 orient=landscape fitW=1 fitToPage=True
Detail area=None titles=None orient=portrait fitW=None fitToPage=None
The second line is the failure this check exists to catch: a sheet added later that never went through the configure function. Looping over wb.worksheets at the end of the job, rather than configuring sheets as they are created, makes that impossible.
Scale by percentage when fitting is wrong
Fit-to-page is right for a wide table, but it can shrink a narrow report to an unreadable size — a five-column sheet fitted to one page wide is fine, while the same setting on a sheet with one very wide text column drags everything down with it. Use an explicit percentage scale instead:
ws.sheet_properties.pageSetUpPr = None # leave fit-to-page mode
ws.page_setup.scale = 85 # per cent of natural size
The two are mutually exclusive: Excel honours scale only when the sheet is not in fit-to-page mode, which is why the property is cleared first. Between 80 and 90 per cent usually buys a column or two without hurting legibility; below about 70 the text stops being comfortable to read on paper.
A practical rule for a generated report: fit to width when the sheet is a wide table of numbers, and use a fixed scale when it is a narrow document-like sheet with long text. If you cannot decide, set the width of the widest text column explicitly and wrap it — the layout then fits without any scaling at all, as covered in Set column width and row height in openpyxl.
Common pitfalls and gotchas
fitToWidthwith nofitToPage. The setting is silently ignored; set the sheet property too.- A stale print area. Hardcoding
A1:F100on a report that grows to 300 rows truncates it without warning. - Margins in the wrong unit. They are inches, not centimetres or points.
- Setting page options on the workbook. They live on each worksheet.
- Expecting xlsxwriter's API here. xlsxwriter uses methods such as
ws.set_landscape()andws.fit_to_pages(); the concepts match but the names do not.
Performance and scale notes
Page setup costs nothing at write time — it is a handful of XML attributes — so apply it to every generated sheet rather than only the ones somebody has complained about. Where it does have a measurable effect is PDF conversion: a sheet without a print area can produce a PDF hundreds of pages long, which is slow to render and slow to email. Setting print_area and fitToWidth before conversion often turns a multi-megabyte PDF into a two-page one. The conversion itself is covered in Convert an Excel file to PDF with Python and the page furniture in Add headers, footers and page numbers to an Excel PDF.
Conclusion
Ten lines of page setup decide whether a generated report is usable on paper or as a PDF. Set the print area from the data's real extent, turn on fit-to-page and fit-to-width so no column is orphaned, repeat the header row, and put "page P of N" in the footer. Apply the same function to every sheet in the workbook, and the output is consistent whether a reader prints it, exports it, or just scrolls.
Frequently asked questions
Why does fitToWidth do nothing?
Scaling options only apply when sheet properties are set to use them. Set ws.sheet_properties.pageSetUpPr.fitToPage = True as well as fitToWidth, or Excel keeps the percentage scale instead.
How do I repeat the header row on every printed page?
Set ws.print_title_rows = "1:1". The equivalent for columns is print_title_cols, and both accept ranges like "1:2" or "A:B".
Does this affect a PDF export? Yes, and that is the main reason to bother. LibreOffice and Excel both honour page setup when converting to PDF, so an unconfigured sheet becomes a PDF spread across a dozen ragged pages.
Can I set the print area to several ranges? Yes — assign a list of ranges. Excel prints each as its own page, which is useful for a summary block and a detail block on one sheet.
How do I put the page number in the footer?
Use Excel's format codes in ws.oddFooter.center.text, for example "Page &P of &N". The same codes work in headers.
Related
- Up: Styling Excel Cells with openpyxl — the on-screen half of presentation.
- Freeze the header row in Excel with openpyxl — the on-screen counterpart to repeated print titles.
- Set column width and row height in openpyxl — widths decide how much fit-to-width has to shrink.
- Convert an Excel file to PDF with Python — where these settings pay off most.
- Add headers, footers and page numbers to an Excel PDF — the same codes, in the PDF pipeline.