Lock Cells and Protect a Sheet with openpyxl
Ship a report with a formula column and somebody will type over it. Not maliciously — they will tab one cell too far, or paste a block that lands one column left of where they meant. Excel's sheet protection exists for exactly this, and openpyxl sets it in a few lines. The catch is that the mechanic runs backwards from how people expect: you do not lock the formulas, you unlock the inputs. This guide covers the pattern, the inverted permission flags, partial protection, and verifying the result. It is the practical core of Protecting and Sharing Excel Workbooks.
Prerequisites
pip install openpyxl
One concept to hold before starting: locked is a cell style attribute, and it does nothing on its own. Excel only consults it when the sheet is protected. So a workbook where every cell is locked and protection is off behaves exactly like one where nothing is locked — which is why the flag catches people out.
Step 1 — Build a sheet worth protecting
A small forecast with a formula column gives the protection something to defend:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = Workbook()
ws = wb.active
ws.title = "Forecast"
ws.append(["Region", "Units (enter)", "Unit price", "Revenue"])
for row, (region, price) in enumerate(
[("North", 12.50), ("South", 11.00), ("West", 13.25)], start=2
):
ws.append([region, 0, price, f"=B{row}*C{row}"])
ws.append([])
ws.append(["Total", None, None, "=SUM(D2:D4)"])
header = Font(bold=True, color="FFFFFF")
header_fill = PatternFill("solid", fgColor="4338CA")
for cell in ws[1]:
cell.font = header
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
for col, width in zip("ABCD", (16, 15, 13, 14)):
ws.column_dimensions[col].width = width
wb.save("forecast.xlsx")
Step 2 — Unlock the inputs, then protect
The two halves, in the only order that works:
from openpyxl import load_workbook
from openpyxl.styles import Protection, PatternFill
wb = load_workbook("forecast.xlsx")
ws = wb["Forecast"]
# 1. Clear the lock on the cells readers are meant to fill in.
entry_fill = PatternFill("solid", fgColor="FFF7E6")
for row in ws.iter_rows(min_row=2, max_row=4, min_col=2, max_col=2):
for cell in row:
cell.protection = Protection(locked=False)
cell.fill = entry_fill
# 2. Turn protection on. Now the still-locked cells refuse edits.
ws.protection.sheet = True
ws.protection.enable()
wb.save("forecast_protected.xlsx")
Reversing the order does not work, and the failure is silent — enable() does not re-read the cells, but neither does it complain. What you get is a sheet where the protection is on and the unlock never registered, so nobody can type anywhere.
The tint is not decoration. On a protected sheet with no visual cue, a reader who clicks a locked cell sees a modal error with no explanation of where they should type. Two lines of fill saves the support message.
For a large or scattered input area, address it by range rather than by loop bounds:
for row in ws["B2:B40"]:
for cell in row:
cell.protection = Protection(locked=False)
Step 3 — Choose what stays allowed
Protection is a set of independent permissions, not a single switch. The naming is the thing to internalise: each flag names what is protected, so True blocks and False permits.
ws.protection.sheet = True # master switch: protection is ON
# Keep the sheet usable.
ws.protection.autoFilter = False # filtering allowed
ws.protection.sort = False # sorting allowed
ws.protection.selectLockedCells = False # locked cells still selectable
# Block the destructive operations.
ws.protection.formatCells = True
ws.protection.insertRows = True
ws.protection.insertColumns = True
ws.protection.deleteRows = True
ws.protection.deleteColumns = True
ws.protection.enable()
The two worth setting on every report are autoFilter and sort. A protected sheet defaults to blocking both, which quietly disables the filter dropdowns added in creating Excel tables and autofilters with Python — the table looks normal and the controls simply do nothing.
| Flag | True means | Set to False when |
|---|---|---|
autoFilter | filtering blocked | the sheet has a filter or table |
sort | sorting blocked | readers browse the data |
formatCells | restyling blocked | almost always leave True |
insertRows | inserting blocked | readers append their own rows |
deleteRows | deleting blocked | almost always leave True |
selectLockedCells | locked cells unselectable | you want values still copyable |
Leaving selectLockedCells = False matters more than it sounds: with it set to True, readers cannot even click a locked cell to copy its value, which makes a report feel broken.
Step 4 — Protect only part of a sheet
Sometimes one group should edit a block that everyone else cannot. openpyxl supports Excel's "allow users to edit ranges" through ws.protection.add:
from openpyxl import load_workbook
from openpyxl.worksheet.protection import SheetProtection
from openpyxl.workbook.protection import WorkbookProtection
wb = load_workbook("forecast.xlsx")
ws = wb["Forecast"]
ws.protection.sheet = True
ws.protection.password = "report"
ws.protection.enable()
# A named range that a specific team can unlock with their own password.
ws.protection.add(
name="ForecastInputs",
sqref="B2:B4",
password="planning",
)
wb.save("forecast_partial.xlsx")
In practice, the simpler unlock-the-range approach covers most needs and does not require anyone to remember a second password. Reach for add only when genuinely different groups need different access to the same sheet.
Step 5 — Verify before shipping
Protection is state that a later stage can silently discard, so assert on it. This is a cheap test and it catches the most common regression in a reporting pipeline:
from openpyxl import load_workbook
def assert_protected(path, sheet, editable="B2:B4", locked_col="D"):
wb = load_workbook(path)
ws = wb[sheet]
assert ws.protection.sheet, f"{sheet} is not protected"
for row in ws[editable]:
for cell in row:
assert cell.protection.locked is False, \
f"{cell.coordinate} should be unlocked"
for (cell,) in ws.iter_rows(min_row=2, max_row=4,
min_col=4, max_col=4):
assert cell.protection.locked is not False, \
f"{cell.coordinate} in the formula column is unlocked"
return True
assert_protected("forecast_protected.xlsx", "Forecast")
print("protection verified")
Note is not False rather than is True in the third check: cells that were never touched carry the inherited default rather than an explicit True, so a strict identity test against True gives a false failure. Drop this into the suite described in testing Excel output with pytest.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Nothing is editable | Protection enabled without unlocking any cells | Assign Protection(locked=False) to the input range first. |
| Everything is still editable | ws.protection.sheet never set | Set sheet = True and call enable(). |
| Filter dropdowns do nothing | autoFilter defaults to blocked | ws.protection.autoFilter = False. |
| Readers cannot copy values | selectLockedCells = True | Set it to False. |
| Protection gone after the job runs | A later to_excel replaced the sheet | Protect as the final step. |
| Unlocking a whole column locked nothing | Assigned to the column dimension, not the cells | Iterate the cells; column_dimensions has no protection. |
| Password prompt never appears | Password set but sheet not enabled | Both are required. |
| Formatting lost after protecting | Reassigned cell.protection on a styled cell | Protection replaces only the protection part; check you did not also reassign cell.style. |
Performance and scale notes
Assigning cell.protection creates a style entry, and openpyxl deduplicates identical ones — so unlocking a thousand cells with the same Protection(locked=False) object costs one style, not a thousand. Create the object once outside the loop rather than constructing a new one per cell:
from openpyxl.styles import Protection
unlocked = Protection(locked=False) # one object, reused
for row in ws.iter_rows(min_row=2, max_row=50_000, min_col=2, max_col=2):
for cell in row:
cell.protection = unlocked
Constructing inside the loop still deduplicates on save, but it allocates fifty thousand short-lived objects along the way, which is measurable on large sheets.
Two structural notes for big reports. Protection cannot be used with openpyxl's write_only mode — that mode streams rows and never holds cells to style — so a workbook that needs both bulk volume and protection has to be written in normal mode, or written streaming and then re-opened to protect. And Excel's ceiling of roughly 64,000 distinct cell formats counts protection variants too, which is another reason to reuse one shared Protection object rather than creating them ad hoc.
Where a report is genuinely large, the pragmatic split is the same one used for macro workbooks: keep the protected, formula-bearing summary small, and push bulk detail to a separate sheet or file written with the streaming approach in writing large DataFrames with write-only mode.
Conclusion
Sheet protection in openpyxl is two steps in a fixed order: clear locked on the cells readers should be able to edit, then set ws.protection.sheet = True and call enable(). Tint the input range so it is obvious where typing is expected. Remember the flags name what is protected, so set autoFilter and sort to False to keep the sheet browsable. Protect last, after all data is written, and assert on the result — a protection step that silently stopped working looks exactly like one that is fine.
Frequently asked questions
Why is my whole sheet read-only after protecting it?
Every cell starts with locked=True, so enabling protection locks all of them. Clear the flag on the cells readers should be able to type into by assigning Protection(locked=False) before you enable protection.
Do I need a password on sheet protection? Not for the guard-rail effect — protection works with no password at all and still prevents accidental edits. A password only stops a reader clicking Unprotect Sheet, and it is easily removed, so treat it as a speed bump.
Why did autoFilter stop working after protection?
The flags name what is protected, so True blocks and False permits. Set ws.protection.autoFilter = False and ws.protection.sort = False to keep filtering and sorting available on a protected sheet.
Can I protect only part of a sheet?
Yes, in two ways. Leave the editable range unlocked, which is the usual approach, or add an unprotected range with its own password through ws.protection.add so a specific group can edit one block.
Does protection survive a pandas write?
No. to_excel replaces the whole sheet, discarding protection along with comments and column widths. Apply protection as the final step, after all data has been written.
Related
- Up to the parent: Protecting and Sharing Excel Workbooks — how sheet protection fits with the other layers.
- Password Protect an Excel File with Python — real encryption, for when reading must be stopped too.
- Hide Sheets, Rows and Columns with openpyxl — tidying the working sheets before you protect.
- Add Dropdown Data Validation to Excel with openpyxl — constraining what the unlocked cells accept.
- Styling Excel Cells with openpyxl — the fills used to signal the input range.