spreadsheet-to-json
Convert Spreadsheets and CSV files to jSON
Battle-tested with spreadsheets under 10MB; recent releases handle much larger files. See version history below.
This library crate provides the core functions to convert common spreadsheet and CSV files into JSON or JSONL (JSON Lines) either directly or asynchronously.
It relies on the Calamine and CSV library crates to process files, the tokio crate for asynchronous operations and naturally serde and serde_json serialization libraries.
It supports the following formats:
- Excel 2007+ Workbook (.xlsx)
- Excel 2007+ Macro-Enabled Workbook (.xlsm) -- read as plain data; macros are ignored
- Excel 2007+ Binary (.xlsb)
- Excel 97-2004 Legacy (.xls)
- OpenDocument Spreadsheets (.ods) compatible with LibreOffice
- CSV: comma separated values (.csv)
- TSV: tab-separated values (.tsv)
Features
- Blazingly fast: It can import 10,000 rows in less than 0.4 seconds.
- Can export to standard JSON or to JSON lines when writing large files
- Formula cells are read as calculated values
- Can identify and convert both Excel's 1900 datetime format and standard ISO format as used in OpenDocument Spreadsheet
- Can identify numeric fields formatted as text and convert them to integers or floats.
- Can identify truthy text or number cells and convert them to booleans
- Can save large files asynchronously
Core Options
Options can be set by instantiating OptionSet::new("path/to/spreadsheet.xlsx") with chained setter methods:
.max_row_count(max: u32): overrides the default max row count of 10,000. Use this is direct mode or to return only the first n rows..header_row(index: u8)overrides the default header row index of 0, useful for spreadsheets with a title and notes on top.omit_header()omit the header altogether and assign default A1-style keys or column numbers..sheet_index(index: u32)zero-based sheer index. Any value over zero will override the specified sheet name..sheet_name(name: &str)case-insensitive sheet name. It will match the first sheet with name after stripping spaces and punctuation..read_mode_async()Defer processing of rows with a callback in the second argument in render_spreadsheet_async().json_lines()Output will be rendered one json object per row.field_name_mode(system: &str, override_header: bool): use either A1 (a,b, ...z,aa,ab, ...) or C-prefixed zero-padded numbers (c01,c02, ...) for the default column key notation where headers are either unavailable or suppressed via theoverride_headerflag. The C-style padding width scales with the sheet's total column count, so keys still sort correctly regardless of width:c01..c99under 100 columns,c001..c999from 100 up to 1,000,c0001..c9999from 1,000 up to 10,000 (seebuild_padded_col_keyinheaders.rs).override_headers(keys: &[&str])Override matched or automatic column keys. More advanced column options will be detailed soon.override_columns(cols: &[Value])Lets you override column settings, represented here as an array ofserde_json::Valuekey/value objects, where :key: overrides the header key,format: string | integer | float | d1 ... d8 | datetime | date | boolean | truthy | truthy:true_key,false_keyd1tod8will round floats to the specified max. number of decimal places.booleanwill cast integer, floats >= 1 as true as well as the strings '1' and 'true', with < 1 and the strings0andfalsebeing false. If unmatched or empty the field value will be null.truthywill cast common English-like abbreviations such as Y, Yes as true and N or No falsetruthy:true_key,false_keylets you cast custom strings to true or false. If unmatched the field value will be null.
default: overrides the default value for empty cells.
The C-style keys are prefixed with c rather than left as bare zero-padded numbers ("001", "002", ...) for two reasons: zero-padding alone guarantees the keys sort correctly as plain strings in virtually any programming language, and a bare numeric-looking string is a confusing choice for an object/map key -- it's easy to mistake for an array index, and some languages treat numeric-looking string keys specially. If you actually want positional access instead of keyed access, that's a one-liner in most languages regardless of how the keys are named -- in JavaScript, for example, Object.values(row) turns a row object into a plain array of its values in field order.
To do
More details of options to come.
Simple example:
let opts = new
.sheet_index
.read_mode_async
.override_headers;
Core functions
-
process_spreadsheet_direct(opts: &OptionSet): May be called in a synchronous context where you need to process results immediately. -
process_spreadsheet_async(opts: &OptionSet): Asynchronously processes files with a callback function to save each row.
Result set
filename: Matched filename,extension: Matched extensionsheet: Matched worksheet name and indexsheets: List of available worksheet nameskeys: Assigned column keysnum_rows: number of rows in the source file that have been successfully parseddata: Vector of dynamic objects (IndexMap<String, Value>) that can be easily translated into JSON or other common formats.out_ref: Optional output reference such as a generated file name, URL or database id.
If the file name and extension cannot be matched, because the file is unavailable or unsupported, the core functions will return a generic error.
Result Set methods
to_json(): Converts to the result set toserde_json::Valuethat may be printed directly or written to a file.to_output_lines(json_lines: bool): Returns a vector of plain-text results with each data row as JSON on a new linerows(): Returns a vector of rendered JSON stringsjson_data(): Returns all data as asserde_json::Value::Arrayready for conversion or post-processing.
Examples
The main implementation is my unpublished Spreadsheet to JSON CLI crate,
Simple immediate jSON conversion
This function processes the spreadsheet file immediately.
use *;
Preview multiple worksheets
You may preview all worksheets and limit the number of sample rows from each sheet.
use *;
Asynchronous parsing and saving to a database
This must be called in an async function with a callback to save rows in separate processes.
use *;
use tokio;
use IndexMap;
use Value;
async
// Save function called in a closure for each row with a database connection and data_id from the outer scope
Version History
- 0.1.2 the core public functions with Result return types now use a GenericError error type
- 0.1.3 Refined A1 and C01 column name styles and added result output as vectors of lines for interoperability with CLI utilities and debugging.
- 0.1.4 Added support for the Excel Binary format (.xlsb)
- 0.1.5 Added two new core functions
process_spreadsheet_direct()for direct row processing in a synchronous context andprocess_spreadsheet_async()in an asynchronous context with a callback. If you need to process a spreadsheet directly in an async function - 0.1.6 Deprecated public function beginning with render (render_spreadsheet_direct() has become. You should use
process_spreadsheet_immediate()for immediate processing of spreadsheets in an async context). Ensured the header row does not appear as the first data row in spreadsheets. - 0.1.7 Added support for multiple worksheets in preview mode and refined output options
- 0.1.10 Reviewed date-time parsing options for Excel and OpenDocument spreadsheets. Reorganised cell post-processors by detected data-type.
- 0.2.0 Bumped
calamineto 0.36.0. Switchedis-truthy,alphanumericandsimple-string-patternsfrom local path dependencies to their published crates.io versions —is-truthygained aTruthyRuleSetbuilder (options()/true_options()/false_options(), replacing the oldTruthyOptionvector plumbing). Fixed the CSV reader silently ignoring its own default row cap (DEFAULT_MAX_ROWS) when no explicit.max_row_count()was set — large uploads could load unbounded into memory. Fixed a panic inColumn::from_jsonon a non-stringformatfield. Assorted performance fixes: redundantColumn/Formatclones per cell, a freshArcallocated per CSV row instead of once per file, a double clone inResultSet::rows(). Fixed a panic inread_workbook_sheet_infoon an unreadable individual sheet. - 0.2.1 Bumped
is-truthyto 0.1.2, fixing CSV cells like"SKU001"or a date starting"01/..."being silently coerced to the booleantrue/falsejust because they contained an embedded0or1. AddedColumn::source_key(andColumn::from_source_key_with_format()): a column override can now be matched by its natural, auto-detected key wherever that column actually is, instead of strictly by position — seeresolve_columns()/natural_column_keys()inheaders.rs. Overrides with nosource_keystill apply positionally, unchanged. FixedFormat::Integeron a CSV cell with a decimal value (e.g."58.2") silently producing0instead of the truncated integer. - 0.2.2 Fixed a per-column
Format::Date/Format::DateTimeoverride having no effect at all on real (non-string) datetime cells in xlsx/ods — only the row-wide--date-only-style default was ever consulted forData::DateTime/Data::DateTimeIsocells, so casting a single datetime column to date-only silently did nothing; seeresolve_date_only()inreader.rs. Fixedis_not_header_rowmisclassifying the header row as real data (leaking it into the output as a bogus extra row) whenever any column had a non-AutoFormat— it used to compare the row's already-formatted values against the header text, and formatting the header row's own text (e.g. through a date or decimal parse) commonly turns it intonullor something else that no longer matches; it now compares raw, un-coerced cell text instead. Both bugs affected the xlsx/ods path only, not CSV/TSV. - 0.2.3 Added
.xlsm(macro-enabled Excel workbook) support..xlsmuses the exact same OOXML container as.xlsxandcalaminehas always read both through its Xlsx reader, but this crate's ownExtensionenum only recognised the.xlsxextension before. - 0.3.0 Breaking:
Column.date_only: boolis nowColumn.datetime_mode: DateTimeMode, andRowOptionSet's separatedate_only/time_onlybooleans are now a singledatetime_mode: DateTimeModefield --RowOptionSet::new()andColumn::from_source_key_with_format()/from_key_ref_with_format()takeDateTimeModeinstead of the oldboolin their signatures. Added aDateTimeModeenum (Full,Simple,DateOnly,TimeOnly,HmOnly) as the single, unified representation for datetime rendering, replacingRowOptionSet's previous separatedate_only/time_onlybooleans and the previously deadColumn.date_only: boolfield — that field was fully wired up for JSON/display output but never actually consulted by the rendering logic, so a per-column date-only override on a genuine (non-string) datetime cell silently did nothing; it's nowColumn.datetime_mode, correctly scoped toFormat::Autocolumns whose cells are already genuine datetimes (Data::DateTime/Data::DateTimeIso) — it never touches strings or numbers in the same column, unlike an explicitFormatoverride, which forces date/time interpretation onto every cell type. AddedFormat::Time(ti/time),Format::Hm(hm), andFormat::DateTimeSimple(ds/simple) alongside the existingFormat::Date/Format::DateTime, giving explicit per-column control over precision: full ISO-8601 with milliseconds and a trailingZ(the default, for JS/JSON interop), the same without the milliseconds/Z, date-only, time-only with seconds, or hours:minutes only. Precedence is a column's ownFormatoverride, then its owndatetime_mode(Auto columns only), then the row-wideRowOptionSet.datetime_modedefault. Fixed genuinely time-only Excel cells — e.g. a cell formatted as plainhh:mm, which Excel actually stores as a full datetime serial with zero elapsed days, since it has no true time-only type — rendering with the meaningless placeholder date1899-12-31(the 1900 date system's epoch); a cell with no real date component is now auto-detected and rendered as a bare time whenever a full datetime wasn't explicitly requested. Fixedcsv_cell_to_json_valuehaving noFormat::Date/Format::DateTimehandling at all — a date-like CSV string starting with a number (e.g."2023-06-15") could be misread by the numeric-extraction path before ever reachingfuzzy-datetime; all five date/time formats now apply uniformly across native Excel/ODS datetime cells, ISO datetime strings, and CSV/TSV text cells. Bumpedfuzzy-datetimeto 0.1.3, adding sliding-pivot two-digit-year expansion (e.g.21-06-23resolves relative to the current date rather than a fixed 50/50 century split) for ambiguous CSV/plain-text date cells, and switching its license to MIT. - 0.3.1
ResultSet::new()takes two new required parameters,header_row_index: Option<usize>andbody_start_index: usize, andResultSetgains matching public fields. Fixed a gap where there was no way to learn which row indices a read actually used --OptionSet.header_row/.data_row_indexonly ever reflect an explicit override and stayNonewhenever auto-detection resolved them instead, so a caller relying on those fields (e.g.spread-cli's--jsonmetadata) sawnulleven when a header and data start were successfully detected and used.ResultSet.header_row_index/.body_start_indexnow carry the actually-resolved 0-based indices regardless of whether they came from an explicit override ordetect_header_and_data_rows. Scoped to the single-sheet read paths (read_single_worksheet/read_csv_core);ResultSet::from_multiple()(the--preview/multi-sheet path) still reportsNone/0for now, since a single pair of indices can't represent multiple sheets that may each resolve differently. - 0.3.2
Format::Decimal(places)(d1-d8) now rounds native numeric cells (xlsx/ods floats and CSV), matching the precision it already applied to string-typed cells.Format::Date/Format::DateTime/Format::DateTimeSimple/Format::Time/Format::Hmon text cells and CSV values now accept/-separated dates in any order, not just--separated YMD (bumpedfuzzy-datetimeto 0.1.4 for related date-guessing fixes).Format::Time/Format::Hmare substantially more capable on text/CSV cells and native numeric cells: they now handle a bare time string with no date component ("11:39"), a decimal-disguised time (12.3meaning 12:30, for both float cells and strings), and a trailing AM/PM marker ("2:30pm"->14:30; an hour outside 1-12 with an am/pm marker is treated as already-24-hour and the marker is dropped, e.g."14:00pm"->14:00). Hours are intentionally not capped at 24, since a time column is just as often a duration (elapsed hours) as a time-of-day.