How to read an Excel file in Node.js
This guide covers the server side of reading Excel files: opening a workbook from disk, parsing one that is already in memory as a Buffer, accepting uploads in an Express API, and processing a whole folder. It uses TabularJS, which reads .xlsx and .xls (plus ODS, CSV and a dozen other formats) and returns plain arrays.
Every snippet below is an ES module. Save it as .mjs, or add "type": "module" to your package.json, and run it with Node.js 18 or later.
Install
Section titled “Install”npm install tabularjsimport tabularjs from 'tabularjs';TabularJS has one function, tabularjs(file, options), and it returns a Promise. In Node.js, file can be a path or bytes (Buffer, Uint8Array, ArrayBuffer).
Read a workbook from a file path
Section titled “Read a workbook from a file path”Pass the path. TabularJS reads the file and chooses the parser from the extension.
import tabularjs from 'tabularjs';
const result = await tabularjs('./data/orders.xlsx');
for (const sheet of result.worksheets) { console.log(sheet.worksheetName, sheet.data.length, 'rows');}
const orders = result.worksheets[0];console.log(orders.data);Output:
Orders 4 rowsCustomers 3 rows[ [ 'Order', 'Customer', 'Date', 'Qty', 'Price', 'Total', 'Paid' ], [ 1001, 'Acme Corp', 45366, 3, 19.99, '=D2*E2', true ], [ 1002, 'Globex', 45369, 8, 5.5, '=D3*E3', false ], [ 1003, 'Initech', 45371.60416666666, 12, 2.25, '=D4*E4', true ]]A few things to notice in that output:
result.worksheetshas one entry per sheet, in workbook order. The tab name isworksheetName.datais an array of rows, and each row is an array of cell values:data[row][col], zero-based. The header row isdata[0]; it is not removed.- Numbers are numbers and TRUE/FALSE are booleans. The
Datecolumn holds numbers too (see Dates). Totalcontains the formula text, not its result. TabularJS does not evaluate formulas.- Empty cells are
'', and every row has the same length.
The same code reads an .xls file: change the path, nothing else. Relative paths are resolved from the current working directory, not from the script’s folder. If you run scripts from different places, build the path from import.meta.dirname (Node 20.11+) or process.cwd() explicitly.
Each worksheet also has columns, cells, style, mergeCells, comments and more. They are described in Output structure. To turn rows into objects keyed by the header, see Convert Excel to JSON.
Read from a Buffer
Section titled “Read from a Buffer”Files do not always come from disk. Downloads from S3, attachments from an email parser, rows from a bytea column: they all give you a Buffer. Pass it directly. Bytes have no file name, so the format is detected from the content. XLSX (a zip container) and XLS (an OLE2 container) are both recognized.
import { readFile } from 'node:fs/promises';import tabularjs from 'tabularjs';
const buffer = await readFile('./data/orders.xls');const result = await tabularjs(buffer); // format detected from the bytes
console.log(result.worksheets.map((s) => s.worksheetName));// [ 'Orders', 'Customers' ]For a remote file, fetch returns an ArrayBuffer, which works the same way:
const response = await fetch('https://example.com/report.xlsx');if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await tabularjs(await response.arrayBuffer());If you know the bytes are CSV and need options such as a delimiter, pass extension: 'csv' so detection is skipped. See the API reference for the detection order.
Handle Excel uploads in Express with multer
Section titled “Handle Excel uploads in Express with multer”For an upload endpoint, keep the file in memory with multer’s memoryStorage() and pass req.file.buffer to TabularJS. Nothing is written to disk.
npm install express multer tabularjsimport express from 'express';import multer from 'multer';import path from 'node:path';import tabularjs from 'tabularjs';
const ALLOWED = new Set(['.xlsx', '.xls', '.ods', '.csv']);
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB fileFilter: (req, file, cb) => { cb(null, ALLOWED.has(path.extname(file.originalname).toLowerCase())); },});
const app = express();
app.post('/import', upload.single('file'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'Send an .xlsx, .xls, .ods or .csv file in the "file" field' }); }
try { // No file name needed: the format is detected from the bytes const result = await tabularjs(req.file.buffer);
res.json({ file: req.file.originalname, sheets: result.worksheets.map((s) => ({ name: s.worksheetName, rows: s.data.length, preview: s.data.slice(0, 3), })), }); } catch (err) { res.status(422).json({ error: err.message }); }});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));Try it with curl:
{ "file": "orders.xlsx", "sheets": [ { "name": "Orders", "rows": 4, "preview": [ ["Order", "Customer", "Date", "Qty", "Price", "Total", "Paid"], [1001, "Acme Corp", 45366, 3, 19.99, "=D2*E2", true], [1002, "Globex", 45369, 8, 5.5, "=D3*E3", false] ] }, { "name": "Customers", "rows": 3, "preview": [["Name", "Country"], ["Acme Corp", "US"], ["Globex", "DE"]] } ]}Why detect from the bytes instead of trusting the name? Users rename files. An .xls saved as report.xlsx still parses correctly this way, while forcing extension: 'xlsx' would fail with a zip error. The file filter on the name is still useful to reject obvious mistakes early.
Two details about uploads:
- The
limits.fileSizecap matters. TabularJS already guards against zip bombs (each part of an XLSX is capped at 256 MB once inflated), but there is no reason to accept a 500 MB upload in an import form. - Content that is not a recognized binary format is read as text. A random text file renamed to
.xlsxreturns a one-sheet result instead of an error. Validate what you got, for example by checking that the header row has the columns you expect, before importing it.
The same pattern works in other frameworks: Fastify (@fastify/multipart gives you await part.toBuffer()), Next.js route handlers (Buffer.from(await file.arrayBuffer()), see the Next.js example) and serverless functions. A shorter version is in the Express upload example.
Batch-convert a folder
Section titled “Batch-convert a folder”To process every spreadsheet in a directory, list the files, parse each one, and write the result next to it. Catch errors per file so one broken workbook does not stop the run.
import { readdir, writeFile } from 'node:fs/promises';import path from 'node:path';import tabularjs from 'tabularjs';
const SPREADSHEET = /\.(xlsx|xls|ods|csv)$/i;const dir = process.argv[2] ?? './data';
const files = (await readdir(dir, { recursive: true })) .filter((name) => SPREADSHEET.test(name));
const summary = [];
for (const name of files) { const fullPath = path.join(dir, name); try { const result = await tabularjs(fullPath); const sheets = result.worksheets.map((s) => ({ name: s.worksheetName ?? path.parse(name).name, rows: s.data, })); await writeFile(fullPath + '.json', JSON.stringify(sheets, null, 2)); summary.push({ file: name, sheets: sheets.length, ok: true }); } catch (err) { summary.push({ file: name, ok: false, error: err.message }); }}
console.table(summary);┌─────────┬───────────────┬────────┬──────┐│ (index) │ file │ sheets │ ok │├─────────┼───────────────┼────────┼──────┤│ 0 │ 'euro.csv' │ 1 │ true ││ 1 │ 'orders.xls' │ 2 │ true ││ 2 │ 'orders.xlsx' │ 2 │ true ││ 3 │ 'people.csv' │ 1 │ true │└─────────┴───────────────┴────────┴──────┘readdir with recursive: true needs Node.js 20.1 or later. CSV files have a single sheet without a worksheetName, which is why the script falls back to the file name.
Files are processed one after the other on purpose. Each workbook is fully loaded in memory while it is parsed, so running hundreds in parallel with Promise.all can exhaust memory on large files. If you need more throughput, process a few at a time, or spread the work over worker_threads. Another version of this script is in the batch directory example.
Dates are serial numbers
Section titled “Dates are serial numbers”In the output above, 45366 is a date. Excel stores dates as the number of days since 1899-12-30 and uses the cell’s number format to display them. TabularJS returns the stored number and exposes the format in worksheet.cells:
import tabularjs from 'tabularjs';
// Excel stores dates as days since 1899-12-30 (the 1900 date system)function excelDateToJS(serial) { return new Date(Math.round((serial - 25569) * 86400 * 1000));}
const [orders] = (await tabularjs('./data/orders.xlsx')).worksheets;
console.log(orders.data[1][2]); // 45366console.log(orders.cells.C2.format); // 'yyyy-mm-dd'console.log(excelDateToJS(orders.data[1][2]).toISOString());// 2024-03-15T00:00:00.000Zconsole.log(excelDateToJS(orders.data[3][2]).toISOString());// 2024-03-20T14:30:00.000ZThe fraction is the time of day. Treat the result as UTC: Excel dates have no time zone. Workbooks created on old versions of Mac Excel may use the 1904 date system, where serials are 1462 days smaller; if dates come out four years early, that is the cause.
Handle errors
Section titled “Handle errors”tabularjs rejects the Promise when it cannot read a file. Wrap calls in try/catch and translate the messages that matter to your users:
import tabularjs from 'tabularjs';
async function readSpreadsheet(path) { try { return await tabularjs(path); } catch (err) { if (err.code === 'ENOENT') throw new Error(`File not found: ${path}`); if (/encrypted/i.test(err.message)) throw new Error(`${path} is password protected`); if (/Unsupported file type/.test(err.message)) throw new Error(`${path} is not a spreadsheet`); throw err; }}The common messages are Unsupported file type: pdf for a format TabularJS does not read, Workbook is encrypted with a password for an .xls protected with a password to open, and Node’s own ENOENT error when the path does not exist. The complete list is in the API reference.
Common pitfalls
Section titled “Common pitfalls”Expecting computed values. Formula cells contain the formula ('=D2*E2'), never the result. If you need numbers, compute them in JavaScript from the input cells, or ask for a file exported with values only.
Using worksheets[0] blindly. Workbooks often have a hidden first sheet (a lookup table, a template). Hidden sheets are returned with worksheetState: 'hidden' or 'veryHidden'. Find the sheet by name instead: result.worksheets.find((s) => s.worksheetName === 'Orders').
Assuming the header is on row 1. Real-world reports often have a title, a blank row, then the header. Look for the header row (for example, the first row whose first cell is 'Order') and slice from there.
Writing a file. TabularJS only reads. To produce an .xlsx from Node.js, use a writer such as ExcelJS.
Very large exports. Each file is read fully into memory. For CSV exports of several gigabytes, a streaming CSV parser such as csv-parse is a better fit than any whole-file reader.
Related
Section titled “Related”- API reference: inputs, options and errors
- Convert Excel to JSON: objects from the header row, all sheets, dates
- Read an Excel file in the browser
- Parse CSV in JavaScript
- Formats: XLSX, XLS, ODS
- Examples: Node.js basic, Express upload, batch directory, XLS formulas

