If you need to read .xlsx files in Rust, two libraries dominate the options: calamine and umya-spreadsheet. I benchmarked both against a ~10 MB workbook with 100,000 rows across multiple sheets. Here are the findings.
The two libraries
| Library | Read | Write | Parsing strategy |
|---|---|---|---|
| calamine 0.26 | ✅ | ❌ | Lazy — per sheet, on demand |
| umya-spreadsheet 2.x | ✅ | ✅ | Eager — entire workbook on open |
calamine is read-only, but its lazy parsing model means open_workbook() barely touches the file. Sheet data is only decoded when you explicitly call worksheet_range() for that sheet.
umya-spreadsheet supports both reading and writing, which makes it the only choice for workflows that need to modify files. The cost is that reader::xlsx::read() parses the entire workbook upfront — every sheet, every cell — regardless of how much data you actually need.
The benchmark setup
The test file has three sheets:
- Sheet1: 100,000 rows × 20 cols (mixed numeric and string data)
- Sheet2: 5,000 rows × 10 cols (medium sheet)
- Sheet3: 1,000 rows × 5 cols (small summary sheet)
Four scenarios were tested:
- Read 10th row — open the workbook, seek to row 10, read cells
- Read top 50 rows — open the workbook, read the first 50 rows from Sheet1
- Read all rows — open the workbook, iterate every cell in Sheet1
- Read specific sheet — open the workbook, read all cells from Sheet2
Each scenario was run 25 times, and the median duration was recorded (resistant to OS scheduling noise). The Criterion suite also ran 100 samples per benchmark for statistical rigour.
The benchmark runner and generator are in excel-bench on GitHub.
Results
Quick runner (cargo run --release, median of 25)
| Benchmark | calamine | umya | Speedup |
|---|---|---|---|
| Read 10th row | 1.12 s | 3.96 s | calamine 3.5× faster |
| Read top 50 rows | 1.13 s | 3.75 s | calamine 3.3× faster |
| Read all rows | 1.14 s | 4.61 s | calamine 4.1× faster |
| Read specific sheet (Sheet2) | 329 ms | 3.97 s | calamine 12.1× faster |
Criterion (cargo bench, 100 samples, 95% CI)
| Benchmark | Library | Low | Median | High |
|---|---|---|---|---|
| Read 10th Row | calamine | 1.1248 s | 1.1274 s | 1.1301 s |
| Read 10th Row | umya-spreadsheet | 3.9502 s | 3.9557 s | 3.9615 s |
| Read Top 50 Rows | calamine | 1.1192 s | 1.1272 s | 1.1404 s |
| Read Top 50 Rows | umya-spreadsheet | 3.8418 s | 3.8465 s | 3.8516 s |
| Read All Rows | calamine | 1.1720 s | 1.1749 s | 1.1786 s |
| Read All Rows | umya-spreadsheet | 4.6046 s | 4.6204 s | 4.6407 s |
| Read Specific Sheet | calamine | 327.21 ms | 328.10 ms | 329.11 ms |
| Read Specific Sheet | umya-spreadsheet | 3.9307 s | 3.9405 s | 3.9545 s |
The Criterion confidence intervals are very tight — these numbers are stable and repeatable.
What the numbers actually mean
calamine parses the whole sheet, umya the whole workbook
On a 10.7 MB workbook, calamine consistently parses Sheet1 in about 1.13 s. umya takes 3.9–4.6 s for the same data. That’s a 3–4× gap that shows up regardless of how much of the sheet you actually read.
The reason becomes clear when you look at scenarios 1 and 2. Reading just the 10th row takes calamine 1.12 s — almost identical to reading all 100,000 rows (1.14 s). This isn’t a bug; it’s how calamine works. worksheet_range() decodes the entire sheet into memory as a range object, and .nth(9) then just picks out the relevant row from that in-memory structure. The parse cost is fixed per sheet, not per row.
So calamine’s “laziness” is at the sheet level, not the row level. Once you ask for a sheet, you get all of it.
Unfortunately, neither of the two libraries supports lazily reading certain rows from an Excel sheet (say, the top 10).
The specific-sheet benchmark is the most interesting
The biggest gap — 12.1× — comes from reading Sheet2 (5,000 rows) from a workbook that also contains a large Sheet1 (100,000 rows).
calamine only decodes the sheet you ask for. Sheet1 is never touched, so reading Sheet2 costs just 328 ms instead of the ~1.13 s it takes to read Sheet1.
umya has no such luxury. read() loads every sheet in the workbook unconditionally. Reading 5,000 rows from Sheet2 still costs 3.94 s — the same as reading the whole thing — because it is reading the whole thing.
This is where calamine’s architecture pays off most. If your workbook has several large sheets but you only need one of them, calamine avoids parsing the rest entirely.
umya’s lazy_read() helps, but not by much
umya does expose a lazy_read() API, used in the top-50-rows benchmark. The result: 3.84 s vs 3.96 s for the standard eager read. A ~3% improvement on a 4-second operation. Not much really. The lazy API still appears to load most of the workbook structure; it does not provide row-level or sheet-level skipping.
When to use each
Use calamine if:
- You only need to read data (no writes)
- You’re working with large workbooks and only need some sheets
- You want the fastest possible read throughput
Use umya-spreadsheet if:
- You need to modify and save
.xlsxfiles (it’s the only option for write support) - You need full workbook access anyway, and want a richer API
- Parse time is acceptable for your use case
Running it yourself
git clone https://github.com/umaranis/excel-bench
cd excel-bench
# Quick median-of-25 runner (generates test_data.xlsx on first run)
cargo run --release
# Full Criterion suite (takes ~30 minutes on the 100k-row file)
cargo bench
# Regenerate the test file
cargo run --bin gen --release
The test file is generated by rust_xlsxwriter with a realistic mix of string and numeric columns. Sheet1 has every third column as a float and the rest as strings; Sheet2 alternates; Sheet3 simulates a small lookup table with categories and statuses.