DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

Writing a million-row .xlsx from Dart without running out of memory

An FFI wrapper over libxlsxwriter, and the memory numbers that made me reach for it.

I had a report endpoint that exported a table to .xlsx. It worked in testing. Then a customer with 400,000 rows hit it and the container OOM-killed the process.

The Dart package most people reach for is excel. It builds the whole workbook as an object graph in memory, serializes it at the end, and hands you the bytes. That is fine for a config sheet with 50 rows. It is the wrong model for a server generating large exports, and the package has had no release since 2024, so the memory behavior is not going to change. There is a maintained fork, excel_community, which is the one to use if you want pure Dart. It is better. It is still an in-memory model.

The measurement

I wrote 100,000 rows by 10 columns and measured peak RSS. Same data, four writers:

Writer Peak memory
xlsxwriter, constant-memory mode 191 MiB
xlsxwriter, in-memory default 314 MiB
excel_community 614 MiB
excel (unmaintained) 1778 MiB

The single number matters less than the shape. Peak memory as the row count grows:

Rows constant-memory in-memory default
10,000 ~191 MiB low
100,000 ~191 MiB 314 MiB
1,000,000 ~191 MiB 1433 MiB

The in-memory default climbs with the data. At a million rows it is at 1433 MiB and still going. The constant-memory mode does not move. It stays at ~191 MiB the whole way because it flushes each row to a temp file on disk as you write it, so the row you just wrote is not held anywhere. Nothing accumulates. That flat line is the reason the package exists.

Peak memory vs row count: constant-memory mode stays flat at ~191 MiB while the in-memory default climbs to 1433 MiB at a million rows

Measured on Apple Silicon with Dart 3.11.

What it is

xlsxwriter is an FFI binding over libxlsxwriter, a mature C library for writing .xlsx. The C library does the XML and ZIP work. The Dart package is the type-dispatch layer and the resource management around it.

The API is close to the C one. You open a workbook, add a worksheet, write cells.

import 'package:xlsxwriter/xlsxwriter.dart';

void main() {
  final workbook = Workbook('report.xlsx');
  final sheet = workbook.addWorksheet('Data');

  sheet.writeString(0, 0, 'Name');
  sheet.writeString(0, 1, 'Amount');
  sheet.writeString(1, 0, 'Widget');
  sheet.writeNumber(1, 1, 12.50);

  workbook.close();
}
Enter fullscreen mode Exit fullscreen mode

For the row-at-a-time case there is writeRow, which takes a List<Object?> and dispatches each element by runtime type. String, int, double, bool, DateTime, and null each go to the right cell type.

final rows = queryLargeTable(); // Iterable, not materialized

final dateFmt = workbook.addFormat().numberFormat('yyyy-mm-dd');

var r = 0;
for (final row in rows) {
  sheet.writeRow(r++, [
    row.name,        // String
    row.count,       // int
    row.total,       // double
    row.active,      // bool
    row.createdAt,   // DateTime, needs dateFmt
    row.note,        // may be null
  ], dateFormat: dateFmt);
}
Enter fullscreen mode Exit fullscreen mode

To get the flat memory line, turn on constant-memory mode when you create the workbook. It changes the trade-off. Rows must be written top-to-bottom, because each one is flushed as you go and cannot be revisited.

final workbook = Workbook.constantMemory('big_report.xlsx');
final sheet = workbook.addWorksheet('Data');

var r = 0;
await for (final row in streamRowsFromDb()) {
  sheet.writeRow(r++, row.toCells());
}
workbook.close();
Enter fullscreen mode Exit fullscreen mode

You never hold more than one row.

Beyond plain cells

insertImage embeds a PNG or JPEG directly from bytes, so you can drop a chart you rendered elsewhere into a cell:

final logo = await File('logo.png').readAsBytes();
sheet.insertImage(0, 0, logo);
Enter fullscreen mode Exit fullscreen mode

Conditional formatting is there too: cell rules, color scales, and data bars, the same three families libxlsxwriter exposes.

The honest comparison

The fair fight is not against the abandoned excel. It is against excel_community, the fork you would actually pick. Against that fork, xlsxwriter uses 3.2x less memory (191 MiB vs 614 MiB at 100k rows) and runs 1.7x faster.

On throughput alone the fork is close enough that speed would not, by itself, be a reason to switch. Memory is the reason. If your export runs on a box with headroom and never gets large, excel_community is pure Dart, reads as well as writes, and has none of the deployment friction below. That is a real advantage and I would use it there.

When not to use it

  • You need to read .xlsx. This package writes. It does not read. For reading, use excel_community.
  • You target the web. It is an FFI package. It does not run on web.
  • You cannot ship native code. FFI means you need libxlsxwriter present: a C toolchain to build it, or a prebuilt binary for your platform. On a controlled server that is a one-time setup. If your deployment cannot include a native library, use the pure-Dart fork.
  • Your sheets are small. At a few hundred rows none of this matters. Pick whatever reads and writes and stay pure Dart.

The narrow case this is built for is a server writing large .xlsx files where the process has a memory ceiling. That was my case. The flat 191 MiB line is what got the export off the OOM-killer.

Top comments (0)