Syed Umar AnisDatabaseReading Excel Files in Rust: calamine vs umya-spreadsheet
Syed Umar AnisDatabaseReading Excel Files in Rust: calamine vs umya-spreadsheet
DatabaseRust

Reading Excel Files in Rust: calamine vs umya-spreadsheet

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

LibraryReadWriteParsing strategy
calamine 0.26Lazy — per sheet, on demand
umya-spreadsheet 2.xEager — 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:

  1. Read 10th row — open the workbook, seek to row 10, read cells
  2. Read top 50 rows — open the workbook, read the first 50 rows from Sheet1
  3. Read all rows — open the workbook, iterate every cell in Sheet1
  4. 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)

BenchmarkcalamineumyaSpeedup
Read 10th row1.12 s3.96 scalamine 3.5× faster
Read top 50 rows1.13 s3.75 scalamine 3.3× faster
Read all rows1.14 s4.61 scalamine 4.1× faster
Read specific sheet (Sheet2)329 ms3.97 scalamine 12.1× faster

Criterion (cargo bench, 100 samples, 95% CI)

BenchmarkLibraryLowMedianHigh
Read 10th Rowcalamine1.1248 s1.1274 s1.1301 s
Read 10th Rowumya-spreadsheet3.9502 s3.9557 s3.9615 s
Read Top 50 Rowscalamine1.1192 s1.1272 s1.1404 s
Read Top 50 Rowsumya-spreadsheet3.8418 s3.8465 s3.8516 s
Read All Rowscalamine1.1720 s1.1749 s1.1786 s
Read All Rowsumya-spreadsheet4.6046 s4.6204 s4.6407 s
Read Specific Sheetcalamine327.21 ms328.10 ms329.11 ms
Read Specific Sheetumya-spreadsheet3.9307 s3.9405 s3.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 .xlsx files (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.


Hi, I’m Umar

Leave a Reply

Your email address will not be published. Required fields are marked *