Exporting millions of rows without running out of memory
Problem: "How do I export 20 million rows every night without loading
them all into memory?" — the question that kills a naive SELECT * +
res.json() approach the first time it runs against production-sized data.
When to use DataEgress for this
- Your table is large enough that
SELECT * FROM tableeither times out, OOMs the process, or takes long enough that you'd want it running in the background with visible progress, not blocking a request. - You want the pagination logic (page size, cursor advancement, when to stop) handled for you, driven by a query you still write and control.
When NOT to use DataEgress for this
- Your dataset is a few thousand rows. This is architecture for a problem you don't have yet — a plain query + CSV response is faster to ship and easier to reason about.
Architecture
You write fetchPage({ tenantId, cursor, limit }) — a single page's worth of
rows, using whatever cursor strategy fits your schema (an incrementing ID, a
composite key, updatedAt, whatever). DataEgress calls it in a loop:
cursor = null
loop:
{ rows, nextCursor } = await fetchPage({ tenantId, cursor, limit: 5000 })
write rows to the output file (CSV: streamed to disk; Parquet: encoded
into a row group immediately)
if nextCursor is null: done
cursor = nextCursor
At no point does DataEgress hold the full dataset as JS objects in memory — only one page (default 5,000 rows) at a time. Verified at 1,000,000 rows: completes in ~3.4 seconds for CSV, ~5 seconds for Parquet, with the process's memory footprint bounded by page size, not total row count.
Full implementation
const events = defineDataset({
id: "events",
fetchPage: async ({ tenantId, cursor, limit }) => {
// Cursor-based pagination on an indexed, monotonic column — the pattern
// that scales. Avoid OFFSET-based pagination on large tables; it gets
// slower as the offset grows.
const rows = await db.query(
`SELECT * FROM events WHERE tenant_id = $1 AND id > $2 ORDER BY id ASC LIMIT $3`,
[tenantId, cursor ? Number(cursor) : 0, limit]
);
return {
rows,
nextCursor: rows.length < limit ? null : String(rows[rows.length - 1].id),
};
},
});
Common errors
- Using
OFFSETinstead of a cursor — works, but gets quadratically slower as the offset grows on large tables. Prefer a monotonic column (auto-increment id, or a composite(created_at, id)key) andWHERE id > $cursor. - Returning
nextCursorwhen there are no more rows — causes an infinite loop, since DataEgress keeps callingfetchPageuntil it getsnull. Returnnullexplicitly oncerows.length < limit. - A slow
fetchPageon one page timing out the whole run — DataEgress doesn't impose its own per-page timeout in V1; a pathologically slow query will make the export slow, not fail outright, unless your own DB client times out first.
Working example
See examples/node for a minimal Node script version
without a web framework.