Skip to content

Migrating from 0.5.1 to 1.0 ​

SheetKit 1.0 puts finite limits on every operation, rejects malformed input that 0.5.1 accepted, and fixes many results that differed from Excel. Most applications upgrade without code changes, but some defaults, return shapes, errors, and saved output change on purpose.

This guide covers the Rust crates, the @sheetkit/node package, and the CLI. After upgrading, rebuild the native addon and recompile TypeScript against the new declarations. The Node.js package requires Node.js 24 or later.

Upgrading from 1.0.0 ​

SheetKit 1.0.1 fixes formula reads that did not match this guide in 1.0.0:

  • Node.js getCellValue returns a FormulaValue for a formula cell. 1.0.0 returned the expression as a string. Code that read that string should check value.type === 'formula' and read value.formula.
  • Node.js getRows and getRowsIterator set formula on formula cells. 1.0.0 left it unset.
  • A cell that shares a formula filled down or across in Excel reads as the formula shifted to that cell. For example, the cell below a master A2*2 reads A3*2. 1.0.0 returned an empty expression. This applies to Rust get_cell_value, get_rows, and get_cols and to the matching Node.js reads. The streaming reader still does not resolve these cells; see Known Limitations.
  • When a sheet has formula cells, Node.js getRows and getRowsIterator count the formula objects against limits.maxOutputBytes. A read that was close to the limit in 1.0.0 can now fail with a limit error.

What to check first ​

  • Resource limits. Open, formula, traversal, stream, save, encryption, and VBA operations use finite defaults. You can raise a limit for trusted input, but you cannot remove it.
  • Lazy open. Node.js opens workbooks lazily and defers auxiliary parts. Numeric cells with a date number format are read as dates.
  • Sparse rows. Row and column APIs return occupied cells only. Dense output is an explicit, bounded option.
  • Structured values. Node.js returns formula and date cells as objects instead of plain scalars.
  • Atomic failures. Formula, import, structural-edit, and save operations either complete or leave the workbook unchanged.
  • CSV. Output neutralizes formula-like text by default.
  • Async saves. save, writeBuffer, and saveWithPassword run in the background. The workbook cannot be modified until they settle.
  • Macros. Saving to .xlsx or .xltx removes VBA content.
  • Strict validation. Malformed references, buffers, tables, colors, package parts, and Node.js arguments now fail instead of loading or applying partially.

Resource limits and errors ​

Each operation applies its own limits. Limits are not global, are not stored on the workbook, and cannot exceed a fixed hard ceiling.

OperationRustNode.jsMain defaults
OpenOpenOptions, OpenLimits, CryptLimits, VbaLimitsOpenOptions128 MiB input, 4,096 ZIP entries, 512 MiB total expanded size, 256 MiB per XML part
FormulaFormulaLimitsFormulaLimits32 KiB per formula, 4 MiB total input, 16 MiB total output
TraversalTraversalOptions, TraversalLimitsTraversalOptions1,048,576 rows, 16,384 columns, 10 million cells, 512 MiB output
SaveSaveLimitsSaveLimits512 MiB archive, 128 MiB per XML part, 10 million rows and cells
Stream writeStreamLimitsStreamLimits16 MiB per row, 512 MiB total, 1,048,576 rows, 10 million cells
JSON importnonelimits option1,048,576 rows, 16,384 columns, 10 million cells, 512 MiB retained
CSVCSV optionsToCsvOptions64 MiB output

An operation counts several dimensions at once, so an input can fail even when each individual value is below its default.

Handling limit errors ​

In Rust, a limit failure is Error::ResourceLimitExceeded. Match it separately from invalid data and I/O errors:

rust
use sheetkit::{Error, OpenOptions, Workbook};

match Workbook::open_with_options("report.xlsx", &OpenOptions::default()) {
    Ok(workbook) => println!("{} sheets", workbook.sheet_names().len()),
    Err(Error::ResourceLimitExceeded(details)) => eprintln!(
        "{} exceeded {}: {} > {}",
        details.operation_code(),
        details.dimension_code(),
        details.observed(),
        details.limit(),
    ),
    Err(error) => return Err(error),
}

In Node.js, use isResourceLimitError instead of matching error messages. limit and observed are bigint:

typescript
import { isResourceLimitError, Workbook } from '@sheetkit/node';

try {
  const workbook = await Workbook.open('report.xlsx');
} catch (error) {
  if (!isResourceLimitError(error)) throw error;
  console.error(error.operation, error.dimension, error.limit, error.observed);
}

Argument errors, lifecycle errors, and wrong passwords remain ordinary errors.

Raising a limit ​

Raise only the dimension that a known, trusted file needs:

typescript
const workbook = await Workbook.open('trusted-large.xlsx', {
  maxOuterInputBytes: 256 * 1024 * 1024,
  maxUnzipSize: 768 * 1024 * 1024,
  maxZipEntries: 8_000,
});

maxUnzipSize and maxZipEntries are still accepted, but no value disables the archive checks. Zero, unsafe JavaScript numbers, values above the hard ceiling, and unknown modes are rejected.

maxXmlPartBytes can lower the 256 MiB per-part default. Raising it has no effect, because each ZIP entry is also limited to 256 MiB and Node.js does not expose that limit.

The 100:1 compression-ratio check applies only to entries larger than 8 MiB after decompression, so small repetitive parts such as shared strings open normally. Change the threshold with compressionRatioFloorBytes (Node.js) or OpenOptions::compression_ratio_floor_bytes (Rust); 0 checks every entry.

Opening workbooks ​

Defaults ​

Node.js open methods default to:

typescript
{ readMode: 'lazy', auxParts: 'deferred', dateInterpretation: 'numFmt' }

The Rust equivalents are ReadMode::Lazy, AuxParts::Deferred, and DateInterpretation::NumFmt.

A lazy workbook parses a worksheet or auxiliary part on first access, under the limits chosen at open. A part that exceeds maxXmlPartBytes can therefore fail on first access even though open succeeded. To validate the whole file up front, open eagerly:

typescript
const workbook = await Workbook.open('input.xlsx', {
  readMode: 'eager',
  auxParts: 'eager',
});

A lazy workbook keeps reading the file as it was at open time. Replacing the file afterward does not change what the workbook reads.

Filtering and partial reads ​

Sheets excluded with sheets are no longer decompressed or validated, and are copied unchanged on save. sheetRows stops parsing after the requested row. Neither option validates the rest of the file.

Legacy option names ​

parseMode: 'full' maps to eager open and parseMode: 'readfast' to lazy open. Use readMode in new code. readMode: 'stream' currently behaves like 'lazy'; use openSheetReader for forward streaming.

Files that now fail to open ​

Open now fails, in every read mode, when:

  • a part fails its CRC or decompression check (0.5.1 dropped such parts);
  • a ZIP entry name is duplicated, or two part names differ only by case;
  • a worksheet relationship points to another part, outside the package, or to an external target;
  • an entry's declared size does not match its content (a lazy workbook fails when it reads or copies that part).

A part that decompresses correctly but contains malformed XML is still kept.

Cell values and dates ​

Node.js cell values now use this type:

typescript
type CellValue = string | number | boolean | DateValue | FormulaValue | null;

With the default dateInterpretation: 'numFmt', a numeric cell with a date number format becomes a DateValue. Choose 'cellType' to follow the stored cell type literally. Rust returns CellValue::Date.

A formula cell keeps its formula and cached result:

typescript
const value = workbook.getCellValue('Sheet1', 'D2');
if (typeof value === 'object' && value?.type === 'formula') {
  console.log(value.formula, value.result);
}

Dates keep this shape in direct reads, row traversal, raw buffers, stream reads, and JSON import. Formulas come back as a FormulaValue from getCellValue, in the formula field of getRows, getRowsIterator, getCols, and openSheetReader cells, and are accepted in that shape by writes and JSON import. Raw buffers (getRowsBuffer, getRowsBufferV2, getRowsRaw), toJSON, and toCSV carry only the formula expression. Update type guards and serializers that expected a number or a flattened formula result.

In Rust, CompactCellRef::new and CompactCellRef::from_coordinates now return Result and enforce Excel's row and column bounds.

Formulas ​

Limits and atomic updates ​

Parsing, evaluation, calculateAll, setCellFormula, fillFormula, FormulaValue assignment, stream writes, JSON import, and raw buffer import share one FormulaLimits budget per operation. The budget is checked before any cell changes, so a failed bulk operation leaves no partial results.

typescript
workbook.fillFormula('Sheet1', 'D2:D1000', 'B2*C2', {
  operationInputBytes: 4 * 1024 * 1024,
  operationEvaluationSteps: 1_048_576,
});

Rust provides set_cell_formula_with_limits, fill_formula_with_limits, evaluate_formula_with_limits, and calculate_all_with_limits. The methods without limits use FormulaLimits::secure_default().

An error inside one cell, such as #DIV/0!, is stored as that cell's cached result. Only cycles and engine failures abort calculateAll.

Changed results ​

Update expected values in formula tests. Results now match Excel for:

  • ranges, blank cells, and exponentiation;
  • financial, holiday, TEXT, Unicode LEN, and Bessel functions;
  • SUM, AVERAGE, MIN, MAX, PRODUCT, MEDIAN, LARGE, SMALL, and RANK, which ignore booleans, text, and empty cells in references. AVERAGE over a reference with no numbers returns #DIV/0!. Values typed directly into the formula are still coerced, so SUM("5", TRUE) is 6;
  • DATE, EDATE, EOMONTH, and WORKDAY, which return #NUM! outside the Excel date range;
  • LEFT, RIGHT, MID, REPT, and REPLACE, which return #VALUE! for negative counts and clamp very large counts;
  • ROUND, ROUNDUP, and ROUNDDOWN, which never return NaN or infinity;
  • formulas reached through another sheet, which evaluate unqualified references, ROW(), and COLUMN() in their own sheet and cell.

Some inputs that 0.5.1 accepted are now rejected or bounded: wildcard criteria in COUNTIF(S), SUMIF(S), AVERAGEIF(S), and SEARCH; large periods in depreciation and cumulative-interest functions; SUBSTITUTE results that would grow too large; and places outside 1 to 10 in base-conversion functions.

Structural edits ​

Inserting, deleting, and copying rows and columns now shifts references token by token instead of by text replacement. Formulas, defined names, data validations, conditional formats, charts, tables, and other dependent parts update together, or the edit fails without changes. Remove any workaround that rewrote formula text yourself.

Behavior to expect:

  • removeRow and removeCol replace references to deleted cells with #REF! and shrink ranges that lose rows or columns. Merges, validations, conditional formats, hyperlinks, auto-filters, comments, and row page breaks shrink or are removed.
  • Removing a column inside a table removes that table column. An edit that would remove a table's header row, totals row, all data rows, or all columns is rejected.
  • insertRows and insertCols fail if a comment would move off the sheet.
  • duplicateRow writes shared formulas on the copy as ordinary formulas.
  • Whole-row and whole-column references such as 1:1 and A:A shift. Whole-sheet references (1:1048576, A:XFD) and 3-D references do not.
typescript
workbook.setCellFormula('Sheet1', 'B1', 'A5+SUM(A1:A5)');
workbook.removeRow('Sheet1', 5);
// B1 now holds '#REF!+SUM(A1:A4)'.

Sheet copy and deletion now handle every part the sheet owns: drawings, charts, images, tables, comments, defined names, and controls. copySheet gives copied tables names that do not collide with existing table or defined names, ignoring case.

Generated IDs ​

New relationship IDs, sheet IDs, custom property IDs, pivot cache IDs, table IDs, and custom number format IDs take the lowest unused value. After deleting rId2, the next relationship is rId2, not rId4. Conditional format priorities still use the current maximum plus one, so evaluation order does not change.

Rows, columns, raw buffers, JSON, CSV, and HTML ​

Sparse traversal ​

In 0.5.1, one distant cell could make row APIs expand the whole rectangle up to it. In 1.0, getRows, getCols, raw buffers, toJSON, toCSV, and toHTML return occupied data only, within traversal limits:

typescript
const rows = workbook.getRows('Sheet1', {
  mode: 'sparse',
  limits: { maxCells: 2_000_000, maxOutputBytes: 128 * 1024 * 1024 },
});

Each row carries its absolute row and column numbers. Do not count array positions to infer blank rows. Sparse CSV and HTML also drop leading and internal empty columns.

If a consumer needs the full rectangle, request dense v1 output with explicit limits and decode it with SheetData:

typescript
import { SheetData } from '@sheetkit/node';

const buffer = workbook.getRowsBuffer('Sheet1', {
  mode: 'dense',
  limits: { maxCells: 250_000, maxOutputBytes: 64 * 1024 * 1024 },
});
const dense = new SheetData(buffer).toArray();

Dense mode exists only for the v1 buffer format. getCols rejects it.

Raw buffers ​

The v1 format now defaults to sparse output. The v2 format is always sparse, with absolute coordinates and inline strings. Both decoders validate the whole buffer before returning data, and setSheetDataBuffer validates everything before writing any cell. Treat buffers from outside the process as untrusted; a rejected buffer leaves the workbook unchanged and usable.

JSON ​

toJSON returns plain objects with own data properties. A header named __proto__ becomes an ordinary property and no longer replaces the row's prototype.

fromJSON reads array indexes and own data properties once, and ignores custom iterators. Proxies, getters, duplicate headers, invalid coordinates, and malformed date or formula objects are rejected without changing the sheet.

typescript
workbook.fromJSON('Sheet1', records, {
  header: true,
  startCell: 'A1',
  limits: { maxRows: 10_000, maxColumns: 100 },
});

CSV and HTML ​

CSV output adds an apostrophe before text that starts like a formula, so a spreadsheet does not execute it. Use raw-exchange only when the receiver is not a spreadsheet and the data is trusted:

typescript
const safeCsv = workbook.toCSV('Sheet1');
const rawCsv = workbook.toCSV('Sheet1', { formulaPolicy: 'raw-exchange' });

CSV output is limited to 64 MiB. HTML escapes text and attributes for their context and counts the final HTML size against limits.maxOutputBytes.

Streaming ​

Reading ​

Use openSheetReader for large forward reads:

typescript
const reader = await workbook.openSheetReader('Sheet1', { batchSize: 500 });
for await (const batch of reader) {
  for (const row of batch) processRow(row);
}

The reader reads from the package or a bounded temporary file instead of cloning the worksheet. Batch size must be a positive integer up to 100,000. A truncated worksheet raises an error instead of returning partial rows. Rust uses open_sheet_reader or open_sheet_reader_owned.

Writing ​

newStreamWriter (Node.js) and StreamWriter::new (Rust) use finite defaults. Pass limits when creating the writer:

typescript
const writer = workbook.newStreamWriter('Export', {
  rowCount: 500_000,
  outputBytes: 256 * 1024 * 1024,
});

Rust uses StreamWriter::new_with_limits. A row or batch is checked before any of it is written, so a rejected row is not appended. After an I/O failure the writer refuses further calls. addMergeCell rejects overlapping ranges.

Saving ​

Limits and atomic replacement ​

All save methods use SaveLimits. In Rust, choose limits with save_with_limits or save_to_buffer_with_limits; in Node.js, pass them as the second argument:

typescript
await workbook.save('output.xlsx', {
  outputBytes: 768 * 1024 * 1024,
  xmlPartBytes: 256 * 1024 * 1024,
});

xmlPartBytes applies to every worksheet written, including unchanged sheets that a lazy save copies as is. A save that passed under a low limit in 0.5.1 can now fail.

A path save writes a temporary file in the same directory and replaces the destination only when the write is complete. If the save fails, the existing file is unchanged. Saving through a symlink replaces the target file and keeps the link. On Unix, an existing file keeps its permission bits, and a new file is created with mode 0o666 minus the process umask.

In Node.js, saveWithPassword and saveWithPasswordSync take CryptLimits instead of SaveLimits. In Rust, save_with_password_with_output_limits accepts both.

Durable saves ​

By default, a save is atomic but not flushed to disk. A power loss or OS crash right after a save can leave the old file, the new file, or, on some file systems, an empty or incomplete file. An application crash still leaves the previous file intact, though a forced kill can leave a .sheetkit-* temporary file behind.

Request durability when the file must survive a power loss:

rust
use sheetkit::{SaveLimits, SaveOptions};

workbook.save_with_options("output.xlsx", SaveLimits::default(), SaveOptions { durable: true })?;
typescript
await workbook.save('output.xlsx', undefined, { durable: true });

A durable save flushes the temporary file before the rename and, on Unix, syncs the directory afterward. On macOS this uses F_FULLFSYNC and adds a few milliseconds per save. If the directory sync fails, the error says that the new file is already in place. Encrypted saves accept the same option through save_with_password_with_options in Rust and the last options argument in Node.js. Network file systems and drives that ignore flush requests weaken these guarantees.

Async saves ​

save, writeBuffer, and saveWithPassword now run in a background task. Modifying the workbook while one is pending throws workbook has an async save operation in progress:

typescript
await workbook.save('output.xlsx');
workbook.setCellValue('Sheet1', 'A1', 'next');

Once the task settles, successfully or not, the workbook can be modified and saved again. Separate workbooks can save at the same time.

Saved content ​

A lazy save copies unchanged parts byte for byte and rewrites only edited parts. Shared strings keep significant whitespace and rich-text runs, and a rich-text string no longer shares an entry with a plain string of the same text. Custom document properties keep empty strings. Compare saved files by reopening them rather than by ZIP bytes, which can change even when the content does not.

Macros ​

The file extension decides whether VBA is kept:

TargetResult
.xlsx, .xltxVBA parts, relationships, and content types are removed
.xlsm, .xltm, .xlamVBA is kept

If you saved a macro workbook as .xlsx and relied on the VBA staying inside, save it as .xlsm instead.

Encryption and VBA ​

Password open applies CryptLimits before key derivation and keeps the same open limits for the decrypted package. Standard and Agile encryption, integrity keys, and package HMAC now follow Office behavior. Agile files whose password key and package key differ in size now open; a saltSize outside 1 to 65536 is rejected. Re-check encrypted test files made with 0.5.1 in Excel or another Office-compatible tool. To raise limits for a large encrypted file, set both the open and the crypt limits.

VBA inspection uses one VbaLimits budget per operation. Module names come from the Unicode stream-name record when present. Source in CP932, CP949, or CP936 is decoded lossily: ASCII (and half-width katakana for CP932) is kept, other double-byte characters become U+FFFD, and each affected module adds a warning to the project's warnings list.

Charts, tables, pivots, and other parts ​

Adding or changing these features now writes every related part and relationship, or fails without changes:

  • pivot tables write their cache, records, and relationships with unique IDs;
  • slicers write their cache and view extensions and validate placement;
  • form controls keep their VML and property relationships and escape labels;
  • charts, drawings, images, shapes, and sparklines use unique IDs; stock charts require the documented series count and order;
  • tables check their width before changing anything and always write headers; names follow Excel rules (1 to 255 characters, not cell-reference-like, and unique among tables and defined names, ignoring case);
  • conditional formats write consistent priorities, formulas, and styles;
  • protection hashes, data validation, page breaks, and comments follow Office semantics.

A table part that no worksheet clearly owns is reported as invalid instead of being attached to the first sheet. Lazy workbooks load sparklines, pictures, comments, controls, names, and merges before any edit that needs them, so you can remove eager-open workarounds added to keep them.

Regenerate golden files for these features and check them by saving and reopening, or in Excel.

Styles and rendering ​

Theme tints, inherited styles, and number formats now render as in Excel, so formatted text and SVG snapshots change. SVG rendering escapes font names itself; pass plain names such as A&B Sans, not escaped ones. Colors must be 6-digit RGB or 8-digit ARGB hex, optionally prefixed with #; anything else is rejected.

Node.js argument checks ​

  • Integer arguments and numeric options reject fractions, NaN, infinity, unsafe integers, and out-of-range values instead of truncating them.
  • Unknown style and conditional format enum strings throw an error that lists the accepted values.

CLI ​

Text output from info, sheets, read, and get escapes terminal control characters and is size-limited. Scripts should use JSON:

bash
sheetkit sheets input.xlsx --format json

read --format csv and convert neutralize formula-like text by default. Keep it raw only for trusted exchange:

bash
sheetkit read input.xlsx --sheet Sheet1 --format csv \
  --csv-formula-policy raw-exchange

If output exceeds its limit, nothing is written. A failed convert leaves the existing destination file unchanged.

Performance ​

On a 100,000-row workbook, compared with 0.5.1 on an Apple M4 Pro with Node.js 24, lazy open is about 29% faster and saving an unchanged lazy workbook about 45% faster. Reading rows, streaming, and saving after edits are within 4% of 0.5.1. Producing a v2 buffer is about 7% (1.8 ms) slower because of the added validation. If your workload differs, measure it with your own files.

Upgrade checklist ​

  1. Upgrade Rust and Node.js packages together, rebuild, and recompile TypeScript. Use Node.js 24 or later.
  2. Replace error-message matching with Error::ResourceLimitExceeded or isResourceLimitError.
  3. Raise limits only for trusted files that need it.
  4. Decide between lazy and eager open, and check date handling.
  5. Update type guards and serializers for DateValue and FormulaValue.
  6. Update row consumers for sparse output with absolute coordinates.
  7. Update expected values for formulas, structural edits, number formats, and SVG output.
  8. Choose a CSV formula policy explicitly and use JSON output from the CLI.
  9. Await async saves before modifying the workbook.
  10. Pass durable: true to saves that must survive a power loss.
  11. Save macro workbooks as .xlsm, .xltm, or .xlam.
  12. Save and reopen representative files with tables, pivots, charts, slicers, controls, comments, and drawings.

Released under the MIT / Apache-2.0 License.