openpyxl: Automate Excel Files with Python (2026)
openpyxl: Automate Excel Files with Python (2026) is a hands-on guide to the library behind almost every "automate Excel with Python" script. You will build a real sales report from code: styled headers, number formats, live formulas, a totals row, frozen panes, a filter, a summary sheet with conditional formatting and a bar chart. Then you will read it back, hit the most common openpyxl surprise (formulas that come back as None), fix it, and process 200,000 rows without running out of memory. Every output below comes from a real run.
Excel reports are one of the easiest wins in Python automation. If your data starts life as CSV, see reading a DataFrame from CSV files with pandas first, and when the report has to go out every Monday, plug the script into a scheduler as shown in APScheduler vs Prefect for scheduling Python automation.
TL;DR
pip install openpyxl. It reads and writes modern.xlsx/.xlsmfiles with no Excel installation needed.Workbook()creates a file,ws.append(row)adds rows,ws["E2"] = "=C2*D2"writes a formula,wb.save("file.xlsx")writes it.- Style with
Font,PatternFill,Alignmentandcell.number_format = "#,##0.00"; addfreeze_panes,auto_filter, conditional formatting and charts. - openpyxl never calculates formulas.
load_workbook(data_only=True)returnsNoneuntil Excel or LibreOffice has recalculated and saved the file. Our run proves it. - Use
write_only=True/read_only=Truefor big files: 200,000 rows written in about 2.9 s and summed in about 2.1 s here. - Tested with openpyxl
3.1.5, pandas3.0.6, Python3.13.5, LibreOffice25.2(for recalculation only).
openpyxl, pandas or XlsxWriter?
| Tool | Best for | Reads .xlsx? | Edits existing files? |
|---|---|---|---|
openpyxl | Formatting, formulas, charts, editing templates, cell-level control | Yes | Yes |
pandas | Tables in and out: read_excel(), to_excel() (uses openpyxl under the hood for .xlsx) | Yes | Adds or replaces whole sheets |
XlsxWriter | Fast, feature-rich new files | No | No |
Rule of thumb: use pandas when you think in tables, and openpyxl when you think in cells, styles and sheets. They work well together, as section 4 shows.
Versions tested (2026-10-07)
- Python
3.13.5 openpyxl3.1.5(current stable release on PyPI; requires Python 3.8 or newer)pandas3.0.6(only for section 4)- LibreOffice
25.2.3headless (only to recalculate formulas in section 2)
python -m venv .venv && source .venv/bin/activate
pip install openpyxl==3.1.5 pandas
python excel_report.py
python add_summary.py
python pandas_and_big.py
1. Build a styled Excel report with formulas
We start from plain Python tuples, the kind of data you get from a database query, an API or a CSV file. Each data row gets a live Revenue formula, and a final row sums the columns. Then we style the header, format the money columns, set column widths, freeze the header row and switch on Excel's filter buttons.
import sys
import openpyxl
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
print(f"# openpyxl {openpyxl.__version__} · Python {sys.version.split()[0]}")
# 1) Write a styled sales sheet with formulas
rows = [
("North", "Laptop", 12, 950.0),
("North", "Monitor", 30, 210.0),
("South", "Laptop", 8, 990.0),
("South", "Keyboard", 55, 35.5),
("East", "Monitor", 18, 205.0),
("East", "Keyboard", 40, 32.0),
]
wb = Workbook()
ws = wb.active
ws.title = "Sales"
ws.append(["Region", "Product", "Units", "Price", "Revenue"])
for r, row in enumerate(rows, start=2):
ws.append([*row, f"=C{r}*D{r}"])
last = ws.max_row
ws.append(["Total", None, f"=SUM(C2:C{last})", None, f"=SUM(E2:E{last})"])
header = Font(bold=True, color="FFFFFF")
fill = PatternFill("solid", fgColor="4F46E5")
for cell in ws[1]:
cell.font, cell.fill = header, fill
cell.alignment = Alignment(horizontal="center")
for row in ws.iter_rows(min_row=2, min_col=4, max_col=5):
for cell in row:
cell.number_format = "#,##0.00"
for cell in ws[ws.max_row]:
cell.font = Font(bold=True)
for col, width in zip("ABCDE", (10, 12, 8, 10, 12)):
ws.column_dimensions[col].width = width
ws.freeze_panes = "A2"
ws.auto_filter.ref = f"A1:E{last}"
wb.save("sales_report.xlsx")
print("saved sales_report.xlsx:", ws.dimensions, "| data rows:", last - 1)
$ python excel_report.py
# openpyxl 3.1.5 · Python 3.13.5
saved sales_report.xlsx: A1:E8 | data rows: 6
ws.dimensions confirms the used range is A1:E8: one header row, six data rows and the totals row. When we opened the real file in LibreOffice Calc, the Sales sheet showed a blue bold header, two-decimal money columns (for example 11,400.00 in E2), and a bold Total row with 163 units and 32,542.50 revenue.
2. Read it back: formulas vs values (the classic gotcha)
Now read the file we just created. By default you get exactly what is stored in each cell, so a formula cell returns the formula text. With data_only=True you get the cached value instead, and here is the surprise:
# 2) Read it back: formulas vs cached values
wb = load_workbook("sales_report.xlsx")
print("E2 formula:", wb["Sales"]["E2"].value)
wb = load_workbook("sales_report.xlsx", data_only=True)
print("E2 data_only:", wb["Sales"]["E2"].value)
E2 formula: =C2*D2
E2 data_only: None
The value is None because openpyxl writes formulas but never calculates them. The result only exists after a spreadsheet engine opens the file, recalculates and saves it. On a server you can do that with headless LibreOffice, then read the cached numbers:
from openpyxl import load_workbook
# 2b) After Excel or LibreOffice recalculates and saves the file,
# data_only=True returns the cached numbers
wb = load_workbook("recalc/sales_report.xlsx", data_only=True)
ws = wb["Sales"]
print("E2 data_only:", ws["E2"].value)
print("Total units:", ws["C8"].value, "| Total revenue:", ws["E8"].value)
$ soffice --headless --convert-to xlsx --outdir recalc sales_report.xlsx
$ python recalc_check.py
E2 data_only: 11400
Total units: 163 | Total revenue: 32542.5
After recalculation, E2 is 11400 (12 laptops at 950.00), and the totals row reads 163 units and 32542.5 revenue. If you cannot run Excel or LibreOffice in your pipeline, simply calculate the numbers in Python and write values instead of formulas, which is exactly what the next section does.
3. Add a summary sheet, conditional formatting and a chart
iter_rows(values_only=True) is the fastest way to loop over data as plain tuples. We total revenue per region in Python, write a Summary sheet, highlight any region above 10,000 with a green fill, and add a bar chart that points at the sheet's cells.
from collections import defaultdict
from openpyxl import load_workbook
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import Font, PatternFill
from openpyxl.chart import BarChart, Reference
# 3) Compute in Python, add a summary sheet, highlight + chart
wb = load_workbook("sales_report.xlsx")
ws = wb["Sales"]
totals = defaultdict(float)
for region, _, units, price, _ in ws.iter_rows(
min_row=2, max_row=ws.max_row - 1, values_only=True):
totals[region] += units * price
sm = wb.create_sheet("Summary")
sm.append(["Region", "Revenue"])
for region, revenue in sorted(totals.items()):
sm.append([region, revenue])
print(f"{region:<6} {revenue:>10,.2f}")
for cell in sm[1]:
cell.font = Font(bold=True)
for (cell,) in sm.iter_rows(min_row=2, min_col=2):
cell.number_format = "#,##0.00"
sm.column_dimensions["B"].width = 12
green = PatternFill("solid", fgColor="BBF7D0")
sm.conditional_formatting.add(
f"B2:B{sm.max_row}",
CellIsRule(operator="greaterThan", formula=["10000"], fill=green))
chart = BarChart()
chart.title, chart.y_axis.title = "Revenue by region", "USD"
chart.width, chart.height, chart.legend = 10, 7, None
data = Reference(sm, min_col=2, min_row=1, max_row=sm.max_row)
cats = Reference(sm, min_col=1, min_row=2, max_row=sm.max_row)
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
sm.add_chart(chart, "D2")
wb.save("sales_report.xlsx")
print("sheets:", wb.sheetnames, "| charts on Summary:", len(sm._charts))
$ python add_summary.py
East 4,970.00
North 17,700.00
South 9,872.50
sheets: ['Sales', 'Summary'] | charts on Summary: 1
North (17,700.00) is the only region above the 10,000 threshold, so it is the only cell that turns green. Because the chart is built from Reference objects, it reads its numbers from the cells, so if someone edits a value in Excel, the bars update automatically. chart.legend = None removes the legend for a single series, and width / height are in centimetres.
4. Use pandas with openpyxl
pandas uses openpyxl as its engine for .xlsx files. read_excel() turns a sheet into a DataFrame, and ExcelWriter(mode="a", if_sheet_exists="replace") adds a sheet to an existing workbook without touching the others.
import pandas as pd
from openpyxl import Workbook, load_workbook
# 4) pandas reads and writes Excel through openpyxl
df = pd.read_excel("sales_report.xlsx", sheet_name="Sales", nrows=6)
top = (df.groupby("Product", as_index=False)["Units"].sum()
.sort_values("Units", ascending=False))
print(top.to_string(index=False))
with pd.ExcelWriter("sales_report.xlsx", engine="openpyxl",
mode="a", if_sheet_exists="replace") as xw:
top.to_excel(xw, sheet_name="Top products", index=False)
print("sheets:", load_workbook("sales_report.xlsx").sheetnames)
$ python pandas_and_big.py
Product Units
Keyboard 95
Monitor 48
Laptop 20
sheets: ['Sales', 'Summary', 'Top products']
nrows=6 skips our totals row so it is not counted as a product. The Top products sheet was added, and the existing Sales and Summary sheets (including the chart) were kept; we confirmed the chart is still there after reloading the file. One caution: pandas reads formula cells through openpyxl too, so the Revenue column here would be empty (NaN) until the file has been recalculated, exactly as in section 2.
5. Large files: write_only and read_only modes
A normal Workbook keeps every cell object in memory. For exports with hundreds of thousands of rows, switch to streaming modes: write_only=True appends rows straight to disk, and read_only=True reads rows lazily.
import time
from openpyxl import Workbook, load_workbook
# 5) Big files: write_only and read_only modes
t = time.perf_counter()
wb = Workbook(write_only=True)
ws = wb.create_sheet("log")
ws.append(["id", "value"])
for i in range(200_000):
ws.append([i, i * 0.5])
wb.save("big.xlsx")
print(f"write_only 200,000 rows: {time.perf_counter() - t:.2f}s")
t = time.perf_counter()
wb = load_workbook("big.xlsx", read_only=True)
total = sum(v for _, v in wb["log"].iter_rows(min_row=2, values_only=True))
wb.close()
print(f"read_only sum={total:,.1f}: {time.perf_counter() - t:.2f}s")
write_only 200,000 rows: 2.91s
read_only sum=9,999,950,000.0: 2.05s
Write-only mode wrote 200,000 rows in under three seconds on our test box, and read-only mode streamed them back and summed the column in about two seconds. The trade-offs: in write-only mode you can only append() (no random cell access), and in read-only mode you should call wb.close() when you are done so the file handle is released.
Common mistakes
- Expecting formula results. openpyxl stores formulas, it does not evaluate them. Use
data_only=Trueonly on files saved by Excel or LibreOffice, or compute values in Python. - Opening with
data_only=Trueand then saving. That replaces every formula with its cached value (or nothing). Keep one workbook object for reading values and another for editing. - Trying to open old
.xlsfiles. openpyxl raisesInvalidFileException: openpyxl does not support the old .xls file format. Convert to.xlsxfirst (for example withsoffice --headless --convert-to xlsx). - Losing macros. For
.xlsmfiles, load withkeep_vba=Trueand save with the same extension. - Editing a file that is open in Excel. On Windows the save fails with a permission error. Close the file first, or write to a new file name.
- Styling cells one by one in huge sheets. Style the header and use number formats per column; for very large styled exports, consider write-only mode or XlsxWriter.
A simple Excel automation workflow
- Pull data from a database, API or CSV with Python or pandas.
- Write a styled sheet with openpyxl (header styles, number formats, freeze panes, filters).
- Calculate totals in Python, or recalculate formulas with headless LibreOffice.
- Add a summary sheet with conditional formatting and a chart.
- Schedule the script and email or upload the file automatically.
FAQ
Do I need Microsoft Excel installed? No. openpyxl reads and writes the .xlsx format directly, so it works on Linux servers, Docker containers and CI. You only need a spreadsheet engine if you want formula results calculated.
Why does my formula cell return None? You opened it with data_only=True and the file was never recalculated by Excel or LibreOffice. See section 2.
openpyxl or pandas? Use pandas for moving tables in and out, openpyxl for formatting, formulas, charts and editing templates. Many reports use both.
Is openpyxl fast enough for big files? Yes, with write_only=True and read_only=True. In our run, 200,000 rows took about 3 seconds to write and about 2 seconds to read.