A fast Elixir library for reading spreadsheet files, powered by Rust and Calamine.
Features
- 🚀 Fast Performance - Native Rust implementation via NIFs
- 📁 Multiple Formats - Supports .xls, .xla, .xlsx, .xlsm, .xlam, .xlsb, and .ods files
- 💾 Paths or Binaries - Parse from a file path or from content already in memory
- 🗂️ Sheet Management - List and filter sheet names, including hidden sheets
- 📅 Consistent Types - Numbers are floats, dates are
NaiveDateTime, times areTime, durations areDuration, in every format - 🛡️ Bounded Memory - A per-call
:max_cellsbudget rejects sparse or crafted files before they are expanded 🔧 Simple API -
{:ok, result} | {:error, reason}for anything wrong with the file
Usage
Getting Sheet Names
List all sheet names from a file:
iex> Spreadsheet.sheet_names("financial_data.xlsx")
{:ok, ["Q1 Revenue", "Q2 Revenue", "Summary"]}Or from binary content using the :format option:
iex> content = File.read!("financial_data.xlsx")
iex> Spreadsheet.sheet_names(content, format: :binary)
{:ok, ["Q1 Revenue", "Q2 Revenue", "Summary"]}Filtering Hidden Sheets
Exclude hidden (and very hidden) sheets from the results:
iex> Spreadsheet.sheet_names("workbook.xlsx", hidden: false)
{:ok, ["Visible Sheet"]}Parsing Sheet Data
Parse a specific sheet by name from a file:
iex> Spreadsheet.parse("sales.xlsx", sheet: "Q1 Data")
{:ok, [
["Product", "Sales", "Date"],
["Widget A", 1500.0, ~N[2024-01-15 00:00:00]],
["Widget B", 2300.0, ~N[2024-01-20 00:00:00]]
]}Parse all sheets from a file (returns a list of {sheet_name, rows} tuples in workbook order):
iex> Spreadsheet.parse("sales.xlsx")
{:ok, [
{"Q1 Data", [
["Product", "Sales", "Date"],
["Widget A", 1500.0, ~N[2024-01-15 00:00:00]]
]},
{"Q2 Data", [
["Product", "Sales", "Date"],
["Widget B", 2300.0, ~N[2024-04-15 00:00:00]]
]}
]}Or from binary content using the :format option:
iex> content = File.read!("sales.xlsx")
iex> Spreadsheet.parse(content, sheet: "Q1 Data", format: :binary)
{:ok, [
["Product", "Sales", "Date"],
["Widget A", 1500.0, ~N[2024-01-15 00:00:00]],
["Widget B", 2300.0, ~N[2024-01-20 00:00:00]]
]}Options
| Option | Functions | Default | Description |
|---|---|---|---|
:format | parse/2, sheet_names/2 | :filename | :filename (the first argument is a path) or :binary (it is the file's content). |
:hidden | parse/2, sheet_names/2 | true | When false, hidden and very hidden sheets are skipped. nil means the default. Ignored by parse/2 when :sheet is given. |
:sheet | parse/2 | nil | Name of the sheet to parse. nil parses every sheet. sheet_names/2 accepts and ignores it, so one options list works for both. |
:max_cells | parse/2, sheet_names/2 | 50_000_000 | Cell budget of the call: the rows × columns (from A1, or from the data with anchor: :data) of every sheet built, summed. :infinity disables it. See "Limiting Memory" below. |
:anchor | parse/2 | :a1 | :a1 anchors rows at A1. :data starts them at the first non-empty row and column. sheet_names/2 accepts and ignores it. |
Rows Are Anchored at A1
By default, rows start at the top-left corner of the sheet: rows |> Enum.at(r) |> Enum.at(c)
is the cell in spreadsheet row r + 1, column c + 1, wherever the data starts.
Leading empty rows and columns are padded with nil, and every row has the same
length. A sheet whose data occupies C3:D4 returns:
{:ok, [
[nil, nil, nil, nil],
[nil, nil, nil, nil],
[nil, nil, "name", "value"],
[nil, nil, "a", 1.0]
]}With anchor: :data, rows start at the first non-empty row and column instead,
so the same sheet returns [["name", "value"], ["a", 1.0]]. A sheet that starts
at A1 returns the same rows either way.
Empty sheets, chart sheets and other sheets without cells return []. They are
still listed by sheet_names/2. The exception is .xls chart sheets, which
can carry the chart's data and return it as rows.
Data Types
| Cell | .xlsx, .xlsm, .xlam, .xlsb, .xls | .ods |
|---|---|---|
| Empty | nil | nil |
| Number (including whole numbers) | float, e.g. 1234.0 | float |
| Text | String.t() | String.t() |
| Boolean | true / false | true / false |
| Date or date-time | %NaiveDateTime{} (a date alone is midnight; milliseconds) | %NaiveDateTime{} (a date alone is midnight; sub-second precision as written in the file, up to 6 digits) |
| Time of day | %Time{} (serial in [0, 1) without a duration format) | %Duration{} (see below) |
Duration (e.g. [h]:mm:ss) | %Duration{} with hour, minute, second, microsecond | %Duration{} (sub-second precision as written in the file, up to 6 digits) |
| Date outside years 0–9999, or a negative serial | the raw serial number as a float | the raw ISO 8601 string |
Formula error (#DIV/0!, #N/A, …) | {:error, "#DIV/0!"} | "" (see Known Limitations) |
| Formula | its cached result, typed as above | its cached result |
Notes:
- Numbers are always floats, whatever the format stores (.xls and .xlsb store
some numbers as integers internally). Compare with
==, or convert withtrunc/1when you need integers. - Durations keep their sign:
-1:30:00is%Duration{hour: -1, minute: -30}. Hours are not folded into days, so36:00:00is%Duration{hour: 36}. - ODS cannot tell a time of day from a duration (both are
office:time-value), so ODS time cells are always%Duration{}. An ODS duration with day, week, month or year parts keeps them in the matching%Duration{}fields. - Sub-second precision: Excel formats store milliseconds; values with a
fractional second get
microsecond: {value, 3}, others{0, 0}, so they compare equal to sigils such as~N[2024-01-15 10:00:00]. ODS values keep the precision written in the file (up to microseconds). - Values that cannot be represented are returned as found rather than as
nil: Excel date serials that are negative or fall outside years 0–9999 as the serial (a float), and ODS date or time values that do not parse as the original string. - Excel errors are
{:error, code}tuples so they never look like text.
Errors and Exceptions
parse/2 and sheet_names/2 return {:error, reason} (a string) for anything
wrong with the input file:
- the file does not exist or cannot be read (
"could not open \"x.xlsx\": No such file or directory"); - the first argument looks like file content but
:formatis:filename(the message suggestsformat: :binary), or vice versa; - the content is not a supported spreadsheet, or calamine cannot parse it;
- the requested sheet does not exist (
"sheet \"Q3\" not found"); - the sheets exceed
:max_cells, or an .xls or .ods file is malformed in a way that would make the parser allocate without bound or loop forever; :formatis neither:filenamenor:binary(kept as an error tuple for backwards compatibility).
They raise ArgumentError for programming errors: unknown options, a :sheet
that is not a string or nil, a :hidden that is not a boolean or nil (only
checked when it is used), or a :max_cells that is not a positive integer or
:infinity. Duplicate options do not raise: the first one wins.
Limiting Memory with :max_cells
Rows are fully built in memory, and padding to A1 means a single cell in
XFD1048576 produces a 1,048,576 × 16,384 rectangle. :max_cells is a cell
budget for one call: every sheet the call builds costs its rows × columns,
counted from A1 (or from the data with anchor: :data), and a sheet that does not fit in what is left returns an error
before anything is allocated for it. The default of 50 million fits, for
example, one sheet of 1,048,576 rows × 47 columns, or 500,000 rows × 100
columns:
iex> Spreadsheet.parse("sparse.xlsx")
{:error, "sheet \"Data\" has 200000 rows × 16384 columns (3276800000 cells), above the :max_cells limit of 50000000"}
iex> Spreadsheet.parse("big.xlsx", max_cells: 100_000_000)
{:ok, [...]}As a guide, each cell of the result takes about 17 bytes when it is nil,
33 bytes for a float and 57 bytes for typical mixed data (short strings, dates,
floats), so the default allows a result of roughly 1–3 GB. Lower it for
untrusted uploads.
- .xlsx and .xlsb sheets are checked against their actual cells while they are read (the file's declared dimensions are ignored). A sheet that repeats cell positions far beyond its rectangle is rejected as malformed, whatever the limit.
- .xls and .ods workbooks are decoded in full when they are opened, so they
are scanned before decoding and every sheet of the workbook counts against
the budget, even when you parse one sheet or only call
sheet_names/2. For .ods the budget also bounds the cells repeat attributes expand to (table:number-columns-repeated). - Malformed files are rejected whatever the limit, including with
max_cells: :infinity: .xls compound file headers or sector chains that would make the parser allocate without bound or loop forever, .xls DIMENSIONS records beyond 65,536 rows × 256 columns, and .ods<text:s>space runs above 100 million spaces per workbook.
Only raise or disable the limit for files you trust.
Known Limitations
- .xls float precision: some .xls numbers stored in Excel's compact RK form
decode with a binary rounding difference, e.g.
0.12119999999999999instead of0.1212. This comes from calamine's decoding. - ODS error cells: LibreOffice stores formula errors in .ods files as empty
text with a private
calcext:value-type="error"attribute, which calamine reads as"". - Time-only cells at serial 0: Excel formats only tell calamine whether a cell
is a date-time or a duration, not whether its format shows a date. A
date-formatted cell whose serial is in
[0, 1)(e.g.1904-01-01in a 1904-epoch workbook) is returned as a%Time{}. - .xls and .ods memory: calamine decodes every sheet of these formats when the file is opened, and copies the requested sheet, so parsing one sheet still costs memory for the whole workbook.
- Not limited:
:max_cellsbounds cells, not the size of individual strings or shared-string tables.
For details on the underlying parser, see the Calamine documentation.
Summary
Types
A cell value: nil, a float, a string, a boolean, a NaiveDateTime, a
Time, a Duration, or {:error, code} for a formula error. See
"Data Types" in the module documentation.
A sheet's rows, anchored at A1 and padded with nil.
Functions
Parses one sheet, or every sheet, of a spreadsheet.
Parses a specific sheet from spreadsheet binary content.
Returns the names of the sheets in a spreadsheet, in workbook order.
Returns a list of sheet names from spreadsheet binary content.
Types
@type cell() :: nil | float() | String.t() | boolean() | NaiveDateTime.t() | Time.t() | Duration.t() | {:error, String.t()}
A cell value: nil, a float, a string, a boolean, a NaiveDateTime, a
Time, a Duration, or {:error, code} for a formula error. See
"Data Types" in the module documentation.
@type rows() :: [[cell()]]
A sheet's rows, anchored at A1 and padded with nil.
Functions
Parses one sheet, or every sheet, of a spreadsheet.
With the :sheet option, returns that sheet's rows. Without it (or with
sheet: nil), returns {sheet_name, rows} tuples for every sheet in
workbook order.
Rows are lists of cells anchored at A1: row 0 is spreadsheet row 1 and
column 0 is column A, with leading empty rows and columns padded with nil.
With anchor: :data they start at the first non-empty row and column
instead. See "Data Types" in the module documentation for the value of each
cell.
Options
:sheet- the name of the sheet to parse. Defaults tonil(all sheets).:format-:filename(default) when the first argument is a path, or:binarywhen it is the file's content.:hidden- whenfalse, excludes hidden and very hidden sheets from all-sheets parsing. Defaults totrue(nilalso means the default). Ignored, and not validated, when:sheetis given: naming a sheet always parses it.:max_cells- the cell budget of the call, or:infinity. Defaults to50000000. Each sheet parsed costs its rows × columns, counted from A1, and the sheets of one call must fit in the budget together (1,048,576 rows × 47 columns fits the default). A sheet that does not fit returns an error before it is expanded. .xls and .ods files are decoded in full when opened, so for them every sheet of the workbook counts, even when one sheet is requested.:anchor-:a1(default) anchors rows at A1.:datadrops the leading empty rows and columns, so row 0 and column 0 are the first that hold a value; each sheet then costs only that rectangle from:max_cells.
Returns {:error, reason} when the file cannot be read or parsed, the sheet
does not exist or the sheets exceed :max_cells, and raises ArgumentError
for unknown or invalid options. With duplicate options, the first one wins.
Examples
# Parse a specific sheet from a file path
Spreadsheet.parse("sales.xlsx", sheet: "Q1 Data")
{:ok, [
["Product", "Sales", "Date"],
["Widget A", 1500.0, ~N[2024-01-15 00:00:00]]
]}
# Parse all sheets from a file path
Spreadsheet.parse("sales.xlsx")
{:ok, [
{"Q1 Data", [
["Product", "Sales", "Date"],
["Widget A", 1500.0, ~N[2024-01-15 00:00:00]]
]},
{"Q2 Data", [
["Product", "Sales", "Date"],
["Widget B", 2300.0, ~N[2024-04-15 00:00:00]]
]}
]}
# Parse all visible sheets from binary content
content = File.read!("sales.xlsx")
Spreadsheet.parse(content, format: :binary, hidden: false)
{:ok, [{"Q1 Data", [...]}, {"Q2 Data", [...]}]}
# Allow a larger sheet
Spreadsheet.parse("big.xlsx", sheet: "Data", max_cells: 100_000_000)
{:ok, [[...]]}
# Start at the data, wherever it begins (here C3)
Spreadsheet.parse("offset.xlsx", sheet: "Data", anchor: :data)
{:ok, [["name", "value"], ["a", 1.0]]}
Parses a specific sheet from spreadsheet binary content.
This function is deprecated. Use parse/2 with sheet: and format: :binary options instead.
Returns the sheet's rows, as parse/2 does.
Returns the names of the sheets in a spreadsheet, in workbook order.
Options
:format-:filename(default) when the first argument is a path, or:binarywhen it is the file's content.:hidden- whenfalse, excludes hidden and very hidden sheets. Defaults totrue(nilalso means the default).:sheetand:anchor- accepted and ignored, so the same options can be passed toparse/2andsheet_names/2.:max_cells- the cell budget of the call, or:infinity. Defaults to50000000. Listing sheets only reads cells for .xls and .ods files, which are decoded in full when opened: every sheet's rows × columns, counted from A1, must fit in the budget together. Other formats are not affected here. Seeparse/2.
Returns {:error, reason} when the file cannot be read or parsed, and
raises ArgumentError for unknown or invalid options. With duplicate
options, the first one wins.
Examples
# From a file path (default)
Spreadsheet.sheet_names("workbook.xlsx")
{:ok, ["Sheet1", "Sheet2"]}
# From binary content
content = File.read!("workbook.xlsx")
Spreadsheet.sheet_names(content, format: :binary)
{:ok, ["Sheet1", "Sheet2"]}
# Exclude hidden sheets
Spreadsheet.sheet_names("workbook.xlsx", hidden: false)
{:ok, ["Sheet1"]}
Returns a list of sheet names from spreadsheet binary content.
This function is deprecated. Use sheet_names/2 with format: :binary instead.
Options
:hidden- Whenfalse, excludes hidden sheets. Defaults totrue.:max_cells- Seesheet_names/2.