Guide
Formatting And Charting Excel Reports With PythonDeep dive

Merge Cells and Centre a Report Title with openpyxl

Add a merged, centred title banner to a generated report — merge_cells, styling the anchor cell, the MergedCell read-only trap, and why centre-across-selection is often better.

Every generated report wants a title: the report name, the period, maybe an as-at timestamp, spanning the width of the data and centred. In Excel that means merging a range and centring the text — two calls in openpyxl, plus a handful of behaviours that are surprising the first time. Only one cell in a merged range is real, borders do not span the merge on their own, and merging inside a data region breaks sorting for everyone downstream. This guide covers the banner properly, and the alternative that avoids merging altogether. It extends Styling Excel Cells with openpyxl.

A title banner above the data, and where its value lives A report sheet whose first row is a merged range spanning columns A to D holding a centred title, and whose second row is a merged subtitle. Below them sit the ordinary header row and data rows. An annotation marks cell A1 as the anchor: it is the only real cell in the merged range and the only one that can be written to. B1, C1 and D1 are MergedCell objects and are read-only. Regional Revenue Report August 2026 · generated 2026-08-15 06:00 UTC region units revenue target North · 412 · 5,150.00 · 4,500.00 South · 388 · 4,268.50 · 4,500.00 A1 is the anchor the only writable cell B1, C1, D1 are read-only never merge here merges inside the data break sorting and filtering

Prerequisites

Bash
pip install openpyxl pandas

A report to add a banner to, written with the data starting two rows down:

Python
import pandas as pd

pd.DataFrame({
    "region": ["North", "South", "West"],
    "units": [412, 388, 265],
    "revenue": [5150.00, 4268.50, 3511.25],
    "target": [4500.00, 4500.00, 3000.00],
}).to_excel("report.xlsx", index=False, startrow=2)

Step 1 — Merge and centre

Write the value to the top-left cell, then merge:

Python
from openpyxl import load_workbook
from openpyxl.styles import Font, Alignment, PatternFill

wb = load_workbook("report.xlsx")
ws = wb.active

ws["A1"] = "Regional Revenue Report"
ws.merge_cells("A1:D1")

anchor = ws["A1"]
anchor.font = Font(size=15, bold=True, color="4338CA")
anchor.alignment = Alignment(horizontal="center", vertical="center")
anchor.fill = PatternFill("solid", start_color="EBEBFD", end_color="EBEBFD")
ws.row_dimensions[1].height = 30

wb.save("report_titled.xlsx")

Write the value before or after merging, but always to the anchor. Merging discards the values in every cell except the top-left, so a value written to B1 and then merged is simply gone.

The read-only behaviour catches everyone once:

Python
ws.merge_cells("A1:D1")
ws["B1"] = "anything"
# AttributeError: 'MergedCell' object attribute 'value' is read-only

The cells inside a merge are MergedCell placeholders, not real cells. Only the anchor is writable, which also means a loop over the range needs guarding:

Python
from openpyxl.cell.cell import MergedCell

for row in ws["A1:D1"]:
    for cell in row:
        if isinstance(cell, MergedCell):
            continue                       # skip the placeholders
        cell.value = "written safely"

Step 2 — Border the whole range

A border set on the anchor draws around that one cell's original bounds — a box a quarter of the way across your banner. Borders have to be applied to every cell of the merge, placeholders included:

Python
from openpyxl.styles import Border, Side
from openpyxl.utils import range_boundaries

def style_merged_range(ws, ref, fill=None, border=None):
    """Apply a fill and border across every cell of a merged range."""
    min_col, min_row, max_col, max_row = range_boundaries(ref)
    for row in range(min_row, max_row + 1):
        for col in range(min_col, max_col + 1):
            cell = ws.cell(row=row, column=col)
            if fill is not None:
                cell.fill = fill
            if border is not None:
                cell.border = border

thin = Side(style="thin", color="CDD5E6")
style_merged_range(
    ws, "A1:D1",
    fill=PatternFill("solid", start_color="EBEBFD", end_color="EBEBFD"),
    border=Border(top=thin, bottom=thin, left=thin, right=thin),
)

Assigning a fill or border to a MergedCell works — it is only value that is read-only. That distinction is the thing to remember: styles apply to the placeholders, values do not.

Step 3 — Build the banner as a function

Titles come in a predictable shape: a heading, a subtitle carrying the period and generation time, then a gap before the data. Wrap it once:

Python
from datetime import datetime, timezone
from openpyxl import load_workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter, range_boundaries

def add_banner(ws, title, subtitle=None, columns=4, height=30):
    """Add a merged, centred title (and optional subtitle) above the data."""
    last = get_column_letter(columns)
    thin = Side(style="thin", color="CDD5E6")

    ws["A1"] = title
    ws.merge_cells(f"A1:{last}1")
    ws["A1"].font = Font(size=15, bold=True, color="4338CA")
    ws["A1"].alignment = Alignment(horizontal="center", vertical="center")
    ws.row_dimensions[1].height = height
    style_merged_range(
        ws, f"A1:{last}1",
        fill=PatternFill("solid", start_color="EBEBFD", end_color="EBEBFD"),
        border=Border(bottom=thin),
    )

    if subtitle:
        ws["A2"] = subtitle
        ws.merge_cells(f"A2:{last}2")
        ws["A2"].font = Font(size=10, italic=True, color="5B6780")
        ws["A2"].alignment = Alignment(horizontal="center", vertical="center")
        ws.row_dimensions[2].height = 18

    return ws

wb = load_workbook("report.xlsx")
ws = wb.active
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
add_banner(ws, "Regional Revenue Report",
           f"August 2026 · generated {stamp}", columns=4)
ws.freeze_panes = "A4"
wb.save("report_titled.xlsx")

freeze_panes = "A4" keeps the banner and the column headers visible as the reader scrolls — a small thing that makes a long report noticeably easier to use. Putting the generation time in the subtitle is worth the line too: it is the first question anybody asks about a report they were forwarded.

Step 4 — Consider centre-across-selection instead

Merging is right for a banner above the data. Inside a data region it causes real problems, and there is an alternative that looks identical and causes none.

Two ways to centre text over several columns Merging cells produces the visual result but genuinely combines the cells, which breaks sorting, filtering and pivot tables, and makes pandas read one value followed by a run of blanks. Centre across selection is purely an alignment setting: the text is drawn centred over the range while every cell remains independent, so all of those operations keep working. Visually a reader cannot tell them apart. merge_cells Q3 Summary the cells really are combined sorting and filtering break pivot tables refuse the range pandas reads value + blanks centre across selection Q3 Summary only an alignment setting sorting and filtering work pivot tables accept the range visually identical to a reader
Python
from openpyxl.styles import Alignment

ws["A1"] = "Regional Revenue Report"
# No merge. The text is drawn centred across A1:D1.
for row in ws["A1:D1"]:
    for cell in row:
        cell.alignment = Alignment(horizontal="centerContinuous")

centerContinuous centres the anchor's text across every adjacent cell that also carries the setting, stopping at the first cell that does not. The cells stay independent, so a reader can sort, filter and pivot the sheet normally — and pandas reads it without the blank-run problem described in handling merged cells when reading Excel.

Use merging for a title above the data, where nothing will ever sort it. Use centerContinuous anywhere a merge would sit inside or beside a data region.

Step 5 — Unmerge

The anchor takes values; the placeholders take only styles A merged range from A1 to D1. The anchor cell A1 is a real cell and accepts both a value and styling. Cells B1, C1 and D1 are MergedCell placeholders: assigning a fill or a border to them works and is in fact required for the border to span the range, but assigning a value raises an AttributeError because the value attribute is read-only. A1 — the anchor cell.value = "Report title" cell.font, cell.fill, cell.border a real cell — everything works its value displays across the range B1, C1, D1 — placeholders cell.value = … raises cell.fill, cell.border work and styling them is required or the border stops partway across

Removing a merge takes the same reference:

Python
from openpyxl import load_workbook

wb = load_workbook("report_titled.xlsx")
ws = wb.active

ws.unmerge_cells("A1:D1")
print([str(r) for r in ws.merged_cells.ranges])

The anchor keeps its value and the former placeholders become ordinary empty cells. When you are flattening a whole sheet rather than one range, snapshot the ranges before iterating — unmerging mutates the collection you would otherwise be looping over:

Python
for ref in [str(r) for r in ws.merged_cells.ranges]:
    ws.unmerge_cells(ref)

Common pitfalls and fixes

SymptomCauseFix
AttributeError: 'MergedCell' ... read-onlyWriting to a non-anchor cellWrite to the top-left cell only.
Border stops a quarter of the way acrossBorder set on the anchor onlyApply it to every cell in the range.
Title value disappeared after mergingValue was not in the anchorPut it in the top-left cell.
Sorting stopped workingMerge inside the data regionUse centerContinuous instead.
pandas reads blanks after the titleMerged range read normallySkip the banner rows with skiprows.
RuntimeError while unmerging in a loopIterating the live rangesSnapshot the refs into a list first.
Title cut off verticallyRow height still the defaultSet row_dimensions[1].height.
Banner scrolls out of viewNo frozen panesws.freeze_panes = "A4".

Performance and scale notes

Merging is cheap — a merged range is one entry in the sheet's range collection, regardless of how many cells it spans. The costs are elsewhere.

Styling the range is per-cell. style_merged_range touches every cell, so a banner across ten columns costs ten style assignments. That is nothing for a title; it would matter if you merged thousands of ranges, which is itself a sign the design is wrong.

Merges slow Excel's own operations. A sheet with many merged ranges is noticeably slower to scroll, sort and recalculate, because Excel checks the merge collection for every affected cell. A report with a handful of banner merges is fine; one with a merge per group heading across ten thousand rows is not — use centerContinuous, or repeat the group value on every row and let a pivot do the grouping.

Merges cannot be created in write_only mode. Streaming writes emit rows as they go and never hold the sheet, so a large report needing both streaming and a banner has to be written in two passes: stream the data with the approach in writing large DataFrames with write-only mode, then re-open it normally to add the banner. Since the banner touches only the first two rows, that second pass is the cheapest part of the job.

If you are creating a report from scratch rather than modifying one, the xlsxwriter equivalent is a single call and slightly faster:

Python
import pandas as pd

with pd.ExcelWriter("report.xlsx", engine="xlsxwriter") as writer:
    df.to_excel(writer, sheet_name="Report", index=False, startrow=2)
    book, sheet = writer.book, writer.sheets["Report"]

    title = book.add_format({
        "bold": True, "font_size": 15, "font_color": "#4338CA",
        "bg_color": "#EBEBFD", "align": "center", "valign": "vcenter",
        "bottom": 1, "border_color": "#CDD5E6",
    })
    sheet.merge_range("A1:D1", "Regional Revenue Report", title)
    sheet.set_row(0, 30)
    sheet.freeze_panes(3, 0)

merge_range writes the value, merges and applies the format across the whole range in one call — including the border, which is the part openpyxl makes you do by hand.

Conclusion

A merged title banner is merge_cells plus an alignment on the anchor, with two behaviours to remember: only the top-left cell accepts a value, and borders must be applied to every cell of the range or they stop partway across. Set the row height and freeze the panes so the banner stays visible. And keep merges above the data, never inside it — where you want the look without the consequences, centerContinuous centres text across columns while leaving every cell independent, so sorting, filtering and pandas all keep working.

Frequently asked questions

Why does writing to a merged cell raise AttributeError? Only the top-left cell of a merged range is a real cell; the rest are MergedCell objects that are read-only. Write to the anchor cell — the top-left one — and the value displays across the whole range.

How do I style the whole merged range? Set the value and font on the anchor cell, but apply borders and fills to every cell in the range. A border set only on the anchor draws around that one cell's original bounds, leaving the rest of the merge unbordered.

Should I merge cells at all? For a title banner, yes — it is what readers expect. For anything inside a data region, no: merged cells break sorting, filtering and pivot tables, and they read back into pandas as one value plus a run of blanks.

What is centre across selection? An alignment option that visually centres text over several columns without actually merging them. It looks the same to a reader and leaves the cells independent, so sorting and filtering keep working.

How do I remove a merge? Call unmerge_cells with the same range reference. The anchor keeps the value and the other cells become ordinary empty cells, so you may want to fill them afterwards.