Fix
Getting Started With Python Excel AutomationDeep dive

Fix "MergedCell object attribute value is read-only" in openpyxl

openpyxl raises this AttributeError when code writes to a cell that sits inside a merged range. Find the merge, write to its top-left cell, and watch for the less obvious cause — insert_rows and delete_rows move cells but leave merged ranges behind.

You write a value into a worksheet and openpyxl stops you:

Text
AttributeError: 'MergedCell' object attribute 'value' is read-only

The cell you addressed is part of a merged range, and it is not the range's top-left corner. In Excel's model a merged range has exactly one value, stored in that corner; every other cell in the range is covered. openpyxl represents the covered cells as MergedCell objects, which have a position but no writable value. The fix is simple once you know which range covers the cell — and the interesting part of this error is the case where you did not think any range covered it. This page is part of Troubleshooting Common Python Excel Errors.

Which cells of a merged range can hold a value A worksheet with A1:C1 merged into one title cell. Only A1, the top-left cell, is a normal Cell that stores the title. B1 and C1 are MergedCell placeholders; assigning a value to either raises the read-only error. ws.merge_cells("A1:C1") — one value, three positions A B C 1 Quarterly sales 2 Region Q1 Q2 3 North 410 380 B1 and C1 are MergedCell no writable value — write to A1 instead

Prerequisites

Bash
pip install openpyxl
Python
from openpyxl import Workbook, load_workbook
from openpyxl.utils import get_column_letter

wb = Workbook()
ws = wb.active
ws.merge_cells("A1:C1")
ws["A1"] = "Quarterly sales"
ws.append(["Region", "Q1", "Q2"])
ws.append(["North", 410, 380])

Step 1 — Confirm the cell is a placeholder

Check the cell's type before anything else. Every cell in a merged range except the corner is a MergedCell, and writing to it raises the error whether you use ws["B1"] = ... or ws.cell(row=1, column=2, value=...):

Python
print(type(ws["A1"]).__name__, type(ws["B1"]).__name__)
# Cell MergedCell

try:
    ws["B1"] = "overwrite"
except AttributeError as exc:
    print(exc)
# 'MergedCell' object attribute 'value' is read-only

ws.merged_cells.ranges lists every merge on the sheet. When a workbook comes from someone else, printing it is the fastest way to see what you are dealing with:

Python
print(ws.merged_cells.ranges)
# {<MergedCellRange A1:C1>}

Step 2 — Write to the range's top-left cell

If the intent is "put this value in the merged block", write to the corner. A small helper finds the range that contains any coordinate and returns the cell that actually stores the value, so calling code does not need to know where the merges are:

Python
def owner(ws, coord):
    """Return the cell that stores the value for coord (itself, or its merge's corner)."""
    for rng in ws.merged_cells.ranges:
        if coord in rng:
            return ws.cell(row=rng.min_row, column=rng.min_col)
    return ws[coord]

owner(ws, "C1").value = "Quarterly sales — revised"
print(ws["A1"].value)
# Quarterly sales — revised

This is the right behaviour for templates, where someone merged a title or an address block and your script should fill it without caring about the merge. It is the wrong behaviour when the merge itself is a mistake, which is Step 3.

Step 3 — Unmerge when each cell should hold its own value

If the script genuinely needs separate values in B1 and C1, the merge has to go. unmerge_cells turns the placeholders back into ordinary cells. The corner keeps its value; the others start empty:

Python
ws.unmerge_cells("A1:C1")
ws["B1"] = "draft"
print(ws["A1"].value, "|", ws["B1"].value, "|", type(ws["C1"]).__name__)
# Quarterly sales — revised | draft | Cell

For sheets where merged blocks stand for "this value applies to every cell below", unmerging and filling the value into each cell is a data-cleaning step in its own right — Unmerge Cells and Fill the Values with openpyxl covers it.

Choosing between Step 2 and Step 3 is really a question about what the merge means. A merged title or a merged address block is presentation: the sheet shows one value across several columns, and your script should respect that by writing to the corner. A merge over cells that should each hold data — a header that someone merged across two quarters, a "Region" label merged down five rows — is structure that fights the data, and unmerging is the honest fix. When the workbook is a template owned by someone else, ask before unmerging; when it is output your own script produces, simply stop creating that merge.

Write to the corner or unmerge Two panels. Write to the top-left cell when the merge is presentation, such as a title, an address block or a template label, and one value should cover the block. Unmerge when each covered cell needs its own value, such as merged headers over data columns or labels merged down rows, and the cells must be written separately. Write to the top-left cell merged title or banner address or signature block template label you fill values for each covered cell Unmerge the range header merged over data columns label merged down many rows output your own script creates somebody else's template, unasked

Step 4 — The hidden cause: inserting or deleting rows

Here is the version of the error that confuses people. The merge is on the title row, and the script writes to a data row — yet it still fails:

Python
wb = Workbook()
ws = wb.active
ws.merge_cells("A1:C1")
ws["A1"] = "Quarterly sales"
ws.append(["Region", "Q1", "Q2"])

ws.insert_rows(1)                     # make room for a banner line

print(ws.merged_cells.ranges)         # still A1:C1
print({c: type(ws[c]).__name__ for c in ("A1", "B1", "A2", "B2", "C2")})
# {'A1': 'Cell', 'B1': 'Cell', 'A2': 'Cell', 'B2': 'MergedCell', 'C2': 'MergedCell'}

try:
    ws["B2"] = "Q1"
except AttributeError as exc:
    print("B2:", exc)

insert_rows moved every cell down one row, including the placeholders, so B2 and C2 are now MergedCell objects. But it did not move the merged range, which still says A1:C1. The sheet is now inconsistent: the placeholders sit on row 2, where the title went, while the merge covers row 1. Save it and the file records a merge over the empty banner row with the title left unmerged underneath it. delete_rows, insert_cols and delete_cols behave the same way.

What insert_rows does to a merged title Before: the title in A1 is merged across A1:C1. After insert_rows(1), the cells moved down so the title and its MergedCell placeholders are on row 2, but the merged range still covers A1:C1, leaving row 2 with read-only placeholders in B2 and C2. A B C 1 Quarterly sales 2 Region Q1 Q2 A B C 1 2 Quarterly sales read-only read-only 3 Region Q1 Q2 before — merge A1:C1 after — merge still A1:C1 insert_rows(1)

The fix is to move the merged ranges yourself, straight after the insert or delete. Each MergedCellRange has a shift method; ranges at or below the insertion point move by the number of rows inserted:

Python
def insert_rows_keeping_merges(ws, idx, amount=1):
    moving = [r for r in ws.merged_cells.ranges if r.min_row >= idx]
    for rng in moving:
        ws.unmerge_cells(str(rng))
    ws.insert_rows(idx, amount)
    for rng in moving:
        rng.shift(row_shift=amount)
        ws.merge_cells(str(rng))

wb = Workbook()
ws = wb.active
ws.merge_cells("A1:C1")
ws["A1"] = "Quarterly sales"
ws.append(["Region", "Q1", "Q2"])

insert_rows_keeping_merges(ws, 1)
print(ws.merged_cells.ranges, ws["A2"].value)
ws["A1"] = "Banner line"          # works: row 1 is ordinary cells now
print(type(ws["B2"]).__name__)    # MergedCell — correctly, it is inside A2:C2

Unmerging before the insert matters: it turns the placeholders back into normal cells, so insert_rows moves ordinary cells and the helper re-creates the merge at the new position. Ranges that straddle the insertion point are rare in reports; if yours has them, decide per case whether they should grow or move.

The column_letter variant

A closely related error appears in column-width code:

Text
AttributeError: 'MergedCell' object has no attribute 'column_letter'

The classic auto-width loop reads the letter from the first cell of each column, and when row 1 has a merged title, the first cell of column B is a placeholder with no column_letter. Use the column number, which both cell types have, and convert it:

Python
wb = Workbook()
ws = wb.active
ws.merge_cells("A1:C1")
ws["A1"] = "Quarterly sales"
ws.append(["Region", "Quarter one", "Quarter two"])

for col in ws.iter_cols(min_row=2):
    letter = get_column_letter(col[0].column)
    width = max(len(str(c.value)) for c in col if c.value is not None)
    ws.column_dimensions[letter].width = width + 2
print({k: v.width for k, v in ws.column_dimensions.items()})

Starting the loop at min_row=2 also stops a long merged title from making column A enormous — its length belongs to the whole merged block, not to one column. The full recipe is in Auto-Fit Column Widths When Writing with pandas.

Common pitfalls

  • Catching the error and skipping the cell. The value you meant to write silently disappears. Decide whether it belongs in the corner (Step 2) or in its own unmerged cell (Step 3).
  • Merging before writing data. Build the grid first, merge last. Merging early turns later writes into placeholders.
  • Structural edits on templates with merges. Any insert_rows/delete_rows call on a sheet with merged ranges below the edit point needs the shift from Step 4.
  • Assuming read-only mode helps. Workbooks opened with read_only=True cannot be written at all; the error you get there is different, and the answer is to open the file normally.

Performance notes

Scanning ws.merged_cells.ranges is cheap — even heavily formatted reports have tens of merges, not thousands. If a helper like owner() is called for every cell of a large sheet, build a dictionary from coordinate to corner once and look cells up in it, rather than scanning every range for every cell.

Conclusion

'MergedCell' object attribute 'value' is read-only means you wrote to a covered cell of a merged range. Write to the corner when the value belongs to the whole block, unmerge when cells need their own values, and after insert_rows or delete_rows shift the merged ranges yourself, because openpyxl moves the placeholders but not the merges. The column_letter variant has the same cause and is fixed by working from cell.column with get_column_letter.

Frequently asked questions

Why can I write to A1 but not B1 in a merged range A1:C1? Only the top-left cell of a merged range holds a value. openpyxl represents every other cell in the range as a MergedCell placeholder, and placeholders have no writable value. Write to A1.

Why do I get the error on a row that has no merged cells in Excel? Most often because insert_rows or delete_rows ran earlier in the script. They move the MergedCell placeholders along with the other cells, but they do not move the merged ranges, so ordinary data cells end up as read-only placeholders. Shift the ranges yourself after inserting or deleting.

Does unmerging lose data? No. After a merge only the top-left cell has a value, so unmerging leaves that value where it is and turns the other cells back into empty, writable cells.

What about 'MergedCell' object has no attribute 'column_letter'? Same cause, different attribute. Code that loops over ws.columns and reads col0.column_letter fails when the first cell of a column is a placeholder. Use get_column_letter from openpyxl.utils on col0.column instead, which works for both cell types.

More on errors & fixes

More on errors & fixes →