ZigXLL Documentation
A Zig framework for building Excel XLL add-ins. Define functions in Zig (or Lua), and the framework generates all Excel boilerplate at compile time: exports, type conversions, registration, COM vtables for RTD.
Overview
Why XLLs?
XLL add-ins are native DLLs that run inside the Excel process with no serialisation or IPC overhead. Excel calls your functions directly and can parallelise them across cores during recalculation.
The catch: the C SDK dates from the early 1990s. Memory management is manual, the type system is painful, and there is almost no tooling. Microsoft themselves call it "impractical for most users."
Why Zig?
Zig's C interop and comptime make the SDK usable. You write normal Zig functions with standard types. The framework generates all the Excel boilerplate at compile time.
- No boilerplate — define functions with
ExcelFunction()and macros withExcelMacro(), the framework handles the rest - Type-safe conversions between Zig types and XLOPER12
- UTF-8 strings — the framework handles UTF-16 conversion
- Error mapping — Zig errors become
#VALUE!in Excel, or return specific errors like#N/A,#DIV/0! - Thread-safe by default (Multi-Threaded Recalculation)
- Zero function call overhead — 2000 Black-Scholes calculations recalc in under 7ms on a basic PC
- Cross-compile from Mac/Linux via xwin
- Async functions — add
.is_async = trueto run on a thread pool with automatic caching - RTD servers in pure Zig — no ATL/MFC needed
- Embedded Lua scripting — write Excel functions in Lua with automatic type marshalling, async, and thread-safe support
Quick start
The fastest way in is to read the example project on GitHub. It is a complete, working XLL covering functions, RTD, async and Lua that you can clone, build and load into Excel. The steps below show how to wire things up from scratch.
Add ZigXLL as a dependency in your build.zig.zon:
.dependencies = .{
.xll = .{
.url = "https://github.com/alexjreid/zigxll/archive/refs/tags/v0.3.1.tar.gz",
.hash = "...",
},
},
Create your build.zig:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.resolveTargetQuery(.{
.cpu_arch = .x86_64,
.os_tag = .windows,
.abi = .msvc,
});
const optimize = b.standardOptimizeOption(.{
.preferred_optimize_mode = .ReleaseSmall,
});
const user_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const xll_build = @import("xll");
const xll = xll_build.buildXll(b, .{
.name = "my_functions",
.user_module = user_module,
.target = target,
.optimize = optimize,
});
const install_xll = b.addInstallFile(
xll.getEmittedBin(), "lib/my_functions.xll",
);
b.getInstallStep().dependOn(&install_xll.step);
}
Define your functions:
// src/my_functions.zig
const xll = @import("xll");
const ExcelFunction = xll.ExcelFunction;
const ParamMeta = xll.ParamMeta;
pub const add = ExcelFunction(.{
.name = "add",
.description = "Add two numbers",
.category = "My Functions",
.params = &[_]ParamMeta{
.{ .name = "a", .description = "First number" },
.{ .name = "b", .description = "Second number" },
},
.func = addImpl,
});
fn addImpl(a: f64, b: f64) !f64 {
return a + b;
}
Wire them up in src/main.zig:
pub const function_modules = .{
@import("my_functions.zig"),
};
Build and load:
zig build # produces zig-out/lib/my_functions.xll
zig build test # runs tests natively, no Windows SDK needed
Output lands in zig-out/lib/my_functions.xll. Double-click to load in Excel.
Cross-compilation
Tests run natively without any Windows SDK. To cross-compile the XLL from Mac or Linux, install xwin for Windows SDK/CRT libraries:
# macOS
brew install xwin
xwin --accept-license splat --output ~/.xwin
# Linux
cargo install xwin
xwin --accept-license splat --output ~/.xwin
If you don't have Cargo, install Rust or grab a prebuilt binary from the xwin releases page.
Once set up, zig build auto-detects ~/.xwin and cross-compiles. See the README for more detail.
Performance
The example project includes Black-Scholes option pricing. 2000 calculations (1000 rows, call + put) recalculate in around 4-7ms on a basic AMD Ryzen 5500U. The Lua implementation of the same calculations is only 2-3ms slower, perfectly acceptable for interactive Excel use.
Creating Excel Functions
Quick start
Define functions using ExcelFunction() and wire them up in your main.zig.
// src/my_functions.zig
const xll = @import("xll");
const ExcelFunction = xll.ExcelFunction;
const ParamMeta = xll.ParamMeta;
pub const add = ExcelFunction(.{
.name = "add",
.description = "Add two numbers",
.category = "My Functions",
.params = &[_]ParamMeta{
.{ .name = "a", .description = "First number" },
.{ .name = "b", .description = "Second number" },
},
.func = addImpl,
});
fn addImpl(a: f64, b: f64) !f64 {
return a + b;
}
// src/main.zig
pub const function_modules = .{
@import("my_functions.zig"),
};
The framework discovers all ExcelFunction definitions at compile time, generates C-callable wrappers, and registers them with Excel when the XLL loads.
ExcelFunction options
pub const myFunc = ExcelFunction(.{
.name = "MyCategory.MyFunc", // Dots are fine - namespaces the function
.description = "What it does", // Shows in Excel's function wizard
.category = "My Category", // Groups functions in the wizard
.params = &[_]ParamMeta{ ... },
.func = myFuncImpl,
.thread_safe = true, // Default: true (enables MTR)
});
| Field | Required | Default | Notes |
|---|---|---|---|
name | yes | — | Function name as it appears in Excel. Dots are allowed for namespacing. |
func | yes | — | The Zig function to wrap. |
params | no | &.{} | Array of ParamMeta structs. Must match function arity if provided. |
description | no | "" | Shown in Excel's Insert Function dialog. |
category | no | "General" | Groups the function in Excel's function list. |
thread_safe | no | true | Enables Multi-Threaded Recalculation. Set to false if your function has side effects or shared state. |
is_async | no | false | Runs the function on a background thread pool with result caching via RTD. Automatically sets thread_safe = false. |
Supported types
Parameters
| Zig type | Excel type | Notes |
|---|---|---|
f64 | Number | |
bool | Boolean | TRUE/FALSE |
[]const u8 | String | Framework handles UTF-16 to UTF-8 conversion |
[][]const f64 | Range | 2D array of numbers. Empty cells become 0.0. |
*XLOPER12 | Any | Raw Excel value, for advanced use |
Optional parameters
Use ?T types for optional parameters. When Excel passes a missing value, it becomes null:
fn powerImpl(base: f64, exponent: ?f64) !f64 {
const exp = exponent orelse 2.0;
return std.math.pow(f64, base, exp);
}
Supported optional types: ?f64, ?bool, ?[]const u8.
Return types
| Zig type | Excel type | Notes |
|---|---|---|
f64 | Number | |
bool | Boolean | |
[]const u8 / []u8 | String | Freed by Excel via xlAutoFree12 |
[][]const f64 / [][]f64 | Array | Spills into a range. Freed by Excel. |
*XLOPER12 | Any | Raw, you manage memory |
All return types are wrapped with !T. Errors automatically become #VALUE! in Excel.
Returning specific Excel errors
For functions returning !*xl.XLOPER12, use the static error helpers on XLValue:
const XLValue = xll.XLValue;
fn safeDivideImpl(a: f64, b: f64) !*xl.XLOPER12 {
if (b == 0) return XLValue.errDiv0();
// ... normal return via wrapResult handled by framework
}
| Helper | Excel error |
|---|---|
XLValue.na() | #N/A |
XLValue.errValue() | #VALUE! |
XLValue.errDiv0() | #DIV/0! |
XLValue.errRef() | #REF! |
XLValue.errName() | #NAME? |
XLValue.errNum() | #NUM! |
XLValue.errNull() | #NULL! |
Returning strings
Allocate the result with std.heap.c_allocator. The framework marks it with xlbitDLLFree and Excel calls xlAutoFree12 to free it:
const allocator = std.heap.c_allocator;
fn reverseImpl(text: []const u8) ![]const u8 {
var result = try allocator.alloc(u8, text.len);
for (text, 0..) |c, i| {
result[text.len - 1 - i] = c;
}
return result;
}
Returning arrays
Return a [][]f64 or [][]const f64. Excel spills the result into adjacent cells:
fn matrixImpl(rows: ?f64, cols: ?f64) ![][]f64 {
const r: usize = @intFromFloat(rows orelse 3);
const c: usize = @intFromFloat(cols orelse 3);
var matrix = try allocator.alloc([]f64, r);
for (0..r) |i| {
matrix[i] = try allocator.alloc(f64, c);
for (0..c) |j| {
matrix[i][j] = @floatFromInt(i * c + j + 1);
}
}
return matrix;
}
Returning raw XLOPER12
For advanced use (e.g. wrapping RTD calls), return *xl.XLOPER12:
const xl = xll.xl;
const rtd_call = xll.rtd_call;
fn livePriceImpl(symbol: []const u8) !*xl.XLOPER12 {
return rtd_call.subscribe("myprog.rtd", &.{symbol});
}
Functions that call rtd_call.subscribe() must set .thread_safe = false — xlfRtd must run on Excel's main thread. See RTD servers for more.
Multiple modules
Split functions across files and list them all in main.zig:
pub const function_modules = .{
@import("math_functions.zig"),
@import("string_functions.zig"),
@import("finance_functions.zig"),
};
Namespacing
Use dots in the function name to namespace it in Excel:
.name = "Finance.BSCall",
Excel shows this as Finance.BSCall. The exported DLL symbol uses underscores (Finance_BSCall_impl) since Windows GetProcAddress doesn't support dots.
Async functions
Add .is_async = true to run a function on a background thread pool. The cell shows #N/A while computing, then updates with the final result.
pub const slow_calc = ExcelFunction(.{
.name = "SlowCalc",
.description = "Expensive calculation",
.is_async = true,
.func = slowCalcImpl,
.params = &[_]ParamMeta{
.{ .name = "x", .description = "Input value" },
},
});
fn slowCalcImpl(x: f64) !f64 {
// This runs on a background thread - Excel stays responsive.
doExpensiveWork();
return x * 2.0;
}
How async works
- First call: cache miss, spawns work on thread pool, cell shows
#N/A - Worker finishes: result cached, Excel recalculates
- Next recalc: cache hit, returns value directly, RTD subscription dropped
- Subsequent calls with same args: instant cache hit (no re-computation)
Intermediate values
To send progress updates to the cell before the final result, add *AsyncContext as the last parameter:
const AsyncContext = xll.AsyncContext;
fn slowCalcImpl(x: f64, ctx: *AsyncContext) !f64 {
ctx.yield(.{ .string = "Computing..." }); // cell updates immediately
doFirstPhase();
ctx.yield(.{ .double = x * 0.5 }); // partial result
doSecondPhase();
return x * 2.0; // final value
}
The *AsyncContext parameter is invisible to Excel — it is not counted as a function parameter and doesn't need a ParamMeta entry.
| Variant | Example |
|---|---|
.int | ctx.yield(.{ .int = 42 }) |
.double | ctx.yield(.{ .double = 3.14 }) |
.string | ctx.yield(.{ .string = "Loading..." }) |
.boolean | ctx.yield(.{ .boolean = true }) |
Caching
Results are cached by function name and arguments. Two calls to =SlowCalc(42) in different cells share the same cached result. To let users force a recalculation, expose a macro that clears the cache:
pub const clear_cache = ExcelMacro(.{
.name = "ClearAsyncCache",
.description = "Clear cached async results",
.func = struct {
fn f() void {
xll.async_cache.getGlobalCache().clear();
}
}.f,
});
Macros (commands)
Excel macros are commands that perform actions rather than returning values. Define macros using ExcelMacro():
const xll = @import("xll");
const xl = xll.xl;
const XLValue = xll.XLValue;
const ExcelMacro = xll.ExcelMacro;
const allocator = @import("std").heap.c_allocator;
pub const hello = ExcelMacro(.{
.name = "MyAddin.Hello",
.description = "Show a greeting",
.category = "My Macros",
.func = helloImpl,
});
fn helloImpl() !void {
var msg = try XLValue.fromUtf8String(allocator, "Hello from Zig!");
defer msg.deinit();
_ = xl.Excel12f(xl.xlcAlert, null, 1, &msg.m_val);
}
Limits
- Maximum 8 parameters per function (Excel supports 255, framework currently caps at 8)
- The
*AsyncContextparameter (if used) does not count toward the 8-parameter limit - Functions must use the C allocator (
std.heap.c_allocator) for returned strings and arrays
Lua Functions
ZigXLL can embed Lua scripts in your XLL, letting you write Excel functions in Lua instead of Zig. The framework handles all marshalling between Excel and Lua at compile time, with no runtime registry or stub pools needed. Lua functions support async execution and thread-safe parallel recalculation.
Quick start
1. Write annotated Lua scripts
--- Add two numbers
-- @param x number First number
-- @param y number Second number
function add(x, y)
return x + y
end
--- Greet someone by name
-- @param name string Name to greet
function greet(name)
return "Hello, " .. name .. "!"
end
--- Calculate hypotenuse
-- @param a number Side a
-- @param b number Side b
function hypotenuse(a, b)
return math.sqrt(a * a + b * b)
end
2. List them in build.zig
const xll = xll_build.buildXll(b, .{
.name = "my_functions",
.user_module = user_module,
.target = target,
.optimize = optimize,
.enable_lua = true,
.lua_scripts = &.{
"src/lua/functions.lua",
},
});
The framework handles parsing annotations, generating Excel function declarations, embedding scripts, and registering everything at startup. No changes to main.zig required.
Annotations
Annotate Lua functions with --- doc comments directly above the function declaration:
--- Description of the function
-- @param x number First number
-- @param y string Name to greet
-- @async
-- @thread_safe false
-- @category My Category
-- @name CustomExcelName
-- @help_url https://example.com/help
function my_func(x, y) ... end
| Tag | Description |
|---|---|
--- line | Function description (first --- line) |
@param name [type] [description] | Parameter. Type is number (default), string, or boolean. |
@rtd | RTD subscription function. Automatically non-thread-safe. |
@async | Run on worker thread with result caching via RTD |
@thread_safe false | Disable multi-threaded recalculation (default: thread-safe) |
@category name | Excel function category (default: "Lua Functions") |
@name ExcelName | Override the auto-generated Excel name |
@help_url url | URL with help information |
Without @name, the Excel name is auto-generated from the Lua function name: add becomes Lua.Add (prefix + PascalCase).
Build options
| Option | Default | Description |
|---|---|---|
lua_scripts | &.{} | Lua script files to embed and generate declarations from |
lua_prefix | "Lua." | Prefix for auto-generated Excel function names |
lua_category | "Lua Functions" | Default category in Excel's function list |
How it works
At compile time, LuaFunction() generates a C-callable impl function with the exact arity Excel expects, an @export of the impl function, and Excel registration metadata.
At runtime, the framework maintains a pool of independent Lua states (default 8, configurable). When Excel calls the function:
- Non-thread-safe: locks the main state (slot 0)
- Thread-safe: acquires any free state from the pool via atomic CAS (no contention)
- Async: spawns a worker thread that acquires a pool state, runs the Lua function, stores the result in the async cache, and notifies Excel via RTD
Async Lua functions
Add @async to the annotation to run a Lua function on a worker thread:
--- Fibonacci with simulated delay
-- @param n number Index
-- @async
function slow_fib(n)
local a, b = 0, 1
for i = 1, n do a, b = b, a + b end
return a
end
This uses the same async infrastructure as Zig ExcelFunction(.{ .is_async = true }) — same cache, same RTD server, same fire-and-forget pattern.
Shared state
Since pool states are independent, global variables don't propagate between them. For state that needs to be visible across all states and threads, use the built-in xll library:
-- xll.set(key, value) - store a value (number, string, boolean, or nil to delete)
xll.set("counter", (xll.get("counter") or 0) + 1)
xll.set("status", "ready")
-- xll.get(key) - retrieve a value (returns nil if not set)
local count = xll.get("counter")
Access is serialised via a mutex on the Zig side — Lua execution stays parallel, only xll.get/xll.set calls block briefly.
Pool size
Override the number of Lua states (default 8) in build.zig:
const xll = xll_build.buildXll(b, .{
// ...
.enable_lua = true,
.lua_states = 12,
});
RTD subscriptions from Lua
Lua functions can subscribe to an RTD server and return a live-updating cell value. Add @rtd to the annotation:
--- Live price for a symbol
-- @param symbol string Ticker symbol
-- @rtd
function price(symbol)
return "myprog.rtd", symbol
end
The function's return values are interpreted as: first = prog_id, rest = topic strings. The cell updates automatically whenever the RTD server pushes a new value.
Mixing Lua and Zig functions
Both coexist in the same XLL. Zig functions go in function_modules in main.zig, Lua functions come from lua_scripts in build.zig. There is no difference from Excel's perspective.
Sandbox
The Lua state is sandboxed by default. The following are removed before any user scripts run:
| Removed | Reason |
|---|---|
dofile, loadfile, load, require | Prevents loading code from the filesystem or arbitrary bytecode |
io (entire library) | No filesystem access |
os.execute, os.remove, os.rename, os.tmpname, os.getenv, os.exit | No shell access or process control |
Safe functions are kept: os.time, os.clock, os.date, os.difftime, the full math, string, and table libraries, and all standard Lua builtins.
Limitations
- Maximum 8 parameters per function (same as
ExcelFunction) - No matrix/table parameter or return type support yet
- Pool states are independent — global variable mutations don't propagate (use
xll.get/xll.set) is_async = trueautomatically forcesthread_safe = false@rtdfunctions are always non-thread-safe
RTD Servers
ZigXLL provides an RTD (Real-Time Data) server framework for pushing live data into Excel from Zig. The framework handles all COM boilerplate automatically — no C++ or ATL needed.
An RTD server is a COM object that Excel polls for updated values. You implement a handler with callbacks; the framework manages IUnknown, IDispatch, DLL exports, and auto-registration.
Handler callbacks
Implement the following callbacks on your handler struct:
onStart— called when Excel initialises the RTD serveronConnect/onConnectBatch— called when a cell subscribes to a topiconRefreshValue— called by Excel to retrieve the current value for a topiconDisconnect— called when a cell unsubscribesonTerminate— called during shutdown
RtdContext
The RtdContext gives your handler access to topic management, update notifications, and user-defined state. Key methods:
ctx.markAllDirty()— mark all topics as needing refreshctx.notifyExcel()— tell Excel to recalculate dirty topicsctx.topics.get(topic_id)— look up a topic's metadata and strings
RtdValue
A tagged union supporting:
| Type | Example |
|---|---|
Integer (i32) | .{ .int = 42 } |
Double (f64) | .{ .double = 3.14 } |
| Boolean | .{ .boolean = true } |
| String | RtdValue.fromUtf8("hello") |
| Empty | .empty |
The framework converts these to COM VARIANT types automatically.
Wrapping RTD in UDFs
Wrap RTD subscriptions in regular Excel functions for cleaner formulas. Instead of =RTD("myprog.rtd",,"AAPL"), users type =MYPRICE("AAPL"):
const xl = xll.xl;
const rtd_call = xll.rtd_call;
pub const live_price = ExcelFunction(.{
.name = "MyPrice",
.description = "Live price for a symbol",
.thread_safe = false, // required for xlfRtd
.params = &[_]ParamMeta{
.{ .name = "symbol", .description = "Ticker symbol" },
},
.func = livePriceImpl,
});
fn livePriceImpl(symbol: []const u8) !*xl.XLOPER12 {
return rtd_call.subscribe("myprog.rtd", &.{symbol});
}
Registration
Auto-registration writes registry entries under HKEY_CURRENT_USER during XLL load, requiring no admin privileges. Background threads typically fetch data in onStart, then call ctx.markAllDirty() and ctx.notifyExcel() to trigger Excel refresh cycles.
Using C/C++ Libraries
Zig is a C compiler and has first-class C interop. You can call any C or C++ library from your Excel functions without any special framework support — just import the library and wrap it with ExcelFunction().
Linking a C library
Add the library to your build.zig:
const xll = @import("xll");
pub fn build(b: *std.Build) void {
const lib = xll.buildXll(b, .{
.name = "my_addin",
.xll_module = b.path("src/main.zig"),
});
// Link a system library
lib.linkSystemLibrary("mylib");
// Or link a static library built from C source
lib.addCSourceFiles(.{
.files = &.{ "vendor/mylib.c" },
.flags = &.{ "-std=c99" },
});
lib.addIncludePath(b.path("vendor/"));
}
Wrapping C functions
Import the C headers and write a thin Zig wrapper:
const xll = @import("xll");
const ExcelFunction = xll.ExcelFunction;
const ParamMeta = xll.ParamMeta;
const c = @cImport({
@cInclude("mylib.h");
});
fn calcImpl(x: f64, y: f64) !f64 {
return c.expensive_calculation(x, y);
}
pub const calc = ExcelFunction(.{
.name = "Calc",
.func = calcImpl,
.params = &[_]ParamMeta{
.{ .name = "x" },
.{ .name = "y" },
},
});
The Zig wrapper handles XLOPER12 type conversion as usual. The C function just sees normal C types.
C++ libraries
For C++ libraries, create a C wrapper header that exposes the functions you need with extern "C" linkage, then import that header with @cImport. Zig cannot import C++ headers directly, but the C wrapper is typically straightforward.
When to use Zig vs Lua vs C
| Approach | Best for |
|---|---|
| Zig | New code, performance-critical functions, full framework access |
| Lua | Rapid iteration, user-editable logic, simple calculations |
| C library | Reusing existing native code, vendor libraries, numerical libraries |
All three can coexist in the same XLL.
How it Works
Some technical detail on how ZigXLL uses Zig's comptime to automatically generate Excel-compatible wrapper functions.
Overview
The framework is a Zig package that combines with user code at build time to produce a complete XLL. The user never writes any Excel boilerplate. The framework's buildXll() build helper creates a complete XLL by:
- Taking the user's module containing
function_modulestuple - Using the framework's
xll_builder.zigas the root source file - The builder exports all Excel entry points (
xlAutoOpen, etc.) - The builder discovers and registers user functions at compile time
ExcelFunction wrapper
The ExcelFunction() function is a compile-time code generator that takes function metadata and produces a struct containing Excel registration metadata, an impl function with the exact signature Excel expects, and type conversion logic between Zig types and XLOPER12.
The switch statement on params.len generates the exact number of parameters needed (0 to 8). Type conversions via extractArg() and wrapResult() handle:
f64↔xltypeNum(numbers)bool↔xltypeBool(TRUE/FALSE)[]const u8↔xltypeStr(strings with UTF-8 conversion)[][]const f64↔xltypeMulti(2D arrays/ranges)*XLOPER12↔ raw passthrough
Function discovery
getAllFunctions() uses comptime reflection to find Excel functions in a module. It scans declarations looking for structs with the is_excel_function marker and builds a comptime array of them.
Execution flow
When a user enters =ADD(1, 2) in Excel:
- Excel looks up the registered exported function
add_implin the XLL DLL - Excel calls
add_impl(XLOPER12*, XLOPER12*)with C calling convention - The generated impl function wraps each XLOPER12 in XLValue, extracts Zig types
- Calls the user's
add(f64, f64)function - Converts the result back to XLOPER12 via
wrapResult() - Excel displays the result
- Later, Excel calls
xlAutoFree12()to free the returned memory
Async architecture
Async functions use Excel's RTD mechanism as a completion notifier. All async functions share a single built-in RTD server (zigxll.async) and a shared thread pool (4 workers). Results are stored in a thread-safe cache keyed by "FuncName|arg1|arg2|...".
- Cache miss (first call): builds a topic key, duplicates arguments, spawns a worker on the thread pool, calls
xlfRtdto subscribe the cell, returns#N/A - Worker completes: stores the result in the cache, calls
UpdateNotify()to tell Excel to recalculate - Recalc (cache hit): returns the value directly, Excel drops the RTD subscription
- Subsequent calls: instant cache hit, no thread pool or RTD involved
Memory management
XLValue wraps XLOPER12 with type safety and tracks whether it owns memory via m_owns_memory. When Excel calls xlAutoFree12(), the framework deallocates any owned memory. UTF-8 to UTF-16 conversion is handled by fromUtf8String() and as_utf8str().
Performance characteristics
- Compile time: function discovery, wrapper generation, all metadata computed (no runtime overhead)
- Runtime: direct C call, no reflection or indirection
- Type conversion: minimal overhead (pointer deref and type check)
- Memory allocation: only for strings and returned values
- Registration: one-time cost at XLL load
Caveats
- Maximum 8 parameters per function (framework limitation, not Excel's)
- Supported types:
f64,bool,[]const u8,[][]const f64,*XLOPER12 - Windows x86_64 only (XLLs can only run on Windows)
- Requires Zig 0.15.1 or later