I built elixcee, a Rust library and runtime for editing Excel workbooks and running data-processing VBA without Excel.
Many teams still use Excel for small but important data-processing jobs. A workbook keeps the input data, formulas, formatting, and VBA in one file, which is convenient for the person who maintains it.
The workflow becomes difficult when the same job needs to run unattended. A VBA macro usually expects Microsoft Excel to be open, which makes Excel a heavy dependency for a CI job, server, or batch worker. Large workbooks add another problem: load and save time, plus memory use. Rewriting an existing workbook and its VBA in another tool is often more work than the original job.
elixcee is my attempt to address that gap. It keeps the workbook format and the data-processing macro, but runs the work from a shell command or a Python script.
The project is available on GitHub:
https://github.com/kent-tokyo/elixcee
A headless workbook runtime
The workbook editing and macro execution happen in one native process. The runtime can load an .xlsx, change cells, evaluate formulas, run a data-processing macro, and save the result.
That requires more than a VBA interpreter. The runtime also has to deal with ZIP entries, XML, formulas, styles, merged cells, hidden rows, and workbook parts it does not actively understand.
Here is a small workbook example:
import elixcee
vm = elixcee.load_workbook("input.xlsx")
vm.run(
"""
Sub ProcessData()
Cells(1, 1).Value = 42
Cells(1, 2).Formula = "=A1 * 2"
End Sub
""",
"ProcessData",
)
vm.save_workbook("output.xlsx")
The same API can be used without a file:
vm = elixcee.Vm()
vm.set_cell(1, 1, 10)
vm.run("""
Sub DoubleIt()
Cells(1, 2).Value = Cells(1, 1).Value * 2
End Sub
""", "DoubleIt")
assert vm.get_cell(1, 2) == 20
The row and column numbers are one-based, like Excel. That small detail matters when moving existing VBA code into a test.
The large-file problem
The next problem appeared when I tried to process large, row-oriented workbooks. The normal API builds a workbook model, which is useful when a macro needs random access to cells. It is unnecessary when a job only needs to read rows and write another file.
I added a streaming reader and an append-only XLSX writer for that case:
import elixcee
for row_number, values in elixcee.open_stream(
"large.xlsx",
sheet="Data",
include_row_numbers=True,
max_rows=1_000_000,
max_columns=32,
):
process(row_number, values)
The limits came from real implementation problems rather than from a feature checklist. A row limit, a column limit, a byte limit, a timeout, and a work budget stop different failure modes. They should not be presented as one generic “memory safe” switch. In particular, the writer's byte limit is a cumulative output limit; it is not a constant-memory guarantee.
From Python or the shell
The core is also available through a CLI. This is useful when a batch job does not need a Python environment:
elixcee macros.bas ProcessData \
--file input.xlsx \
--output output.xlsx \
--json
The CLI can check VBA modules, take workbook snapshots, run workbook fixtures, and emit structured diagnostics. The Rust library, Python API, and CLI use the same workbook and VBA implementation.
A benchmark that had to be redone
I wanted to know whether the native writer was actually faster than openpyxl and ClosedXML. The first comparison was not fair: Rust used macOS F_FULLFSYNC, while the Python measurement used a weaker fsync path. I removed that result from the comparison and ran it again with the same durability barrier.
For load → edit → durable save → reload on an Apple M4, the independent openpyxl run looked like this:
| Fixture | elixcee p50 | openpyxl 3.1.2 p50 | Ratio |
|---|---|---|---|
| 17 populated cells | 5.021 ms | 9.538 ms | 1.90× |
| 1,000 × 10 numeric cells | 35.591 ms | 84.496 ms | 2.37× |
| 10,000 × 10 numeric cells | 317.622 ms | 947.311 ms | 2.98× |
This was 40 samples per library and fixture, with the batch order rotated. Values and formula text were checked after each run. The numbers are for native Rust API calls; they do not include PyO3 overhead.
The ClosedXML comparison was also faster for elixcee in the pooled medians, by 1.83× to 2.28× on the three fixtures. That run was done on a busy machine and before a later elixcee optimization. One round on the largest fixture was faster with ClosedXML. I am keeping that result as a provisional comparison, not as a general library ranking.
Why I chose Rust
The workbook reader and writer spend a lot of time moving bytes, parsing XML, and managing owned data. Rust gave me a small native core and made it easier to put limits around those operations.
The VBA interpreter benefits from the same setup. The runtime can enforce an instruction budget, call-depth limit, string and array limits, and a materialized-cell limit. These are useful when a macro comes from outside the process that runs it.
Python is still the most convenient interface for many users, so elixcee exposes the core through PyO3. The CLI is useful for jobs that only need a binary.
What is still unfinished
The VBA interpreter still covers data-processing macros rather than the whole Excel object model. OOXML round trips have tests for cell values, formulas, styles, merges, and several other parts, but not for every workbook object. The streaming APIs have limits and regression tests; I still need larger process-level measurements before making a claim about memory behavior. The benchmark above also measures native Rust calls, so Python binding performance is a separate question.
That is where the project is now: a usable workbook library and VBA runtime, with a growing set of tests around the cases I actually need. The current release is 1.0.2.
elixcee is available under the MIT license: