All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.7.1] - 2026-10-25
Added
anchor: :dataforparse/2starts rows at the first non-empty row and column, as before 0.7.0, instead of padding them to A1. The sheet then costs only that rectangle from:max_cells.sheet_names/2accepts and ignores it.anchor: :a1is the default.
[0.7.0] - 2026-10-14
This release fixes a VM crash on crafted or very sparse files and makes cell
types consistent across formats, which changes some return values. The public
functions, their arities and the supported Elixir requirement (~> 1.18) are
unchanged. Read Breaking changes before upgrading, and Changed if you
match on error messages.
Breaking changes
- Rows are anchored at A1. Row index 0 is spreadsheet row 1 and column
index 0 is column A. Previously the rows started at the first non-empty
cell, so data beginning at
C3came back atrows[0][0]and its position was lost. Leading empty rows and columns are now padded withnil. Migrate: sheets whose data starts at A1 are unaffected. Otherwise, index cells by their spreadsheet position, or drop the padding yourself, e.g.Enum.drop_while(rows, &Enum.all?(&1, fn cell -> is_nil(cell) end))for leading rows. - Numbers are always floats.
.xlsand.xlsbfiles store some numbers as integers (RK records), which used to come back as integers while the same cell in.xlsx/.odswas a float. Every number is now a float, e.g.1234.0. Migrate: compare with==instead of===or pattern matching on integers, or convert withtrunc/1/round/1where you need integers. - Durations are
%Duration{}structs, and keep their sign. Duration- formatted cells (e.g.[h]:mm:ss) in.xlsx/.xls/.xlsbandoffice:time-valuecells in.odsused to be ISO 8601 strings such as"PT1H30M0S"(.odsreturned the file's own string, e.g."PT01H30M00S"), and negative durations were clamped to"PT0H0M0S". They are now%Duration{hour: 1, minute: 30}, and-1:30is%Duration{hour: -1, minute: -30}. Hours are not folded into days. Migrate: use the struct fields, orDuration.to_iso8601/1if you need a string (its format differs from the old strings, e.g."PT1H30M"). - Time-of-day cells are
%Time{}. An.xlsx/.xls/.xlsbdate-time cell whose serial is in[0, 1)(a time without a date, e.g.h:mm:ss) used to be aNaiveDateTimeon 1899-12-31. It is now e.g.~T[12:00:00]..odscannot distinguish a time of day from a duration, so.odstime cells are%Duration{}(see above). Migrate: match on%Time{}, or combine it with a date viaNaiveDateTime.new/2. - Dates that cannot be represented return the serial number instead of
nil. A date-formatted Excel cell whose serial is negative or falls outside years 0–9999 used to benil(or a bogus date for negative serials); it is now the raw serial as a float, e.g.-1.0..odsdate values that do not parse are still returned as the raw string. Migrate: handle a float where you expectednilfor such cells. - Very large workbooks return an error. The new
:max_cellsoption (below) is a cell budget for each call,50_000_000by default: every sheet the call builds costs its rows × columns counted from A1, and a sheet that does not fit in what is left returns{:error, "sheet \"Name\" has R rows × C columns (N cells), above the :max_cells limit of 50000000"}(naming what earlier sheets used, when they used some) instead of being parsed. The default fits, for example, one sheet of 1,048,576 rows × 47 columns, or 500,000 rows × 100 columns; the result takes roughly 17 bytes pernilcell, 33 per float and 57 for typical mixed data..xlsand.odsworkbooks are decoded in full when opened, so for them every sheet of the workbook counts, even when you parse one sheet, andsheet_names/2can return the error too. Migrate: pass a highermax_cells: n, ormax_cells: :infinity, for large files you trust, or parse sheets one at a time. - Unknown options raise
ArgumentError. Options that neither function accepts (e.g. a typo such assheets:) used to be silently ignored; they now raiseArgumentErrornaming them. Duplicate options are still accepted (the first one wins, as before), andsheet_names/2accepts and ignores:sheet, so one options list still works for both functions. Migrate: fix or remove the options the exception names. - The
decimaldependency is gone (it was never used). If your project usedDecimalthrough this package, add{:decimal, "~> 2.0"}to your own dependencies.
Fixed
- A crafted or very sparse file could abort the whole VM. A 2 KB
.xlsxwith values inA1andXFD200000made calamine allocate ~100 GB for the sheet's rectangle; the failed allocation aborted the BEAM rather than raising..xls(cell positions, a forged DIMENSIONS record, compound file header fields and looping sector chains) and.ods(row and column repeat attributes,<text:s>space runs) had equivalent problems when the file was opened, and a 1.3 MB.xlsxrepeating one cell 20 million times peaked at 1.2 GB. All of these now return{:error, reason}before the memory is allocated (see:max_cellsbelow, and Changed for the new messages). The checks for malformed files run whatever:max_cellsis, including:infinity; only the cell counts depend on it. - Data that does not start at A1 keeps its position (see Breaking changes).
- Negative durations keep their sign instead of becoming zero.
- A missing or unreadable file reports the real I/O error (see Changed).
- Passing file contents as a path (with the default
format: :filename) returns{:error, reason}suggestingformat: :binaryinstead of raisingArgumentError. - Paths no longer have to be valid UTF-8 on Unix.
- File extensions are matched case-insensitively (
REPORT.XLSXis opened as.xlsxdirectly, so errors name the real problem). - VBA modules and
.xlsbchart sheets are skipped without reading them (they return[]), so their content can no longer make a whole-workbook parse fail..xlschart sheets are still read, since they can carry the chart's data. - Files that make calamine panic (such as
.xlsformulas with truncated operands) return{:error, "could not read workbook: ..."}instead of raising anErlangErrorwith:nif_panicked. - NaN or infinite numbers (only possible in malformed files) return
{:error, "#NUM!"}instead of an invalid term.
Added
:max_cellsoption forparse/2andsheet_names/2: a cell budget for the call (default50_000_000,:infinitydisables it). Each sheet built costs its rows × columns counted from A1..xlsx/.xlsbsheets are checked against their actual cells while they are read (the declared<dimension>is ignored);.xls/.odsfiles are pre-scanned before calamine opens them.Spreadsheet.cell/0andSpreadsheet.rows/0types.- Tests and generated fixtures (
test/fixtures/build_fixtures.exs) covering.xlsb, data not starting at A1, time-of-day cells, positive and negative durations, error cells from LibreOffice exports, the 1904 date epoch, out-of-range dates, hidden sheets in.xls/.ods, non-ASCII sheet names, chart sheets, missing files and sheets, option handling, crafted sparse files, repeated cells and an.xlstemplate declaringA1:IV50000. - CI runs
mix format --check-formatted,mix credo --strict,cargo fmt --check,cargo clippy -D warningsand the Rust unit tests, and caches Rust builds. The release workflow checks, before building, that the tag matches the versions Mix and Cargo report formix.exsandCargo.toml.
Changed
- Error messages. If you match on error strings:
- A missing or unreadable file returns
"could not open \"path\": No such file or directory"(or the relevant I/O error) instead of"Xlsx error: I/O error: No such file or directory (os error 2)"and similar, or"Cannot detect file format"for paths without a spreadsheet extension. - A directory returns
"could not open \"path\": is a directory"instead of"Cannot detect file format". - A missing sheet returns
"sheet \"Name\" not found"for every format instead of"Xlsx error: Worksheet 'Name' not found"and similar. - Content no format can open still returns exactly
"Cannot detect file format", with two exceptions that add detail: a path passed withformat: :binaryappends" (the content looks like a file path; did you mean format: :filename?)", and content that starts with a zip signature but is damaged (e.g. a truncated upload) returns the zip error from the.xlsxreader, or from the.odsreader when it has an OpenDocument spreadsheetmimetype, e.g."Xlsx error: Zip error: invalid Zip archive: Could not find EOCD". - File contents passed as a path return
"could not open the given path: the value looks like file contents rather than a path; did you mean format: :binary?"instead of raising. - Malformed files return new errors, from
sheet_names/2too for.xlsand.ods:"invalid .xls file: ..."(compound file headers and sector chains that would allocate without bound or loop forever, DIMENSIONS records beyond 65,536 rows × 256 columns),"invalid .ods file: ..."(<text:s>elements expanding to more than 100 million spaces) and"invalid sheet \"Name\": ... repeat positions ..."(.xlsx/.xlsbsheets with more cell records than twice their rectangle). Some of these files opened in 0.6.4 when the operating system let calamine reserve the memory (a forged FAT sector count reserves 16 GB of address space); where it cannot, they abort the VM. - Files that make calamine panic return
"could not read workbook: ..."(see Fixed).
- A missing or unreadable file returns
- Invalid option values raise a descriptive
ArgumentError. A:sheetthat is not a string ornil, or a:hiddenthat is not a boolean, already raised in 0.6.4, as an opaqueArgumentError("argument error") from the NIF; the message now names the option.:hiddenis only checked where it is used (all-sheets parsing andsheet_names/2), soparse/2with a:sheetstill ignores it, andhidden: nilnow means the default instead of raising.sheet: nilstill means "all sheets", and an invalid:formatstill returns{:error, "Invalid format option: ..."}. - Cells are converted to their final Elixir values inside the NIF, which
removes a second pass over every cell in Elixir, and cells referencing the
same shared string share one term. Parsing a 100,000 × 10
.xlsxsheet with 400,000 shared-string cells takes 0.46 s instead of 0.87 s with 0.6.4, with a lower peak RSS (280 MB instead of 435 MB) and a result of the same size. rustleris now an optional dependency with any version ({:rustler, ">= 0.0.0", optional: true}, asrustler_precompiledrecommends), so applications pinning anotherrustlerresolve. It is only needed to force a source build, which uses rustler 0.38.rustler_precompiledrequirement raised from~> 0.8to~> 0.10.- calamine is pinned to exactly 0.36.1 (the version 0.6.4 already resolved to), and quick-xml and zip to the versions it uses, because the pre-scans mirror its internals.
- Release builds use
codegen-units = 1and strip debug info, configured innative/spreadsheet/Cargo.toml. - The six internal NIFs are collapsed into two (
sheet_names/3,parse/4) that take{:path, path}or{:binary, content}; both still run on dirty CPU schedulers. - Local NIF builds target NIF 2.16, like the precompiled binaries.
- The module documentation is now the README, including a type mapping table for every format and a known limitations section.
Removed
- The
decimalruntime dependency (see Breaking changes). - The unused
igniterdev/test dependency, the rustlerserdefeature and the directserdecrate dependency. - Internal modules and functions that were never public API (
@moduledoc false):Spreadsheet.Parser, and theSpreadsheet.CalamineNIFssheet_names_from_path/2,sheet_names_from_binary/2,parse_from_path/2,parse_from_binary/2,parse_all_from_path/2andparse_all_from_binary/2. UseSpreadsheet.sheet_names/2andSpreadsheet.parse/2. - The unused
arm-unknown-linux-gnueabihftarget fromnative/spreadsheet/.cargo/config.toml.
Known limitations
.odserror cells written by LibreOffice are returned as"": they are stored as empty text with a privatecalcext:value-type="error"attribute, which calamine does not read. Needs a calamine change.- Some
.xlsnumbers decode with a binary rounding difference (e.g.0.12119999999999999for0.1212) in calamine's RK decoding. Unchanged. .xlsand.odsworkbooks are still decoded in full by calamine when opened, and the requested sheet is copied, so parsing one sheet costs memory for the whole workbook. Pre-scanning those formats closes the crash cases above but adds a second pass over the file; fixing this properly needs limits (or lazy sheet loading) in calamine.
[0.6.4] - 2026-10-14
No breaking changes: the public API, supported Elixir requirement (~> 1.18)
and all runtime mix.exs constraints are unchanged. Dependency updates only.
Added
- Legacy
.xlsfiles declaring code page 10007 (Mac Cyrillic) or 21010 (UTF-16LE) now open; previously calamine failed with "Codepage … not found" (codepage0.1.2 → 0.1.3).
Updated
- Updated Rust dependencies:
encoding_rs0.8.35 → 0.8.42: rewritten ASCII fast path, UTF-8 validation viasimdutf8, and a fix for a boundary bug in streaming legacy decoders (calamine uses the non-streaming API, so results are unchanged). Pulls in four new crates:simdutf8,scopeguard,core_detectandmultiversion_no_op.flate21.1.9 → 1.1.10: a stream truncated at end-of-file is now an error instead of a silent short read, plus an infinite-loop fix in gzip header writing. Dropsminiz_oxideandadler2from the lockfile.- flate2 1.1.10 moved zlib-rs runtime CPU feature detection (SIMD) behind a
new default feature, which
zipdisables.flate2is now listed directly inCargo.tomlwithruntime_detectionenabled, so decompression keeps using SIMD. The backend is still zlib-rs. - Transitive:
cc,cfg-if,crc32fast,equivalent,find-msvc-tools,indexmap,inventory,js-sys,libc,syn,unicode-ident,wasm-bindgen(+ macro crates),zlib-rs,zopfli. rustlerstays on 0.38.0: crate 0.39.0 is out, but the hex package is still 0.38.0 and the two must match.- Verified parsing is unaffected: every fixture in
test/files(.ods,.xls,.xlsxand both hidden-sheet variants) was parsed before and after the update viasheet_names/2,sheet_names_from_binary/2,parse/2andparse_from_binary/2, producing identical results.
- Tested against
rustler_precompiled0.10.0, which the existing~> 0.8constraint already allows (adds NIF 2.18 / OTP 29 support; the options this library passes and the checksum file format are unchanged). - Tested against
decimal3.2.0. - Updated Elixir dev/test dependencies:
ex_doc0.40.4,igniter0.8.4, and their transitives (ex_ast,finch,hpax,makeup,mint,req,sourceror,spitfire). Dropped the staleinflexandtomllock entries, which no environment resolved.
Removed
- Dropped the unused
dialyxirdev/test dependency.
Internal
- CI actions updated to their current major versions.
[0.6.3] - 2026-08-25
No breaking changes: the public API, supported Elixir requirement (~> 1.18)
and all runtime Elixir dependencies are unchanged. Dependency updates only.
Updated
- Updated transitive Rust dependencies (
android_system_properties,cc,crc32fast,fast-float2,find-msvc-tools,futures-core,futures-task,futures-util,js-sys,log,syn,wasm-bindgen,wasm-bindgen-macro,wasm-bindgen-macro-support,wasm-bindgen-shared,zlib-rs). The direct Rust dependencies (calamine0.36.1,rustler0.38.0,chrono0.4.45,serde1.0.229) were already at their latest releases and are unchanged.- Verified parsing is unaffected: every fixture in
test/files(.ods,.xls,.xlsxand both hidden-sheet variants) was parsed before and after the update viasheet_names/2,sheet_names_from_binary/2,parse/2andparse_from_binary/2, producing identical results.
- Verified parsing is unaffected: every fixture in
- Updated Elixir dev/test dependencies (no runtime deps changed):
earmark_parser1.4.45 → 1.4.46,req0.7.1 → 0.7.3,spitfire0.3.13 → 0.4.0. Dropped the stalecastorelock entry, which no environment resolved.
[0.6.2] - 2026-07-30
No breaking changes: the public API, supported Elixir requirement (~> 1.18)
and all runtime Elixir dependencies are unchanged. Dependency updates only.
Updated
- Updated Rust dependencies:
- calamine 0.36.0 → 0.36.1
- Bug fixes only: fixed a 0.36.0 regression that broke Strict OOXML
(ISO/IEC 29500)
_rels/.relslinks, fixed XLSB parsing of unhandled workbook-global records (previously could silently drop a worksheet or panic), and XLS string cells now preserve empty strings instead of discarding them.
- Bug fixes only: fixed a 0.36.0 regression that broke Strict OOXML
(ISO/IEC 29500)
- serde 1.0.228 → 1.0.229
- Updated transitive Rust dependencies (
cc,futures-core,futures-task,futures-util,libc,proc-macro2,quote,syn)
- calamine 0.36.0 → 0.36.1
- Updated Elixir dev/test dependencies (no runtime deps changed):
igniter0.8.2 → 0.8.3,ex_ast0.12.10 → 0.13.1,glob_ex0.1.11 → 0.1.12,req0.6.3 → 0.7.1
[0.6.1] - 2026-07-17
No breaking changes: the public API, supported Elixir requirement (~> 1.18)
and all runtime Elixir dependencies are unchanged.
Changed
- CI now tests every supported Elixir version (1.18, 1.19 and 1.20) against every Erlang/OTP version each of them supports (26 through 29), using the latest patch releases (1.18.4 / 1.19.5 / 1.20.2, OTP 26.2 / 27.3 / 28.5 / 29.0).
Updated
- Updated Rust dependencies:
- calamine 0.35.0 → 0.36.0
- No API changes affecting this library (verified: only
open_workbook_auto*,sheets_metadataandworksheet_rangeare used). - Bug fixes: shared-formula cell-name replacement for defined names, custom
number-format detection for the
*(fill/repeat) operator, better XLS error messages for truncated files. - Behaviour note: XLSX text cells now trim leading/trailing ASCII
whitespace unless
xml:space="preserve"is set, matching Excel and the ECMA-376 spec. Files written by Excel, LibreOffice, openpyxl etc. set this attribute when whitespace is intended, so their output is unaffected; only spec-violating writers are normalised.
- No API changes affecting this library (verified: only
- Updated transitive Rust dependencies (
atoi_simd,cc,memchr,quick-xml,syn,wasm-bindgen,zip,zlib-rs, …)
- calamine 0.35.0 → 0.36.0
- Updated Elixir dev/test dependencies (transitive only, no runtime deps
changed):
ex_ast0.12.0 → 0.12.10,hpax1.0.3 → 1.0.4,makeup1.2.1 → 1.2.2,mint1.9.0 → 1.9.3,req0.6.2 → 0.6.3
[0.6.0] - 2026-06-24
Fixed
- Breaking:
.odsdates are now normalised toNaiveDateTime(date-only values become midnight), matching the.xlsx/.xlsoutput. Previously they leaked back as raw ISO 8601 strings, so the type of a date cell depended on the source format. - Duration-formatted cells (e.g.
[h]:mm:ss) are no longer misread as bogus dates. Calamine surfaces them asDateTime/TimeDeltavalues; they are now returned as ISO 8601 duration strings (e.g."PT1H30M0S").
Changed
- Internal cleanup: deduplicated the path/binary NIF bodies behind generic Rust helpers, and the all-sheets/format-error handling in the Elixir wrapper. No user-facing behaviour change.
- Removed the inert
doctest Spreadsheetdeclaration (the@docexamples are illustrative and were never executed as doctests).
Updated
- Updated Elixir dependencies:
- rustler 0.37.3 → 0.38.0
- Added
Term::sizesupport on Erlang 29 (nif_version_2_18), direct UTF-8 atom support (nif_version_2_17),NewBinary::from_iter, anddeserialize_seqfor binary deserialization - Deprecated the previous atom-creation signatures (alongside the new UTF-8
variants) and removed some deprecated codegen features. No code changes
were required — this library uses only
rustler::nif,NifTaggedEnum, andrustler::init!, none of which are affected. Not breaking for callers.
- Added
credo1.7.18 → 1.7.19 (dev/test)decimal3.1.0 → 3.1.1ex_doc0.40.2 → 0.40.3 (dev)igniter0.8.0 → 0.8.2 (dev/test)
- rustler 0.37.3 → 0.38.0
- Updated Rust dependencies:
[0.5.0] - 2026-05-15
Added
Spreadsheet.Calamine.parse_all_from_path/2andparse_all_from_binary/2— open the workbook once and return every sheet, replacing the previous open-per-sheet loop inSpreadsheet.parse/2.
Changed
- Breaking: Excel formula errors (
#REF!,#DIV/0!,#N/A, …) are now returned as{:error, reason}tuples instead of bare strings, so they are distinguishable from text cells. - All NIFs are now scheduled on
DirtyCputo avoid stalling the BEAM scheduler when parsing large workbooks.
Fixed
- Datetimes that calamine cannot convert no longer leak the literal string
"Invalid DateTime"into the result — they becomenil. - Sub-second precision in
Data::DateTimecells is preserved (was previously truncated to whole seconds by the format string). - Hidden-sheet filtering now uses sheet metadata in both branches, removing a
latent inconsistency between
hidden: trueandhidden: false.
Updated
- Updated Elixir dependencies:
erlex0.2.8 → 0.2.9,ex_ast0.11.2 → 0.12.0 - Updated transitive Rust dependencies (
cc,hashbrown,js-sys,libc,quick-xml,wasm-bindgen)
[0.4.10] - 2026-05-12
- Relax
rustler_precompiledpackage version
[0.4.9] - 2026-05-12
Added
- Allow
decimalpackage version 2.0 or 3.0 (~> 2.0 or ~> 3.0)
Updated
- Updated
rustler_precompiled0.8.1 → 0.9.0 - Updated Rust dependencies:
- calamine 0.34.0 → 0.35.0
- Added
has_1904_epoch()method on XLS/XLSX/XLSB structs to detect Excel date epoch - Made
RowDeserializerpublic for customDeserializeSeedimplementations - Performance: optimised
<v>cell value parsing and cell reference parsing - Bug fixes: redundant
<v>in XLSX inlineStr cells, XML namespace prefixes in relation ids, rich text<r>after plain<t>, XLS date formats for formula values, XLSX shared formula expansion corrupting function names (e.g.LOG10), inlineStr CDATA handling, BIFF5 formula parse panic, panic on unsupported XLS formula functions
- Added
- rustler 0.37.3 → 0.37.4
- Fixed OTP 22/23 heap overflow by gating
ErlNifResourceTypeInitmembers/dyncall onnif_version_2_16
- Fixed OTP 22/23 heap overflow by gating
- calamine 0.34.0 → 0.35.0
[0.4.7] - 2026-03-18
Updated
- Updated Rust dependencies:
- calamine 0.33.0 → 0.34.0
- Performance improvements: cell reader buffer reuse, optimised XLS sector chain reads, cached path/attr lookups, minimised allocations
- Bug fixes: capitalized workbook/book stream handling in XLS, BIFF5 record parsing, empty
<v/>values in XLSX, millisecond rounding
- rustler 0.37.2 → 0.37.3
- calamine 0.33.0 → 0.34.0
[0.4.6] - 2026-03-02
Updated
[0.4.5] - 2026-01-20
Updated
- Updated Rust dependencies
[0.4.3] - 2025-12-05
Updated
- Updated Rust dependencies:
- rustler 0.37.0 → 0.37.2
[0.4.2] - 2025-11-24
Updated
- Updated Rust dependencies in native spreadsheet module:
- calamine 0.31.0 → 0.32.0
[0.4.1] - 2025-11-17
Changed
- Dependency updates
[0.4.0] - 2025-10-29
Added
- NEW:
parse/2now supports parsing all sheets at once when called without the:sheetoption- Returns a list of
{sheet_name, sheet_data}tuples in sheet order - Supports
:hiddenoption to exclude hidden sheets - Works with both file paths and binary content via
:formatoption - Example:
Spreadsheet.parse("workbook.xlsx")returns{:ok, [{"Sheet1", [...]}, {"Sheet2", [...]}]}
- Returns a list of
Changed
- BREAKING: Unified API interface for
sheet_names/2andparse/2functionssheet_names/2now accepts a:formatoption (:filenameor:binary) instead of having separate_from_binaryvariantparse/2now uses a:sheetoption to specify which sheet to parse, instead of a separate parameter- Default format is
:filenamefor backwards compatibility with existing code that doesn't pass options - Examples:
Spreadsheet.sheet_names(content, format: :binary)replacesSpreadsheet.sheet_names_from_binary(content)Spreadsheet.parse(content, sheet: "Sheet1", format: :binary)replacesSpreadsheet.parse_from_binary(content, "Sheet1")Spreadsheet.parse("file.xlsx", sheet: "Sheet1")replacesSpreadsheet.parse("file.xlsx", "Sheet1")
Deprecated
sheet_names_from_binary/2- Usesheet_names/2withformat: :binaryinsteadparse_from_binary/2- Useparse/2withsheet:andformat: :binaryoptions instead
Fixed
- Improved error messages for invalid format options
[0.3.0] - 2025-10-13
Updated
- Updated Rust dependencies in native spreadsheet module:
- Changed calamine feature flag from
datestochrono
[0.2.4] - 2025-09-03
Updated
- Updated calamine dependency to version 0.30.0
[0.2.3] - 2025-01-08
Updated
- Updated calamine dependency to version 0.29.0
[0.2.2] - 2025-07-23
Added
- Support for filtering hidden sheets in
sheet_names/2andsheet_names_from_binary/2functions