Guide
Getting Started With Python Excel AutomationDeep dive

Call Python from Excel with an xlwings UDF

Expose a Python function as a worksheet formula with xlwings — the add-in setup, @xw.func and @xw.arg decorators, array and DataFrame arguments, and the Windows-only limits.

Most Python-and-Excel automation runs one way: Python produces a file, somebody opens it. A user-defined function inverts that. You write a Python function, and a spreadsheet user types =REVENUE_FORECAST(B2:B13, 0.04) into a cell and gets the result — with your libraries, your logic, and no VBA. xlwings makes this a decorator and an add-in click. This guide covers the setup, the argument converters that make it pleasant, and the constraints worth knowing before you commit to the approach. It builds on Automating Excel with xlwings: the Basics.

What happens when a cell calls a Python function A worksheet cell containing a formula calls the xlwings add-in, which crosses the process boundary via COM into a Python interpreter. There the decorated function runs with the range converted to a DataFrame, and its return value is converted back and written into the calling cell or spilled across a range. The round trip is the reason a function should take and return whole ranges rather than being called once per row. in the worksheet =FORECAST_TOTAL( B2:C13, 0.04) an ordinary formula the boundary xlwings add-in COM · Windows only one round trip per call in the Python process @xw.func @xw.arg("data", pd.DataFrame) the range arrives as a DataFrame pandas, numpy, your own modules the return value spills back into the sheet

Prerequisites

Bash
pip install xlwings
xlwings addin install

Windows with Excel installed. UDFs use COM automation, which exists only in Excel for Windows — there is no macOS or Linux equivalent. If you are producing files on a server, this is the wrong tool entirely; write .xlsx with openpyxl or xlsxwriter instead.

Then, once per machine, enable trust for the VBA object model: Excel → File → Options → Trust Center → Trust Center Settings → Macro Settings → tick "Trust access to the VBA project object model". Without it the add-in cannot register your functions and every call returns #NAME?.

Step 1 — Create the project

xlwings scaffolds a matched pair — a workbook and a Python module with the same stem:

Bash
xlwings quickstart forecast_tools
cd forecast_tools

That gives you forecast_tools.xlsm and forecast_tools.py. The names must match: the add-in looks for a module named after the workbook. Renaming one without the other is the second most common cause of #NAME?.

Step 2 — Write the function

Decorate a plain Python function with @xw.func and it becomes a worksheet formula:

Python
# forecast_tools.py
import xlwings as xw

@xw.func
def growth_rate(first, last, periods):
    """Compound growth rate between two values over N periods."""
    if first <= 0 or periods <= 0:
        return "#VALUE! first and periods must be positive"
    return (last / first) ** (1 / periods) - 1

In Excel, click Import Functions on the xlwings ribbon tab, then use it like anything built in:

Text
=growth_rate(B2, B13, 11)

Returning an error string rather than raising is deliberate. An unhandled exception surfaces as a bare #VALUE! with no explanation; a returned string lands in the cell and tells the user what went wrong.

Step 3 — Take ranges as DataFrames

How a worksheet range becomes a DataFrame A four-row range on the sheet, whose first row holds the column headings region and revenue. The arg decorator with header set to True and index set to False turns that into a DataFrame with two named columns and three data rows. Getting the two options wrong is what makes the header appear as a data row, or makes the first column vanish into the index. the range A1:B4 region revenue North 5150.00 South 4268.50 West 3511.25 @xw.arg("data", pd.DataFrame, index=False, header=True) DataFrame 2 named columns 3 data rows header=False makes the headings a data row; index=True makes "region" vanish into the index

The @xw.arg decorator converts an incoming range before your function sees it, which is what makes UDFs pleasant to write:

Python
import numpy as np
import pandas as pd
import xlwings as xw

@xw.func
@xw.arg("data", pd.DataFrame, index=False, header=True)
def forecast_total(data, growth=0.04, periods=12):
    """Project a monthly revenue column forward and return the total."""
    if "revenue" not in data.columns:
        return "#VALUE! expected a 'revenue' column"

    revenue = pd.to_numeric(data["revenue"], errors="coerce").dropna()
    if revenue.empty:
        return "#VALUE! no numeric revenue values"

    base = float(revenue.iloc[-1])
    projected = base * ((1 + growth) ** np.arange(1, periods + 1))
    return float(revenue.sum() + projected.sum())
Text
=forecast_total(A1:B13, 0.04, 12)

The index=False, header=True pair tells xlwings that the first row of the range is a header and no column should be treated as an index — which matches how a spreadsheet table is usually laid out. Getting these wrong is the usual reason a DataFrame arrives with the header as data.

ConverterUse for
pd.DataFramea tabular range with headers
pd.Seriesa single labelled column
np.arraya numeric block, no labels
lista simple one- or two-dimensional range
dicta two-column key/value range

Step 4 — Return a table

Return a DataFrame and Excel spills it across a range — on current versions using dynamic arrays, so the user types one formula and gets a block:

Python
import pandas as pd
import xlwings as xw

@xw.func
@xw.arg("data", pd.DataFrame, index=False, header=True)
@xw.ret(index=False, header=True)
def summarise_by_region(data):
    """Return a region-level summary as a spilled table."""
    numeric = data.copy()
    numeric["revenue"] = pd.to_numeric(numeric["revenue"], errors="coerce")

    return (
        numeric.groupby("region", as_index=False)
               .agg(orders=("revenue", "size"),
                    revenue=("revenue", "sum"),
                    average=("revenue", "mean"))
               .round(2)
               .sort_values("revenue", ascending=False)
    )

@xw.ret controls the return conversion the way @xw.arg controls the input. Setting header=True writes the column names into the first spilled row, which is almost always what a reader wants.

Step 5 — Design for the round trip

Every call crosses a process boundary. That cost is small in absolute terms and enormous when multiplied by a filled-down column.

Design the function so one call does all the work Two designs. A scalar function filled down a thousand rows makes a thousand separate crossings between Excel and Python, each carrying a fixed overhead that dwarfs the calculation. A range function called once takes the whole block, computes everything in vectorised pandas, and spills a whole block back — one crossing regardless of row count. scalar UDF, filled down =RATE(B2, C2) in 1,000 cells 1,000 process crossings recalculated on every sheet change the workbook becomes unusable range UDF, called once =SUMMARISE(A1:C1001) one crossing vectorised pandas inside spills the whole result back

Two decorators help when a scalar function is genuinely the right shape. @xw.func(volatile=False) — the default — tells Excel the result depends only on the arguments, so it is not recalculated on every change elsewhere. And caching pays for itself when a function is called repeatedly with the same inputs:

Python
from functools import lru_cache
import xlwings as xw

@lru_cache(maxsize=512)
def _lookup(code):
    """Expensive work — a database or API call — cached across calls."""
    return fetch_rate_from_source(code)

@xw.func
def rate_for(code):
    try:
        return _lookup(str(code).strip().upper())
    except Exception as exc:
        return f"#VALUE! {exc}"

Note that lru_cache lives on the private helper, not the decorated function — xlwings needs the real function object to register it, and caching the wrapper can confuse the argument conversion.

Common pitfalls and fixes

SymptomCauseFix
#NAME? on every callAdd-in missing, or functions not importedInstall the add-in; click Import Functions.
#NAME? after renamingModule name no longer matches the workbookKeep the stems identical.
Add-in cannot register functionsVBA object model not trustedEnable it in Trust Center → Macro Settings.
Header row treated as dataConverter options wrong@xw.arg(..., header=True, index=False).
#VALUE! with no detailException raised inside the functionCatch it and return the message as a string.
Workbook recalculates constantlyFunction marked volatileLeave volatile=False (the default).
Very slow with a filled-down formulaOne crossing per cellRedesign to take and return a range.
Nothing works on macOSUDFs are Windows-onlyUse xlwings scripts, or generate the file.

Performance and scale notes

The dominant cost is the boundary crossing, not your Python. A trivial scalar function still pays the full COM round trip, so the practical guidance is entirely about call count.

Take a range, return a range. One call over a thousand rows is roughly a thousand times cheaper than a thousand calls over one row each, and the pandas work inside is vectorised anyway.

Keep imports at module level. Importing pandas inside the function body re-runs the lookup on every call; at module level it happens once when the interpreter starts.

Cache anything external. A UDF that queries a database on each call will make Excel feel broken. lru_cache on the helper turns a thousand identical lookups into one — and where the source is a real database, consider pulling the whole table once with the approach in refreshing an Excel report from a database on a schedule rather than querying per cell.

There is also a design question worth asking before building UDFs at all. They tie the workbook to a Windows machine with Python and the add-in installed — which is fine for a small analyst team and a real obstacle for anything distributed widely. Where the calculation can happen ahead of time, generating a finished workbook is more portable and needs nothing installed on the reader's machine:

Python
# Often the better answer: compute in Python, ship a plain .xlsx.
summary = summarise_by_region(data)
summary.to_excel("regional_summary.xlsx", index=False, engine="xlsxwriter")

Reserve UDFs for the case that genuinely needs them: a user typing new inputs and wanting your logic to respond interactively.

Conclusion

An xlwings UDF turns a Python function into a worksheet formula with two decorators and an add-in click. Match the module name to the workbook, trust the VBA object model, and use @xw.arg with pd.DataFrame so ranges arrive in a shape worth working with. Return error strings rather than raising, so users see a reason instead of a bare #VALUE!. Above all, design for the round trip: take a whole range and spill a whole range back, because a scalar function filled down a thousand rows makes a thousand crossings and turns a workbook unusable. And remember it is Windows-only — if the report just needs producing, generate the file instead.

Frequently asked questions

Do xlwings UDFs work on macOS or Linux? No. User-defined functions require the COM automation layer that only exists in Excel for Windows. On macOS you can still drive Excel with xlwings scripts, and on Linux neither works — use openpyxl or xlsxwriter to produce files instead.

Why does Excel say my function name is not recognised? Either the add-in is not installed, the workbook's module name does not match the Python file, or you have not clicked Import Functions after adding or renaming a function. All three produce the same #NAME? error.

Can a UDF return a whole table? Yes. Return a DataFrame or a list of lists and Excel spills it across a range. On current versions this uses dynamic arrays automatically; on older ones the caller must enter it as an array formula.

Are UDFs fast enough for a large model? Each call crosses the process boundary between Excel and Python, so thousands of individual calls are slow. Design the function to take a whole range and return a whole range, so one call does the work of a thousand.

How do I debug a UDF that returns an error? Set the add-in to debug mode and run the Python file directly, which attaches your interpreter to Excel so breakpoints work. Failing that, wrap the body in a try block and return the exception text so the message lands in the cell.