Insert and Delete Rows and Columns with openpyxl
Sometimes a sheet needs a new column between two existing ones, or a block of rows removed. openpyxl has four methods for it, and they work — but they do considerably less than Excel's equivalent commands, and the gap is where bugs come from. Formulas do not follow the cells they reference. Merged ranges and conditional formatting do not always move. And deleting rows in the obvious order removes the wrong ones. This guide covers the mechanics and the guard rails. It is part of Using openpyxl for Excel File Manipulation.
Prerequisites
pip install openpyxl
A sheet with a formula, so the traps are visible:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.append(["region", "units"])
ws.append(["North", 120])
ws.append(["South", 95])
ws.append(["West", 140])
ws["A5"] = "Total"
ws["B5"] = "=SUM(B2:B4)"
wb.save("sales.xlsx")
Step 1 — Insert rows and columns
The four methods take a position and a count:
from openpyxl import load_workbook
wb = load_workbook("sales.xlsx")
ws = wb.active
ws.insert_rows(3) # one row before row 3
ws.insert_rows(3, amount=2) # two rows before row 3
ws.insert_cols(2) # one column before column B
ws.insert_cols(2, amount=3) # three columns before column B
wb.save("sales_expanded.xlsx")
The position is where the new row will be, not where it goes after. insert_rows(3) makes the new row row 3 and pushes the old row 3 down to row 4.
Inserted rows are empty and unstyled — they do not inherit the formatting of their neighbours the way Excel's insert does. Fill them yourself:
from copy import copy
from openpyxl import load_workbook
wb = load_workbook("sales.xlsx")
ws = wb.active
ws.insert_rows(3)
ws["A3"] = "East"
ws["B3"] = 88
# Copy the style from the row below, which is the old row 3.
for col in range(1, ws.max_column + 1):
source = ws.cell(row=4, column=col)
target = ws.cell(row=3, column=col)
target._style = copy(source._style)
wb.save("sales_expanded.xlsx")
Copying _style wholesale carries font, fill, border, alignment and number format in one assignment. It is a private attribute, but it is the practical way to clone a cell's complete appearance — assigning the individual style objects one by one is both longer and easy to leave incomplete.
Step 2 — Fix the formulas yourself
This is the part that catches people. openpyxl moves cells; it does not rewrite formula text. After the insert above, the total still reads =SUM(B2:B4) and excludes the new row.
You have two options. The robust one is to rewrite the formulas after any structural change, which is easy when your script owns the layout:
from openpyxl import load_workbook
wb = load_workbook("sales.xlsx")
ws = wb.active
ws.insert_rows(3)
ws["A3"], ws["B3"] = "East", 88
# Rewrite the total to span whatever the data range now is.
last_data_row = ws.max_row - 1 # the total sits on the last row
ws.cell(row=ws.max_row, column=2).value = f"=SUM(B2:B{last_data_row})"
wb.save("sales_expanded.xlsx")
The more durable one is to use a named range for the data block and have the formula reference the name. Names are stored once and are far easier to update than scanning formula text — see creating a named range in Excel with openpyxl.
If you must patch existing formulas, do it deliberately rather than with a blanket regular expression — a naive substitution will happily corrupt a text cell that happens to contain something like B2:B4:
import re
from openpyxl import load_workbook
RANGE = re.compile(r"\b([A-Z]{1,3})(\d+):([A-Z]{1,3})(\d+)\b")
def shift_ranges(formula, at_row, amount):
"""Extend ranges that span the insertion point. Formulas only."""
def repl(m):
c1, r1, c2, r2 = m.group(1), int(m.group(2)), m.group(3), int(m.group(4))
if r1 < at_row <= r2:
r2 += amount
return f"{c1}{r1}:{c2}{r2}"
return RANGE.sub(repl, formula)
wb = load_workbook("sales.xlsx")
ws = wb.active
for row in ws.iter_rows():
for cell in row:
if isinstance(cell.value, str) and cell.value.startswith("="):
cell.value = shift_ranges(cell.value, at_row=3, amount=1)
The startswith("=") guard is what keeps this safe — only formula cells are touched.
Step 3 — Delete in reverse order
Deleting looks symmetrical and is not. Every deletion shifts the rows below it up by one, so a list of row numbers collected beforehand goes stale the moment you delete the first.
Collect first, delete second, and always descending:
from openpyxl import load_workbook
def delete_rows_where(ws, predicate, min_row=2):
"""Delete every row for which predicate(row_values) is true."""
doomed = [
cell.row
for cell in (r[0] for r in ws.iter_rows(min_row=min_row))
if predicate([c.value for c in ws[cell.row]])
]
# Descending: deleting a later row never moves an earlier one.
for row in sorted(doomed, reverse=True):
ws.delete_rows(row)
return len(doomed)
wb = load_workbook("sales.xlsx")
ws = wb.active
removed = delete_rows_where(ws, lambda values: values[1] in (None, 0))
print(f"removed {removed} rows")
wb.save("sales_clean.xlsx")
Never delete while iterating. The iterator holds positions that the deletion invalidates, and you get skipped rows or an exception with no clear cause.
Deleting contiguous blocks is both faster and simpler — one call instead of many:
ws.delete_rows(10, amount=25) # rows 10 through 34 in one operation
ws.delete_cols(4, amount=3) # columns D, E and F
Step 4 — Repair what did not move
Merged ranges, conditional formatting, data validation and chart references are stored as coordinate strings, and openpyxl does not consistently rewrite all of them when the sheet shifts. Check afterwards:
from openpyxl import load_workbook
wb = load_workbook("sales_expanded.xlsx")
ws = wb.active
print("merged:", [str(r) for r in ws.merged_cells.ranges])
print("validations:", [str(dv.sqref) for dv in ws.data_validations.dataValidation])
print("conditional:", list(ws.conditional_formatting))
The pragmatic pattern for a sheet with several of these is to strip and re-apply rather than trying to patch coordinates. Re-adding a conditional format over the new range is a few lines and is guaranteed correct, whereas patching a range string is guesswork:
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import PatternFill
ws.conditional_formatting = type(ws.conditional_formatting)() # clear
last = ws.max_row - 1
ws.conditional_formatting.add(
f"B2:B{last}",
CellIsRule(operator="lessThan", formula=["100"],
fill=PatternFill("solid", bgColor="FEE8F2")),
)
The same reasoning applies to the rules described in applying conditional formatting to a range with openpyxl.
Common pitfalls and fixes
| Symptom | Cause | Fix |
|---|---|---|
| Total excludes the new row | Formula text not rewritten | Rewrite the formula, or use a named range. |
| Wrong rows deleted | Deleted in ascending order | Sort the row numbers descending. |
| Iterator skips rows or raises | Deleting during iteration | Collect first, delete in a second pass. |
| Inserted row has no formatting | openpyxl does not inherit styles | Copy _style from a neighbouring row. |
| Merged range now covers the wrong cells | Merges stored as coordinates | Re-apply merges after the change. |
| Conditional formatting stops at the old last row | Range string unchanged | Clear and re-add the rule. |
| Chart plots the wrong series | Chart references are absolute | Rebuild the chart after restructuring. |
| Very slow with many inserts | Every insert shifts all cells below | Rebuild the sheet instead. |
Performance and scale notes
Each insert or delete shifts every cell below the point of change. That makes a single call cheap and a loop of calls quadratic — a thousand individual inserts on a fifty-thousand-row sheet moves cells fifty million times.
import time
from openpyxl import Workbook
wb = Workbook(); ws = wb.active
for i in range(20_000):
ws.append([f"row {i}", i])
start = time.perf_counter()
for _ in range(200):
ws.insert_rows(2)
print(f"200 inserts: {time.perf_counter() - start:.2f}s")
Three rules follow.
Batch with amount=. One insert_rows(2, amount=200) does the shifting once; two hundred separate calls do it two hundred times.
Rebuild rather than restructure when the change is substantial. Writing the rows you want into a fresh sheet is linear, and it sidesteps every formula, merge and conditional-formatting problem in this guide:
import pandas as pd
# Instead of deleting a thousand rows one by one:
df = pd.read_excel("sales.xlsx")
df = df[df["units"] > 0]
df.to_excel("sales_clean.xlsx", index=False, engine="xlsxwriter")
For most cleaning tasks that is not just faster but simpler — the row filtering belongs in pandas, and the workbook is written once at the end. Reach for insert_rows and delete_rows when you must preserve an existing sheet's formatting and structure, as when filling a template; otherwise let writing a DataFrame to Excel produce the shape you want directly.
Do the structural work before the styling. Inserting rows into a heavily styled sheet moves style references as well as values, so a script that styles first and restructures afterwards does the expensive work twice. Get the shape right, then format.
Conclusion
insert_rows, insert_cols, delete_rows and delete_cols move cells and nothing else. Formulas keep their original text, so rewrite them — or reference a named range that you update once. Delete in descending order, always, and collect the row numbers in a separate pass from the deletion. Check merged ranges, conditional formatting and validations afterwards, and re-apply rather than patch. And when the change is more than a few operations, rebuilding the sheet from the data you want is both faster and free of every trap above.
Frequently asked questions
Do formulas update when I insert a row with openpyxl?
No. openpyxl moves cell values and styles but does not rewrite formula text, so a SUM over B2:B10 still says B2:B10 after a row is inserted in the middle. Excel would adjust it; openpyxl does not.
Why did my deletion remove the wrong rows? You deleted in ascending order. Every deletion shifts the rows below up by one, so the indexes you collected before starting no longer point at the same rows. Delete from the bottom upwards instead.
Do merged cells, charts and conditional formatting move with the rows? Not reliably. Merged ranges, chart references, data validation ranges and conditional formatting ranges are stored as coordinates and are not all recalculated on insert or delete. Check and re-apply them afterwards.
Is it faster to rebuild the sheet than to insert rows? Almost always, for anything beyond a handful of operations. Each insert or delete shifts every cell below the point of change, so a loop of a thousand inserts is far slower than writing a new sheet from the desired data.
How do I delete every row matching a condition? Collect the matching row numbers in one pass, then delete them in reverse order in a second pass. Never delete while iterating — the iterator and the sheet fall out of step.
Related
- Up to the parent: Using openpyxl for Excel File Manipulation — the wider toolkit.
- Iterate over Rows and Columns with openpyxl — the read pass that feeds the deletion list.
- Create a Named Range in Excel with openpyxl — formulas that survive restructuring.
- Remove Blank Rows from Excel with pandas — the rebuild-instead approach.
- Rename, Reorder and Delete Excel Sheets with openpyxl — the same operations at sheet level.