Skip to content
datahacks.dev
Excel

Reading Excel Files in Node With exceljs Step by Step

Read and write xlsx in Node.js with exceljs: load a workbook, read typed cells, handle formulas and dates, stream large sheets and avoid common traps.

7 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar, with the Node.js logo.

JavaScript services read Excel files more often than anyone planned: an upload endpoint for price lists, an import job for a partner's weekly sheet, a script that turns a workbook into JSON. In Node, the library most people reach for is exceljs. It reads and writes xlsx, preserves styles and has a streaming mode for files that do not fit in memory. This post goes through reading a workbook, the types you get back and the ways they surprise people, streaming for large files and the care needed with files you did not make. It is a spoke of reading and writing Excel from JavaScript, Java and C Sharp.

Install and load

Shell
npm install exceljs
JavaScript
const ExcelJS = require('exceljs');

async function main() {
  const workbook = new ExcelJS.Workbook();
  await workbook.xlsx.readFile('orders.xlsx');

  const sheet = workbook.getWorksheet('Orders');   // or workbook.worksheets[0]
  console.log(sheet.name, sheet.rowCount, sheet.columnCount);
}
main();

readFile parses the whole file into memory. Everything is asynchronous, so use await or promises. workbook.xlsx.load(buffer) reads from a Buffer, which is what you want for an uploaded file that you have not saved to disk. exceljs reads xlsx only; it does not read the old xls format. getWorksheet returns undefined if the name is wrong, so check before using it.

Read rows

JavaScript
const rows = [];
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
  if (rowNumber === 1) return;            // header
  rows.push({
    order: row.getCell(1).value,
    date: row.getCell(2).value,
    customer: row.getCell(3).value,
    amount: row.getCell(4).value,
  });
});

eachRow calls your function for each row that has content. Rows and columns are numbered from 1, in line with Excel. row.values returns an array of the cell values, and it is one-based: index 0 is empty, so the first column is row.values[1]. Code that uses row.values as if it were zero-based reads the wrong columns, a classic off-by-one bug.

If the first row is a header, build objects from it:

JavaScript
const header = sheet.getRow(1).values.slice(1).map(v => String(v).trim());
const data = [];
for (let r = 2; r <= sheet.rowCount; r++) {
  const vals = sheet.getRow(r).values.slice(1);
  if (vals.every(v => v == null || v === '')) continue;
  data.push(Object.fromEntries(header.map((h, i) => [h, vals[i]])));
}

sheet.rowCount is the index of the last row with data, which can include trailing rows with formatting only; skip empty rows as above. sheet.actualRowCount counts rows that have values.

Cell values are typed, and sometimes objects

A cell's value is a JavaScript value, with rules.

In the sheetcell.value
Textstring
Numbernumber
Booleanboolean
Date (date-formatted cell)Date
Emptynull
Formula{ formula: 'A1+B1', result: 42 }
Shared formula{ sharedFormula: 'A2', result: 42 }
Hyperlink{ text: 'Docs', hyperlink: 'https://...' }
Rich text{ richText: [{ text: 'Hello ' }, { text: 'world', font: { bold: true } }] }
Error{ error: '#N/A' }

A formula cell is an object, not its result. To get the value, use cell.result or check the type. A helper that normalizes any cell to a plain value avoids crashes downstream:

JavaScript
function plain(v) {
  if (v == null) return null;
  if (typeof v === 'object' && !(v instanceof Date)) {
    if ('result' in v) return plain(v.result);
    if ('richText' in v) return v.richText.map(t => t.text).join('');
    if ('text' in v) return v.text;
    if ('error' in v) return null;
  }
  return v;
}

Formula results are the cached values saved by the program that wrote the file. A workbook written by a library without a spreadsheet engine has no cached results, and result is undefined (handling Excel formulas and cached values).

Use cell.text for the display text of a cell, as formatted in Excel, and cell.value for the underlying value. A cell showing 1,234.50 has the number 1234.5 as its value.

Dates and time zones

Excel stores dates as serial numbers, and exceljs turns date-formatted cells into JavaScript Date objects. It converts using UTC, so a cell with 2025-03-14 becomes a Date for midnight UTC. Printing it in a local time zone west of UTC shows 2025-03-13. The fix is to treat it as a UTC date:

JavaScript
const d = cell.value;                    // Date
const iso = d.toISOString().slice(0, 10); // '2025-03-14', the date as stored

Use getUTCFullYear() and friends, not the local getters. Dates stored as text stay strings and need parsing. A cell that is a number formatted as a date but that exceljs did not recognize arrives as a number, a serial; convert with new Date(Date.UTC(1899, 11, 30) + serial * 86400000). Workbooks using the 1904 date system need a shift; exceljs exposes workbook.properties.date1904.

Merged cells, hidden rows and tables

Merged cells share a master cell. The other cells in the range report the master's value in exceljs (they are linked to it), which differs from some libraries that give blanks. Check cell.isMerged and cell.master if you need to know. Hidden rows and columns are still read; check row.hidden. Excel Tables are exposed through sheet.getTables() in recent versions. Data validation and conditional formatting are available as properties if you need them.

Large files: the streaming reader

The regular reader builds the entire workbook in memory, which fails for files with hundreds of thousands of rows. exceljs has a streaming reader that processes rows as they are parsed:

JavaScript
const reader = new ExcelJS.stream.xlsx.WorkbookReader('big.xlsx', {
  sharedStrings: 'cache',
  hyperlinks: 'ignore',
  styles: 'ignore',
  worksheets: 'emit',
});

let n = 0;
for await (const worksheet of reader) {
  for await (const row of worksheet) {
    n++;
    handle(row.values);
  }
}
console.log(n, 'rows');

Options control what is kept: sharedStrings: 'cache' keeps the shared strings table in memory so text cells resolve ('emit' yields them as events, 'ignore' drops them and text cells come back empty), and ignoring styles and hyperlinks saves memory and time. Streaming rows have no random access, and some information (merged cells, tables) is not available. Process each row and discard it. For comparisons and other approaches to the memory problem see reading a 500 MB Excel file without running out of memory.

Write a workbook

Writing is the mirror image:

JavaScript
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Report');
ws.columns = [
  { header: 'Order', key: 'order', width: 12 },
  { header: 'Date', key: 'date', width: 12, style: { numFmt: 'yyyy-mm-dd' } },
  { header: 'Amount', key: 'amount', width: 12, style: { numFmt: '#,##0.00' } },
];
ws.addRow({ order: '0007', date: new Date(Date.UTC(2025, 2, 14)), amount: 1234.5 });
ws.getRow(1).font = { bold: true };
ws.views = [{ state: 'frozen', ySplit: 1 }];
await wb.xlsx.writeFile('report.xlsx');

Write identifiers as strings to keep leading zeros, dates as Date objects with a number format, and numbers as numbers. For large outputs, use the streaming writer: new ExcelJS.stream.xlsx.WorkbookWriter({ filename: 'big.xlsx' }), add rows and call row.commit() on each, then await workbook.commit(). Committed rows are flushed to disk and cannot be changed afterwards.

When user data goes into cells, strings that begin with =, +, - or @ can be interpreted as formulas by Excel when someone opens the file. Prefix such values with a single quote or write them as strings with a text format (escaping formula characters in a CSV export describes the same risk).

Untrusted files

A workbook uploaded by a user is input from outside, and parsing happens in your process. An xlsx is a zip, so a small file can expand to a very large one. Set limits: reject files over a maximum size, count rows and stop at a ceiling, run the parse in a worker with a timeout and a memory limit, and never put the uploaded file's name into a path without sanitizing it. Validate the contents against the structure you expect before processing: sheet name, header row, column types. Reject the rest with a clear message. Do not evaluate any text from cells as code or as a query.

Check the result

Compare the number of rows read with the workbook's, and a control total with Excel's. Print the types of the first rows. Check dates near the day boundary, a formula cell, a rich-text cell and an empty row. Test with a file saved by Excel, one saved by LibreOffice and one produced by a script, since each writes slightly differently.

When exceljs is not the right tool

You only need the data of a huge file. A command-line converter or a database may be faster than Node (converting a huge Excel file to CSV or Parquet).

You need the old xls format. exceljs does not read it; convert first or use another library.

The code runs in a browser. exceljs works there but is heavy; consider the lighter SheetJS (reading Excel in the browser with SheetJS).

You need to calculate formulas. exceljs does not calculate. Use a spreadsheet engine or compute in code.

See the exceljs repository documentation and Node's page on streams. To convert a small result to JSON, use the CSV to JSON converter.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.