Guide
Getting Started With Python Excel AutomationDeep dive

Working with Excel Formulas in Python

Write, generate, read back and freeze Excel formulas from Python with openpyxl — including cached values, absolute references, named ranges, and when to calculate in pandas instead.

A spreadsheet that only carries numbers is a report; a spreadsheet that carries formulas is a model someone can prod. Python can produce either. openpyxl writes formulas as plain strings and reads them back the same way — what it never does is calculate them, and that single fact explains most of the surprises in this area. This guide, part of Getting Started with Python Excel Automation, covers the full round trip: writing a formula, generating references that survive a growing dataset, reading results back, naming ranges, and freezing formulas into values before a file leaves your hands.

The formula round trip between Python and Excel Python writes a formula string into a cell and saves the file. openpyxl stores the text only. When Excel opens the workbook it evaluates the formula and stores a cached result alongside it on save. Reading with data_only=True returns that cached result, which is None until a calculating application has saved the file. Python ws["D2"] = "=B2*C2" writes text, never a number report.xlsx formula: =B2*C2 cached value: none yet Excel opens it evaluates the formula caches 249.90 on save load_workbook(path, data_only=True) reads the cached result — only after a calculating app has saved re-open in Python: formula text comes back

Write your first formula

A formula is just a string that starts with =. Assign it like any other value and save:

Python
from openpyxl import Workbook

wb = Workbook()
ws = wb.active
ws.title = "Orders"

ws.append(["SKU", "Quantity", "Unit_Price", "Line_Total"])
ws.append(["A-100", 4, 19.99, "=B2*C2"])
ws.append(["B-200", 2, 49.50, "=B3*C3"])
ws.append(["C-300", 7, 12.25, "=B4*C4"])

ws["C6"] = "Order total"
ws["D6"] = "=SUM(D2:D4)"

wb.save("orders.xlsx")
print(ws["D2"].value)      # '=B2*C2' — the text, not 79.96

The print is the important line. Python holds '=B2*C2', not 79.96, because nothing has calculated anything. Open orders.xlsx in Excel or LibreOffice and the numbers appear immediately; the file on disk is complete and correct, it simply carries instructions rather than results.

That is also why a workbook full of formulas can look empty to a downstream script. If another job reads this file with pandas read_excel, the Line_Total column arrives as NaN until someone has opened and saved the workbook in a spreadsheet application.

Generate references that follow your data

Hardcoded row numbers survive exactly one dataset. Build them from the row you are writing, and let get_column_letter translate column indexes so a re-ordered layout does not silently point at the wrong column:

Python
from openpyxl import Workbook
from openpyxl.utils import get_column_letter

rows = [
    ("A-100", 4, 19.99),
    ("B-200", 2, 49.50),
    ("C-300", 7, 12.25),
    ("D-400", 5, 8.75),
]

wb = Workbook()
ws = wb.active
ws.append(["SKU", "Quantity", "Unit_Price", "Line_Total", "Share"])

qty_col = get_column_letter(2)      # 'B'
price_col = get_column_letter(3)    # 'C'
total_col = get_column_letter(4)    # 'D'

for sku, qty, price in rows:
    ws.append([sku, qty, price])
    r = ws.max_row                                  # the row just written
    ws[f"{total_col}{r}"] = f"={qty_col}{r}*{price_col}{r}"

last = ws.max_row
total_row = last + 2
ws[f"C{total_row}"] = "Order total"
ws[f"{total_col}{total_row}"] = f"=SUM({total_col}2:{total_col}{last})"

# Each line's share of the order — the divisor must not move, so anchor it
for r in range(2, last + 1):
    ws[f"E{r}"] = f"={total_col}{r}/$D${total_row}"
    ws[f"E{r}"].number_format = "0.0%"

wb.save("orders_generated.xlsx")

Two habits are doing the work here. ws.max_row gives the row that was just appended, so the formula and its data can never drift apart. And the $D$14-style anchor on the divisor means every Share formula points at the one total cell — without the dollar signs, a reader who sorts or inserts a row gets a column of #DIV/0!.

Which parts of a generated reference move and which are anchored The formula equals D3 divided by dollar D dollar 14 is broken into parts: D3 is relative, so both its column and row shift when the formula is copied; dollar D dollar 14 is absolute, so the column and row stay pinned to the order total cell. =D3 / $D$14 D3 — relative shifts with the row it lives on generated per row from ws.max_row $D$14 — absolute pinned column and row survives sorting and inserted rows

Mixed anchors are legal and useful: $D14 pins the column while letting the row move, and D$14 does the opposite. When you generate a grid of formulas — a cross-tab where every cell divides by its column header — mixed anchors are what let one generated pattern fill the whole block.

Read a formula back: text or cached result

load_workbook has two personalities, and picking the wrong one is the most common formula bug in Python code:

Python
from openpyxl import load_workbook

# Personality 1 — the default: formulas come back as text
wb_text = load_workbook("orders.xlsx")
print(wb_text["Orders"]["D2"].value)      # '=B2*C2'

# Personality 2 — cached results written by the last calculating application
wb_vals = load_workbook("orders.xlsx", data_only=True)
print(wb_vals["Orders"]["D2"].value)      # None if Excel has never saved this file

data_only=True does not calculate. It reads a value Excel stored next to the formula the last time Excel saved the workbook. For a file Python just generated, that slot is empty and you get None — a fact worth asserting loudly rather than discovering in a report:

Python
from openpyxl import load_workbook

wb = load_workbook("orders.xlsx", data_only=True)
ws = wb["Orders"]

missing = [c.coordinate for c in ws["D"] if c.row > 1 and c.value is None]
if missing:
    raise SystemExit(
        f"No cached values in {missing[:5]} — open and save the file in Excel, "
        "or compute the totals in Python instead of writing formulas."
    )

There is also a halfway house. Setting the workbook's recalculation flag asks the reading application to recompute everything on open, which is useful when you have rewritten input cells under formulas that Excel might otherwise consider unchanged:

Python
from openpyxl import load_workbook

wb = load_workbook("orders.xlsx")
wb.calculation.fullCalcOnLoad = True
wb.save("orders.xlsx")
What each load_workbook mode returns for a formula cell A decision path: opening with the default mode returns the formula text and lets you edit the formula. Opening with data_only=True returns the cached number, or None when no calculating application has saved the workbook, and any save from that mode discards the formulas. load_workbook(path, …) default cell.value → "=B2*C2" edit and re-save formulas safely no numbers available use when editing the model data_only=True cell.value → 79.96 or None None until Excel has saved the file saving from here drops the formulas use when harvesting results

The warning in the right-hand box deserves its own sentence: if you load with data_only=True, change one cell and call save(), every formula in the workbook is replaced by whatever cached number was sitting in it. That is occasionally exactly what you want — see freezing values below — and otherwise it is a silent data loss.

Name a range so formulas read like English

A defined name turns 'Rates'!$B$1 into TaxRate. Formulas written from Python become far easier to review, and a reader can find the input cell without hunting:

Python
from openpyxl import Workbook
from openpyxl.workbook.defined_name import DefinedName

wb = Workbook()
rates = wb.active
rates.title = "Rates"
rates["A1"] = "VAT rate"
rates["B1"] = 0.20

orders = wb.create_sheet("Orders")
orders.append(["SKU", "Net", "VAT", "Gross"])
orders.append(["A-100", 79.96])

# Workbook-wide: usable from any sheet
wb.defined_names.add(DefinedName("TaxRate", attr_text="'Rates'!$B$1"))

orders["C2"] = "=B2*TaxRate"
orders["D2"] = "=B2+C2"

wb.save("named.xlsx")
print(list(wb.defined_names))          # ['TaxRate']

Names come in two scopes. A name added to wb.defined_names is visible everywhere; a name added to ws.defined_names belongs to that sheet only, which is how you give five regional tabs their own Target cell without collisions:

Python
from openpyxl import Workbook
from openpyxl.workbook.defined_name import DefinedName

wb = Workbook()
for region in ("North", "South", "West"):
    ws = wb.create_sheet(region)
    ws["A1"] = "Target"
    ws["B1"] = 100000
    ws.defined_names.add(DefinedName("Target", attr_text=f"'{region}'!$B$1"))
    ws["B3"] = "=B2/Target"          # resolves to this sheet's own Target

del wb["Sheet"]
wb.save("scoped_names.xlsx")
Workbook-wide names versus sheet-local names TaxRate is added to the workbook, so formulas on every sheet resolve it to the same Rates cell. Target is added to each worksheet separately, so the North, South and West sheets each resolve Target to their own cell without colliding. wb.defined_names TaxRate → 'Rates'!$B$1 Orders Invoices Summary every sheet resolves the same cell one rate, one place to change it ws.defined_names North Target → 'North'!$B$1 South Target → 'South'!$B$1 West Target → 'West'!$B$1 same name, three different cells no collision between sheets

Names are also the cleanest way to hand a range to a chart or a validation list. When you later add a dropdown from a named list, the formula =Regions is far more robust than ='Lookups'!$A$2:$A$12, because the name can be repointed in one place.

Freeze formulas into values before shipping

Some recipients should never see a formula: a file sent to a client, an archive that must read the same in five years, or a sheet that feeds a system without a calculation engine. Freezing means reading the cached results and writing them back as literals — which requires that the workbook has been opened and saved by Excel or LibreOffice at least once, so that cached values exist:

Python
from openpyxl import load_workbook

src = load_workbook("orders.xlsx", data_only=True)   # cached results
dst = load_workbook("orders.xlsx")                   # formulas + all formatting

for name in src.sheetnames:
    s, d = src[name], dst[name]
    for row in s.iter_rows():
        for cell in row:
            target = d[cell.coordinate]
            if isinstance(target.value, str) and target.value.startswith("="):
                if cell.value is None:
                    raise SystemExit(f"{name}!{cell.coordinate} has no cached value")
                target.value = cell.value            # replace formula with result

dst.save("orders_frozen.xlsx")

Loading the file twice looks wasteful, but it is what keeps the styling. The data_only copy is only a source of numbers; every fill, border and number format is preserved because the file being saved is the ordinary copy. Running the same freeze with a single data_only=True load and a save() also works, and quietly strips nothing else — but it gives you no chance to check for missing cached values first.

Cross-sheet references and the quoting rule

A formula on the Summary tab that reads the Q1 Sales tab needs the sheet name in front of the reference, and that name must be wrapped in single quotes whenever it contains a space, a hyphen or anything other than letters, digits and underscores. Getting this wrong produces a #REF! that only shows up when someone opens the file, so it is worth a helper:

Python
import re
from openpyxl import Workbook

def sheet_ref(sheet_name, ref):
    """Return a formula-safe reference like 'Q1 Sales'!$B$2 or Summary!$B$2."""
    safe = re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", sheet_name)
    quoted = sheet_name if safe else "'{}'".format(sheet_name.replace("'", "''"))
    return f"{quoted}!{ref}"

wb = Workbook()
q1 = wb.active
q1.title = "Q1 Sales"
q1.append(["Region", "Revenue"])
for region, revenue in (("North", 26700), ("South", 20900), ("West", 7600)):
    q1.append([region, revenue])

summary = wb.create_sheet("Summary")
summary["A1"] = "Q1 revenue"
summary["B1"] = f"=SUM({sheet_ref('Q1 Sales', '$B$2:$B$4')})"
summary["A2"] = "Best region"
summary["B2"] = (
    f"=INDEX({sheet_ref('Q1 Sales', '$A$2:$A$4')},"
    f"MATCH(MAX({sheet_ref('Q1 Sales', '$B$2:$B$4')}),"
    f"{sheet_ref('Q1 Sales', '$B$2:$B$4')},0))"
)

wb.save("cross_sheet.xlsx")
print(summary["B1"].value)

Two details save time later. Sheet names that a user might rename are exactly the ones worth replacing with a defined name, because a rename updates the name's target but not a hardcoded string you generated. And a sheet name containing an apostrophe needs that apostrophe doubled inside the quotes — rare, but it happens with names like O'Brien Region, and the helper above handles it.

Writing lookup formulas from Python

Lookups are where generated formulas earn their keep: the reader can add a row to the price list and every line recalculates. VLOOKUP remains the most portable choice, while XLOOKUP is cleaner but only resolves in recent Excel versions:

Python
from openpyxl import Workbook

wb = Workbook()

prices = wb.create_sheet("Prices")
prices.append(["SKU", "Unit_Price"])
for sku, price in (("A-100", 19.99), ("B-200", 49.50), ("C-300", 12.25)):
    prices.append([sku, price])
last_price_row = prices.max_row

orders = wb.create_sheet("Orders")
orders.append(["SKU", "Quantity", "Unit_Price", "Line_Total"])
for sku, qty in (("A-100", 4), ("C-300", 7), ("Z-999", 2)):
    orders.append([sku, qty])
    r = orders.max_row
    lookup_range = f"Prices!$A$2:$B${last_price_row}"
    # IFERROR keeps an unknown SKU from poisoning the totals below
    orders[f"C{r}"] = f'=IFERROR(VLOOKUP(A{r},{lookup_range},2,FALSE),"unpriced")'
    orders[f"D{r}"] = f'=IF(ISNUMBER(C{r}),B{r}*C{r},0)'

del wb["Sheet"]
wb.save("lookups.xlsx")

The FALSE fourth argument forces an exact match, which is almost always what a SKU lookup wants — the default of TRUE silently returns the nearest lower match on unsorted data, producing wrong prices rather than an error. Wrapping the call in IFERROR turns a missing SKU into a readable label instead of #N/A, and the ISNUMBER guard on the line total stops one unpriced row from breaking the grand total underneath.

Formula or pandas? A quick decision

SituationBetter choiceWhy
Reader changes an input and expects totals to updateExcel formulaThe model has to live in the file
The number must be identical for every recipientCompute in pandasNo dependence on a calculation engine
Output is also exported to CSVCompute in pandasCSV keeps values, not formulas
Aggregations across 200k rowsCompute in pandasThousands of SUMIFS calls make a workbook crawl
Audit trail showing how a figure was derivedExcel formulaThe derivation is visible in the cell
A chart you generate from Python needs the numbersCompute in pandasChart series read values, not uncalculated formulas

The mixed approach is usually the best one for recurring reports: compute the figures in pandas so the numbers are fixed and correct, then add a handful of formulas — a grand total, a variance against target — where you want the reader to be able to play.

Common errors and fixes

SymptomCauseFix
Cell shows =B2*C2 as text in ExcelThe cell's number format is TextSet cell.number_format = "General" before assigning
None from data_only=TrueNo calculating application has saved the fileOpen and save in Excel, or compute the value in Python
Formulas vanished after a script ranWorkbook was loaded with data_only=True and savedLoad without data_only when you intend to keep formulas
#REF! after rows were insertedReference was built with hardcoded row numbersGenerate references from ws.max_row, anchor fixed cells with $
#DIV/0! down a whole columnThe divisor moved with each rowAnchor it: $D$14, not D14
#NAME? in ExcelThe defined name was never added, or is misspelledCheck list(wb.defined_names) and the exact spelling in the formula
Formula works in Excel but breaks in LibreOfficeA function specific to one applicationStick to the common function set, or compute in pandas

Audit a delivered workbook for broken formulas

Once a file has been round-tripped through Excel, the cached values tell you whether the model still works. Excel stores error results as strings such as #REF! and #DIV/0!, so a short scan catches a broken report before a reader does:

Python
from openpyxl import load_workbook

ERRORS = {"#REF!", "#DIV/0!", "#VALUE!", "#N/A", "#NAME?", "#NULL!", "#NUM!"}

wb = load_workbook("orders_frozen.xlsx", data_only=True)
broken = [
    f"{ws.title}!{cell.coordinate} = {cell.value}"
    for ws in wb.worksheets
    for row in ws.iter_rows()
    for cell in row
    if isinstance(cell.value, str) and cell.value in ERRORS
]

if broken:
    print("Formula errors found:")
    print("\n".join(broken[:20]))
else:
    print("No formula errors cached in this workbook.")

Run this as the last step of a report job and the failure lands in your logs rather than in someone's inbox. It pairs naturally with the wider checks in validating Excel data with Python, which cover the input side of the same problem.

Key takeaways

  • openpyxl stores formulas as text and never calculates them; numbers appear when a spreadsheet application opens the file.
  • data_only=True returns cached results, not fresh calculations — and returns None for a file Python just created.
  • Saving a workbook that was loaded with data_only=True permanently replaces its formulas with numbers.
  • Generate references from the row you are writing, and anchor anything that must not move with $.
  • Defined names make generated formulas readable, and sheet-local names let repeated tabs reuse one name safely.
  • When the number matters more than the model, compute it in pandas and write a literal.

Frequently asked questions

Does openpyxl calculate my formulas? No. openpyxl writes and reads the formula text; it has no calculation engine. The numbers appear when Excel, LibreOffice or another spreadsheet application opens the file and evaluates it.

Why does a formula cell read back as None with data_only=True?data_only=True returns the value Excel cached the last time it saved the file. A workbook that Python just created has never been opened by a calculating application, so there is no cached result yet.

Should I write a formula or compute the number in pandas? Write a formula when the reader is expected to change inputs and see the result update. Compute in pandas when the number must be identical for everyone, must survive a CSV export, or feeds a chart you are generating.

How do I stop my generated formula breaking when rows shift? Anchor the parts that must not move with dollar signs, for example $B$1 for a single rate cell, and build the moving parts from the row number you are on rather than hardcoding them.

Can I use named ranges written by Python inside formulas? Yes. Add a DefinedName to wb.defined_names for a workbook-wide name or to ws.defined_names for a sheet-local one, then reference the name in any formula string you write.

Why does Excel show my formula as text instead of a result? The cell was written while its number format was Text, or the string does not start with an equals sign. Set the format to General before assigning, and always begin the string with =.

Up to the parent guide:

Go deeper here:

Related areas: