This directory contains an experimental Rust implementation of MiniExcel's basic XLSX read and write workflows. It is a research track and does not replace the .NET packages. The core crate is ready for crates.io packaging but has not been published yet.
Open the MiniExcel Browser Lab to inspect or generate XLSX files locally in your browser. Uploaded workbooks never leave the browser.
The MVP currently supports:
- Reading
.xlsxfiles from paths. - Bounded-memory worksheet streaming through
MiniExcel::query()andMiniExcel::query_as(). - Listing worksheets with index, type, and visibility metadata, and selecting a worksheet by name.
- Listing selected column names with header and A1 start-cell semantics.
- Dynamic rows with stable column order and optional header rows.
- Typed row deserialization through Serde.
- Inclusive A1 start/end ranges, header trimming, and optional empty-row filtering.
- Creating new
.xlsxworkbooks from dynamic rows or Serde structs. - Worksheet selection for reads and path-based workbook output.
- Strings, booleans, integers, floating-point values, empty cells, Excel errors, dates, times, datetimes, and durations.
- An in-browser WebAssembly adapter and Browser Lab for local XLSX inspection and generation.
The implementation uses Rust 2024 with an MSRV of Rust 1.85.0.
Run commands from the repository root:
cargo +1.85.0 check --workspace --all-targets --locked
cargo +1.85.0 test --workspace --all-targets --lockedThe workspace lockfile is committed so CI and local research use the same dependency graph.
crates.io is Rust's equivalent of NuGet. The miniexcel name is currently available, and only the core library is configured for publication; the local CLI and WebAssembly adapter remain private workspace packages.
Create and verify the same archive that crates.io will receive:
cargo +1.85.0 package --manifest-path miniexcel/Cargo.toml --lockedThe package is written to target/package/miniexcel-0.1.0.crate. Publishing requires a crates.io account with a verified email and an API token, and should only be done after reviewing the archive:
cargo login
cargo +1.85.0 publish --manifest-path miniexcel/Cargo.toml --lockedRun the CLI from the repository root:
cargo +1.85.0 run -p miniexcel-cli -- --helpIf the current directory is already miniexcel-cli, Cargo discovers the package and its parent workspace automatically:
cargo +1.85.0 run -- --help--manifest-path is always resolved relative to the current directory. To name the workspace manifest explicitly from miniexcel-cli, use --manifest-path ../Cargo.toml.
List sheets and inspect rows:
cargo +1.85.0 run -p miniexcel-cli -- sheets tests/data/xlsx/TestMultiSheet.xlsx
cargo +1.85.0 run -p miniexcel-cli -- query tests/data/xlsx/TestDynamicQueryBasic.xlsx --header --limit 5From miniexcel-cli, the equivalent commands are:
cargo +1.85.0 run -- sheets ../tests/data/xlsx/TestMultiSheet.xlsx
cargo +1.85.0 run -- query ../tests/data/xlsx/TestDynamicQueryBasic.xlsx --header --limit 5query supports --sheet, --header, --start-cell, --end-cell, --ignore-empty-rows, and --format table|json|jsonl. It prints at most 20 rows by default. Use --limit 0 --format jsonl for unbounded streaming output; JSON and table output collect the selected rows before rendering.
The Browser Lab uses miniexcel-wasm to inspect uploaded XLSX bytes and generate a demo workbook entirely in the browser. Build and test it from web-demo:
npm ci
npm run build
npm run test:e2eThe build requires the wasm32-unknown-unknown target and wasm-bindgen-cli 0.2.127. The Rust workflow validates the WASM build and Playwright desktop/mobile behavior.
Create and read back a workbook, or run both parity adapters:
cargo +1.85.0 run -p miniexcel-cli -- write-demo ./tmp/miniexcel-demo.xlsx
cargo +1.85.0 run -p miniexcel-cli -- parity --repo-root ../MiniExcelFrom miniexcel-cli, these become:
cargo +1.85.0 run -- write-demo ./tmp/miniexcel-demo.xlsx
cargo +1.85.0 run -- parity --repo-root ../../MiniExcelAfter one build, the executable is target/debug/miniexcel (.exe on Windows).
.NET and Rust consume the same versioned behavior contract at tests/data/contracts/xlsx-parity-v1.json. It covers the common dynamic and typed query surface with the same XLSX fixtures and canonical expected values.
cargo +1.85.0 test -p miniexcel --test parity_contract --locked
dotnet test ../MiniExcel/tests/MiniExcel.OpenXml.Tests/MiniExcel.OpenXml.Tests.csproj --framework net10.0 --filter "FullyQualifiedName~RustParityContractTests"Both commands must pass for a behavior to be considered equivalent. See Compatibility and research notes for normalization rules and the explicit version 1 scope.
MiniExcel is the only public behavior entry point. Reader, writer, ZIP/XML parser, and iterator implementation types are internal. The remaining root exports are data and configuration contracts: CellValue, DynamicRow, CellReference, ExcelRange, ReadOptions, WriteOptions, HeaderMode, SheetInfo, SheetType, SheetVisibility, Error, and Result. Date/time Serde adapters are available under serde_helpers.
Worksheet metadata is available from paths or in-memory XLSX data:
use miniexcel::{MiniExcel, SheetVisibility};
for sheet in MiniExcel::get_sheet_info("book.xlsx")? {
println!(
"{} (id={}): {:?}, active={}",
sheet.name(),
sheet.id(),
sheet.visibility(),
sheet.is_active()
);
if sheet.visibility() == SheetVisibility::Hidden {
println!("{} is hidden", sheet.name());
}
}
# Ok::<(), miniexcel::Error>(())The closest Rust equivalent to MiniExcel.Query is an iterator:
use miniexcel::MiniExcel;
for row in MiniExcel::query("book.xlsx")? {
let row = row?;
println!("{:?}", row["A"]);
}
# Ok::<(), miniexcel::Error>(())Worksheet XML is decompressed and parsed incrementally. Rows are delivered through a bounded channel and mapped as the iterator advances, so callers can use operations such as take, filter, and find without collecting every row. Dropping the iterator stops its worker. Use MiniExcel::query_with_options() for worksheet, header, start-cell, and empty-row options.
Typed rows use the same model:
# use serde::Deserialize;
use miniexcel::MiniExcel;
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Record {
name: String,
}
for record in MiniExcel::query_as::<Record>("book.xlsx")? {
println!("{}", record?.name);
}
# Ok::<(), miniexcel::Error>(())MiniExcel::query() and query_as() accept paths because a worker owns the ZIP archive while the iterator is alive. Their concrete iterator types are intentionally hidden.
Memory boundary: the streaming path keeps workbook metadata, styles, and the shared-string table in memory, plus a small row channel and parser buffers. It does not retain worksheet XML or all worksheet rows. It performs one bounded-memory metadata pass before the streaming pass so every dynamic row has a stable global column schema and explicitly declared style-only rows are preserved even when
<dimension>is missing or stale. Peak memory can still grow with the shared-string table or a single exceptionally large row, but not with the full worksheet row count.
use miniexcel::{HeaderMode, MiniExcel, ReadOptions};
let options = ReadOptions::new()
.with_sheet_name("Data")
.with_start_cell("B2".parse()?)
.with_end_cell("E20".parse()?)
.with_header_mode(HeaderMode::FirstRow);
for row in MiniExcel::query_with_options("book.xlsx", &options)? {
println!("{:?}", row?["Name"]);
}
# Ok::<(), miniexcel::Error>(())HeaderMode::Auto is the default. It means no header for query() and a first-row header for query_as().
Without headers, dynamic keys use the actual Excel column names such as A, B, and AA. Empty rows are retained by default to match MiniExcel. Use with_ignore_empty_rows(true) to filter rows whose cells are all empty.
use chrono::NaiveDate;
use miniexcel::MiniExcel;
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Release {
name: String,
version: u32,
#[serde(deserialize_with = "miniexcel::serde_helpers::deserialize_date")]
released_on: NaiveDate,
}
let rows = MiniExcel::query_as::<Release>("book.xlsx")?
.collect::<miniexcel::Result<Vec<_>>>()?;
# Ok::<(), miniexcel::Error>(())Serde rename, alias, default, skip, and Option semantics are supported. MiniExcel-specific column-index attributes are not part of the MVP.
use miniexcel::{CellValue, DynamicRow, MiniExcel, WriteOptions};
let mut row = DynamicRow::new();
row.insert("Name".to_owned(), CellValue::String("MiniExcel".to_owned()));
row.insert("Version".to_owned(), CellValue::Int(2));
MiniExcel::save_as_with_options(
"book.xlsx",
&[row],
&WriteOptions::new().with_sheet_name("Data"),
)?;
# Ok::<(), miniexcel::Error>(())Dynamic schemas are the union of row keys in first-seen order. Missing values are written as blank cells. Use MiniExcel::save_as_with_schema() when an explicit schema is required, including header-only exports.
use chrono::NaiveDate;
use miniexcel::{MiniExcel, WriteOptions};
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "PascalCase")]
struct Release {
name: String,
#[serde(serialize_with = "miniexcel::serde_helpers::serialize_date_to_excel")]
released_on: NaiveDate,
}
let values = [Release {
name: "MiniExcel Rust".to_owned(),
released_on: NaiveDate::from_ymd_opt(2026, 8, 13).unwrap(),
}];
let options = WriteOptions::new()
.with_sheet_name("Releases")
.with_column_format("ReleasedOn", "yyyy-mm-dd");
MiniExcel::save_as_serialized_with_options("releases.xlsx", &values, &options)?;
# Ok::<(), miniexcel::Error>(())The column-format key is the final Serde field/header name. Typed Serde writing supports structs and vectors of structs; maps and flatten are handled through the dynamic API instead.
- The default worksheet is the first workbook worksheet, not the active tab.
- Dynamic XLSX numbers with an exact
i64representation are returned asCellValue::Int; other numeric values remainFloat. - Excel serial dates cannot always distinguish date-only, time-only, and datetime intent. Dynamic serial values are normalized to
CellValue::DateTime; ISO values retain the more specific variant when possible. - Formula expressions are not returned. Reading uses their cached values.
MiniExcel::query()andquery_as()strictly stream worksheet XML from paths.- Streaming is synchronous and uses one worker thread per active query. Async I/O is not part of the MVP.
- Writing creates new workbooks and overwrites target paths. It cannot modify an existing workbook.
CSV, .xls, .xlsb, .ods, templates, macros, images, merged-cell operations, formula authoring, a general style system, and editing existing workbooks are deferred.
See Compatibility and research notes for dependency choices and behavior mapping.