How-to
Formatting And Charting Excel Reports With PythonDeep dive

Write an Excel Table with Formula Columns Using XlsxWriter

Write a pandas DataFrame as a real Excel table with XlsxWriter, including calculated columns that use structured references, a totals row, column formats and a table style: get the range right, choose between the [@Column] shorthand and fully qualified references that also work outside Excel, add custom totals, and verify the formulas by recalculating.

An Excel table — the banded, filterable range created with Insert › Table — is more than formatting. Its columns have names that formulas can use (Orders[Net]), a calculated column fills its formula into every row and into rows added later, and a totals row can sum, count or average whatever is currently filtered. XlsxWriter writes tables with all of that from Python, which makes generated reports easier for people to extend: they type a new order under the table and the net amount calculates itself. The details that trip people up are the table's range, which must include the header and totals rows, and the form of the structured references, because one form works in Excel only while another works in every spreadsheet application. This page writes a DataFrame as a table with formula columns and totals, compares the two reference styles, and checks the results by recalculating the file. It belongs to Building Excel Reports with xlsxwriter.

Prerequisites

Bash
pip install xlsxwriter pandas openpyxl

LibreOffice (soffice) is used in the last step to recalculate the workbook, and openpyxl to read the results.

Step 1 — A DataFrame as a table, with a formula column

add_table() takes the range, the data as a list of rows, and a list of column definitions. A column with a formula becomes a calculated column:

Python
import pandas as pd
import xlsxwriter

orders = pd.DataFrame({"Product": ["Bolts", "Nuts", "Washers", "Rivets"], "Qty": [120, 80, 300, 45],
                       "Price": [0.45, 0.12, 0.05, 1.80], "Discount": [0.10, 0.0, 0.05, 0.15]})

wb = xlsxwriter.Workbook("orders_table.xlsx")
ws = wb.add_worksheet("Orders")
money = wb.add_format({"num_format": "#,##0.00"})
pct = wb.add_format({"num_format": "0%"})
columns = [
    {"header": "Product", "total_string": "Total"},
    {"header": "Qty", "total_function": "sum"},
    {"header": "Price", "format": money},
    {"header": "Discount", "format": pct},
    {"header": "Net", "format": money, "total_function": "sum",
     "formula": "=[@Qty]*[@Price]*(1-[@Discount])"},
]
last_row = len(orders) + 1                       # header + data rows + totals row, zero-based
ws.add_table(0, 0, last_row, len(columns) - 1, {"name": "Orders", "data": orders.values.tolist(),
             "columns": columns, "total_row": True, "style": "Table Style Medium 2"})
ws.set_column(0, 0, 12)
ws.set_column(1, 4, 11)
wb.close()
print(f"table Orders: A1:E{last_row + 1} ({len(orders)} data rows, header and totals)")

The range passed to add_table() must cover the header row, every data row and the totals row: with four orders that is rows 0 to 5, zero-based, or A1:E6. Getting it one row short is the common mistake — the last data row silently falls outside the table, or worse, ends up as the totals row. The DataFrame contributes only its values (orders.values.tolist()); the headers come from the column definitions, which is also where per-column formats live. The Net column has no data, only a formula, written with Excel's [@Column] "this row" shorthand. Table names follow the rules for defined names — letters, digits and underscores, starting with a letter, unique in the workbook — so Orders works but Q3 orders does not; choosing a meaningful name pays off every time someone writes a formula against the table.

The table as written A header row, four orders with product, quantity, price and discount, a calculated Net column filled with the same structured-reference formula in every row, and a totals row with the label Total and sums under Qty and Net. A B C D E 1 Product Qty Price Discount Net 2 Bolts 120 0.45 10% =[@Qty]*… 3 Nuts 80 0.12 0% =[@Qty]*… 4 Washers 300 0.05 5% =[@Qty]*… 5 Rivets 45 1.80 15% =[@Qty]*… 6 Total SUBTOTAL SUBTOTAL

Step 2 — What actually ends up in the file

Python
import zipfile

with zipfile.ZipFile("orders_table.xlsx") as z:
    sheet = z.read("xl/worksheets/sheet1.xml").decode()
    table = z.read("xl/tables/table1.xml").decode()
cell = sheet[sheet.find('<c r="E2"'):]
print("E2 formula:", cell[cell.find("<f>") + 3:cell.find("</f>")])
total = sheet[sheet.find('<c r="E6"'):]
print("E6 formula:", total[total.find("<f>") + 3:total.find("</f>")])
print("calculated column stored:", "<calculatedColumnFormula>" in table)

XlsxWriter translates [@Qty] into the form Excel 2010 and later save, [[#This Row],Qty] — a reference to the current row without the table name, which Excel resolves from the table the cell is in. The built-in sum total becomes SUBTOTAL(109,[Net]); function 109 sums only visible rows, so the total follows the table's filters. The formula is also recorded as the column's calculatedColumnFormula, which is what makes Excel fill it into new rows typed under the table. XlsxWriter cannot calculate formulas, so it stores 0 as each formula's last result; Excel recalculates on opening, but anything that reads cached values — pandas, openpyxl with data_only=True — sees zeros until the file has been opened and saved in a spreadsheet application. The options for that are covered in Store Formula Results with xlsxwriter.

Step 3 — References that work everywhere

The shortened references are valid Excel, but not every application resolves a reference without its table name. Recalculate the file in LibreOffice and look:

Python
import subprocess
from openpyxl import load_workbook

def recalculated(path):
    load_workbook(path).save("roundtrip.xlsx")            # drop XlsxWriter's cached zeros so LibreOffice recalculates
    subprocess.run(["soffice", "--headless", "--convert-to", "xlsx", "--outdir", "calc", "roundtrip.xlsx"], check=True, capture_output=True)
    return [row for row in load_workbook("calc/roundtrip.xlsx", data_only=True)["Orders"].iter_rows(values_only=True)]

for row in recalculated("orders_table.xlsx"):
    print(row)

The recalculated() helper is a testing trick, not something to ship: re-saving with openpyxl removes the cached zeros, which makes LibreOffice compute every formula on load, and the converted copy is then read back with its calculated values. The Net column shows #N/A and the totals #NAME?: LibreOffice does not resolve the shortened, table-less references. Writing the references in full — Orders[[#This Row],[Qty]] for the row formula and an explicit =SUBTOTAL(109,Orders[Net]) as a custom total — gives formulas that Excel and LibreOffice both calculate:

Python
columns[1]["total_function"] = "=SUBTOTAL(109,Orders[Qty])"
columns[4]["total_function"] = "=SUBTOTAL(109,Orders[Net])"
columns[4]["formula"] = "=Orders[[#This Row],[Qty]]*Orders[[#This Row],[Price]]*(1-Orders[[#This Row],[Discount]])"
wb = xlsxwriter.Workbook("orders_table.xlsx")
ws = wb.add_worksheet("Orders")
for c in columns:                                        # formats belong to this workbook
    if "format" in c:
        c["format"] = wb.add_format({"num_format": "#,##0.00" if c["header"] != "Discount" else "0%"})
ws.add_table(0, 0, last_row, len(columns) - 1, {"name": "Orders", "data": orders.values.tolist(),
             "columns": columns, "total_row": True, "style": "Table Style Medium 2"})
wb.close()

rows = recalculated("orders_table.xlsx")
for row in rows:
    print(row)
expected = (orders["Qty"] * orders["Price"] * (1 - orders["Discount"])).round(2)
print("Net matches pandas:", [round(r[4], 2) for r in rows[1:-1]] == expected.tolist(), "| total", round(rows[-1][4], 2), "vs", round(expected.sum(), 2))

Every Net value now calculates and matches the same calculation in pandas, and the totals row sums both columns. A string starting with = as total_function is written as a custom total formula. Excel treats the full references exactly like the short ones — it may even display them in the short form — so nothing is lost for Excel users, and the workbook also works for anyone opening it in LibreOffice or reading it with tools that evaluate formulas. Table references in openpyxl-built workbooks follow the same rules, as covered in Use Structured Table References in Formulas from Python.

Short or fully qualified structured references The shorthand [@Qty] is saved as a reference without the table name and the built-in sum total as SUBTOTAL of a table-less column; Excel resolves both but LibreOffice shows errors. Writing Orders[[#This Row],[Qty]] and SUBTOTAL(109,Orders[Net]) calculates in both. Shorthand [@Qty], total 'sum' saved as [[#This Row],Qty] SUBTOTAL(109,[Net]) Excel calculates LibreOffice: #N/A, #NAME? Orders[[#This Row],[Qty]] table name included =SUBTOTAL(109,Orders[Net]) Excel calculates LibreOffice calculates

Letting people extend the table

The point of writing a table rather than a plain range is what happens after the file leaves Python. In Excel, typing a new product in the row directly below the last data row — above the totals row, which moves down — extends the table automatically: the new row takes the table's banding and column formats, the Net column fills in from the stored calculated-column formula, and the totals include the new row. Formulas elsewhere that refer to Orders[Net] include it too, with no ranges to edit. That makes tables the right structure for any generated workbook people are expected to add to — order logs, budget lines, issue trackers. Data validation on input columns, added with ws.data_validation() over the table's data rows, keeps those additions clean; new rows in a table inherit validation from the rows above.

Checking the table's formulas from Python Write the table with XlsxWriter, inspect the sheet and table XML for the formulas and the calculated-column definition, re-save with openpyxl to drop cached zeros, recalculate with LibreOffice, and compare every calculated value with pandas. Write with XlsxWriter add_table, formulas Inspect the XML formulas, calculated column Drop cached zeros re-save with openpyxl Recalculate LibreOffice Compare with pandas Net and totals match

Common pitfalls

  • A range one row short. Include the header and totals rows in the add_table() range.
  • Headers from the DataFrame. Table headers come from the columns list, not the data.
  • Expecting cached results. XlsxWriter stores 0; Excel recalculates, other readers do not.
  • Shorthand references outside Excel. Use Table[[#This Row],[Column]] for portability.
  • Reusing formats across workbooks. Formats belong to the workbook that created them.

Conclusion

To write a DataFrame as an Excel table with XlsxWriter, pass its values to add_table() with a range covering header, data and totals rows, and describe each column — header, format, total function, and a formula for calculated columns. Write structured references with the table name, Orders[[#This Row],[Qty]], and custom totals such as =SUBTOTAL(109,Orders[Net]), so the formulas calculate in every spreadsheet application, and check them by recalculating the file and comparing with pandas.

Frequently asked questions

How do I write a pandas DataFrame as an Excel table with XlsxWriter? Call worksheet.add_table with a range that covers the header and data rows, pass df.values.tolist() as data and a columns list with a header for each column.

How do I add a calculated column to an XlsxWriter table? Give the column definition a formula using structured references, for example =@Qty*@Price or the fully qualified Orders[[#This Row],Qty]*Orders[[#This Row],Price].

How do I add a totals row to an XlsxWriter table? Set total_row to True, extend the range by one row, and give columns a total_function such as sum or a custom formula starting with =.

Why do my XlsxWriter table formulas show errors in LibreOffice? XlsxWriter saves @Column as a reference without the table name, which LibreOffice does not resolve; write the reference with the table name instead.

More on xlsxwriter

More on xlsxwriter →