Reach for it when the request includes any of: "add a column that sums/looks-up", "format these cells", "highlight values over X", "build a summary sheet that pulls from the detail tabs", "make a chart", "turn this CSV into a real Excel model".
-
Install + import. pip install openpyxl if missing. Then:
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.formatting.rule import ColorScaleRule, CellIsRule, FormulaRule
from openpyxl.chart import BarChart, LineChart, PieChart, ScatterChart, Reference, Series
from openpyxl.utils import get_column_letter
New file → wb = Workbook(). Editing an existing file → wb = load_workbook("in.xlsx") (add data_only=False to keep formulas as formulas, not last-cached values).
-
Lay out sheets and headers. ws = wb.active; ws.title = "Detail"; add more with wb.create_sheet("Summary"). Write headers, then write typed cell values — pass real int/float/datetime, not strings, or Excel treats numbers as text and SUM returns 0. Set cell.number_format = "yyyy-mm-dd" for dates.
-
Write formulas as strings, never precompute. Store the formula so Excel computes it:
ws["D2"] = "=B2*C2"
ws["E2"] = "=SUM(D2:D100)"
ws["F2"] = '=VLOOKUP(A2,Detail!$A$2:$C$100,3,FALSE)'
A leading = is what makes it a formula. openpyxl does not evaluate it — the cell has no value until Excel/LibreOffice opens and recalcs the file.
-
Cross-sheet refs and named ranges. Reference another sheet with SheetName!A1; quote sheet names containing spaces: 'Q1 Detail'!A1. For reusable ranges:
from openpyxl.workbook.defined_name import DefinedName
wb.defined_names.add(DefinedName("tax_rate", attr_text="Assumptions!$B$1"))
ws["C2"] = "=B2*tax_rate"
-
Formatting. Apply per cell (styles do not cascade from columns/rows):
ws["A1"].font = Font(bold=True, color="FFFFFF")
ws["A1"].fill = PatternFill("solid", fgColor="305496")
ws["B2"].number_format = '#,##0.00'
ws.column_dimensions["A"].width = 22
ws.freeze_panes = "A2"
-
Conditional formatting is bound to a range and re-evaluates live in Excel:
ws.conditional_formatting.add("D2:D100",
ColorScaleRule(start_type="min", start_color="F8696B",
end_type="max", end_color="63BE7B"))
ws.conditional_formatting.add("E2:E100",
CellIsRule(operator="greaterThan", formula=["1000"],
fill=PatternFill("solid", fgColor="FFC7CE")))
-
Pivot-style summary — don't try to write a real PivotTable (openpyxl support is fragile). Instead build a summary sheet of unique keys + SUMIF/COUNTIF/AVERAGEIF against the detail sheet, so totals stay live:
ws_sum["B2"] = '=SUMIF(Detail!$A:$A,A2,Detail!$D:$D)'
-
Native charts bound to Reference ranges (these recalc/redraw in Excel, unlike pasted images):
chart = BarChart(); chart.title = "Sales by Region"; chart.type = "col"
data = Reference(ws, min_col=2, min_row=1, max_col=2, max_row=10)
cats = Reference(ws, min_col=1, min_row=2, max_row=10)
chart.add_data(data, titles_from_data=True)
chart.set_categories(cats)
ws.add_chart(chart, "H2")
Swap BarChart→LineChart/PieChart/ScatterChart as needed. Scatter needs Series(yvalues, xvalues) explicitly.
-
Save with wb.save("out.xlsx"). Pick a clear, descriptive filename.
After saving, reopen and assert structure programmatically — never assume the write succeeded:
Then do a real recalc check: open the file once in Excel or headless LibreOffice (libreoffice --headless --convert-to xlsx out.xlsx) and confirm formula cells show numbers, not 0/#REF!/text. If totals are 0, the inputs were strings — go back to step 2.