~/2026/02/25/node-js-data-warehouse-streaming-etl-rows-without-loading-them-all-in-memory.md
Node.js: Data Warehouse – Streaming ETL Rows Without Buffering Them
--- author: Tom Lasswell date: read: 5 min in: [scripts, engineering] tags: [nodejs, data-warehouse, etl, sql] ---
$ grep -n '^#' post.md
The failure mode I keep seeing in hand-rolled ETL jobs is the same one every time: SELECT * FROM source_table, load the whole result set into an array, transform it in memory, then loop over the array and insert rows one at a time into the destination. It works fine in development against a few thousand rows and falls over in production, either by exhausting the process's memory or by taking so long on row-by-row inserts that the job never finishes inside its window. Node's streams solve both problems if you actually use them as streams rather than as a .on('data') handler that pushes into an array.
This script pulls rows from a source Postgres table through a cursor, transforms them one at a time, and bulk-loads them into a destination Postgres table (a staging table in a warehouse, say) with COPY ... FROM STDIN. Three details make it correct rather than just fast:
- Backpressure end to end.
pipeline()fromnode:stream/promisesconnects the cursor, the transform, and theCOPYstream. WhenCOPYfalls behind, the cursor stops fetching. - Raw text in, raw text out. By default
pgturnstimestamptzinto a JavaScriptDateandjson/jsonbinto an object. Written back withString(value), a date becomesTue Feb 24 2026 ...and an object becomes[object Object]. That is an easy bug to ship, because nothing fails until the load hits atimestamptzorjsonbcolumn. This version tells the cursor to skip type parsing, so every value stays the text Postgres sent, whichCOPYreads back as-is. - An explicit column list.
COPY table FROM STDINwithout a column list assumes the table's physical column order. The script reads the destination's writable (non-generated) columns frominformation_schema.columns, names them in theCOPY, and fails on the first row if the transform doesn't produce every one of them.
Requirements
- A supported Node.js LTS release (22 or later).
pg,pg-query-stream, andpg-copy-streams6 or later (npm install pg pg-query-stream pg-copy-streams). Both stream modules only work with the pure JavaScript client, notpg-native.- A source role with
SELECTon the source table, and a destination role withINSERT(plusTRUNCATEif you use--truncate) on the destination table. Each role also needsUSAGEon the table's schema if it doesn't own it. - The destination table created ahead of time. For the default transform below it needs every source column plus a
loaded_at timestamptzcolumn:CREATE TABLE staging.stg_orders ( id bigint PRIMARY KEY, customer_id bigint, status text, total numeric(12,2), metadata jsonb, created_at timestamptz, updated_at timestamptz, loaded_at timestamptz NOT NULL );
Usage
Set the connection strings and run the job. --truncate empties the destination in the same transaction as the COPY, so readers never see a half-loaded table. TRUNCATE takes an ACCESS EXCLUSIVE lock, so readers wait until the commit, and it isn't MVCC-safe: a transaction whose snapshot predates the commit and hadn't yet touched the table sees it empty. If that matters, load a staging table and swap it in.
export SOURCE_DATABASE_URL="postgres://etl_reader:<password>@<source-host>:5432/app"
export DEST_DATABASE_URL="postgres://etl_writer:<password>@<warehouse-host>:5432/warehouse"
node stream_etl.js --source-table public.orders --dest-table staging.stg_orders --truncate --log-every 50000
Sample output:
Streaming public.orders -> staging.stg_orders (8 columns)
Processed 50,000 rows (heap 21 MB)
Processed 100,000 rows (heap 23 MB)
Done: 137,412 rows in 38.6s, committed
The heap figure is process.memoryUsage().heapUsed at that moment. What matters is that it stays roughly flat as the row count climbs; if it grows with the row count, something in the transform is holding onto rows.
Script
/**
* stream_etl.js
*
* Streams rows from a source Postgres table through a cursor, applies a
* per-row transform, and bulk-loads the result into a destination table with
* COPY ... FROM STDIN, without buffering the result set in memory.
*
* Reads: SOURCE_DATABASE_URL, --source-table (SELECT *).
* DEST_DATABASE_URL, information_schema.columns for --dest-table.
* Writes: --dest-table via COPY, inside one transaction (optionally after TRUNCATE).
*
* Usage: node stream_etl.js --source-table <schema.table> --dest-table <schema.table>
* [--truncate] [--batch-size 1000] [--log-every 50000]
*/
"use strict";
const { Pool } = require("pg");
const QueryStream = require("pg-query-stream");
const { from: copyFrom } = require("pg-copy-streams");
const { Transform } = require("node:stream");
const { pipeline } = require("node:stream/promises");
// Skip pg's type parsing: every non-null value arrives as the text Postgres sent.
const RAW_TEXT = { getTypeParser: () => (value) => value };
function parseArgs(argv) {
const args = { truncate: false, "batch-size": "1000", "log-every": "50000" };
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith("--")) {
throw new Error(`Unexpected argument: ${token}`);
}
const key = token.slice(2);
if (key === "truncate") {
args.truncate = true;
} else {
args[key] = argv[i + 1];
i += 1;
}
}
if (!args["source-table"] || !args["dest-table"]) {
throw new Error("Usage: node stream_etl.js --source-table <schema.table> --dest-table <schema.table> [--truncate]");
}
const batchSize = Number(args["batch-size"]);
const logEvery = Number(args["log-every"]);
if (!Number.isInteger(batchSize) || batchSize < 1 || !Number.isInteger(logEvery) || logEvery < 1) {
throw new Error("--batch-size and --log-every must be positive integers");
}
return {
sourceTable: args["source-table"],
destTable: args["dest-table"],
truncate: args.truncate,
batchSize,
logEvery,
};
}
function splitName(name) {
const parts = name.split(".");
return parts.length === 2 ? parts : ["public", parts[0]];
}
function quoteName(client, name) {
return splitName(name).map((part) => client.escapeIdentifier(part)).join(".");
}
async function destinationColumns(client, name) {
const [schema, table] = splitName(name);
const { rows } = await client.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2
AND is_generated = 'NEVER'
ORDER BY ordinal_position`,
[schema, table],
);
if (rows.length === 0) {
throw new Error(`Destination table ${name} not found or not visible to this role`);
}
return rows.map((row) => row.column_name);
}
// One row in, one row out. Values are Postgres text (or null). Extend this for
// renames, filtering, or derived columns; never hold a reference to past rows.
function transformRow(row, loadedAt) {
return { ...row, loaded_at: loadedAt };
}
// COPY text format: tab-delimited, \N for null, and backslash, newline,
// carriage return, and the delimiter escaped with a backslash.
function toCopyField(value) {
if (value === null || value === undefined) {
return "\\N";
}
return String(value)
.replace(/\\/g, "\\\\")
.replace(/\t/g, "\\t")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r");
}
async function run() {
const { sourceTable, destTable, truncate, batchSize, logEvery } = parseArgs(process.argv.slice(2));
const sourcePool = new Pool({ connectionString: process.env.SOURCE_DATABASE_URL, max: 1 });
const destPool = new Pool({ connectionString: process.env.DEST_DATABASE_URL, max: 1 });
const sourceClient = await sourcePool.connect();
const destClient = await destPool.connect();
const startedAt = Date.now();
const loadedAt = new Date().toISOString();
let rowCount = 0;
try {
const columns = await destinationColumns(destClient, destTable);
const columnList = columns.map((c) => destClient.escapeIdentifier(c)).join(", ");
console.log(`Streaming ${sourceTable} -> ${destTable} (${columns.length} columns)`);
await destClient.query("BEGIN");
if (truncate) {
await destClient.query(`TRUNCATE ${quoteName(destClient, destTable)}`);
}
const source = sourceClient.query(
new QueryStream(`SELECT * FROM ${quoteName(sourceClient, sourceTable)}`, [], {
batchSize,
types: RAW_TEXT,
}),
);
const toCopyLines = new Transform({
writableObjectMode: true,
readableObjectMode: false,
transform(row, _encoding, callback) {
const out = transformRow(row, loadedAt);
if (rowCount === 0) {
const missing = columns.filter((c) => !(c in out));
if (missing.length > 0) {
callback(new Error(`Transform does not produce destination column(s): ${missing.join(", ")}`));
return;
}
}
rowCount += 1;
if (rowCount % logEvery === 0) {
const heapMb = Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
console.log(`Processed ${rowCount.toLocaleString()} rows (heap ${heapMb} MB)`);
}
callback(null, columns.map((c) => toCopyField(out[c])).join("\t") + "\n");
},
});
const sink = destClient.query(
copyFrom(`COPY ${quoteName(destClient, destTable)} (${columnList}) FROM STDIN`),
);
// Propagates backpressure and destroys every stream if any one fails;
// destroying the COPY stream sends CopyFail so the server aborts the COPY.
await pipeline(source, toCopyLines, sink);
await destClient.query("COMMIT");
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log(`Done: ${rowCount.toLocaleString()} rows in ${seconds}s, committed`);
} catch (error) {
await destClient.query("ROLLBACK").catch(() => {});
throw error;
} finally {
sourceClient.release();
destClient.release();
await Promise.all([sourcePool.end(), destPool.end()]);
}
}
run().catch((error) => {
console.error("ETL job failed:", error.message);
process.exitCode = 1;
});
Notes
- Where the memory bound comes from.
pg-query-streamwrapspg-cursor, which fetchesbatchSizerows per round trip instead of the whole result set. The readable side'shighWaterMarkdefaults to the samebatchSize, so the stream buffers about one batch before it stops pulling. The package README describes it as keeping "only a low number of rows in memory", which is the property you want. The bound is on rows, not bytes: the working set is roughly--batch-sizetimes the average row width, plus the transform's and COPY stream's own small buffers, whatever the table size. A table with wide rows (largetext,jsonborbyteavalues) holds far more memory per batch than one of narrow rows, so lower--batch-sizefor those. - Why
pipeline()and not.pipe(). Node's docs saystream.pipeline()callsstream.destroy(err)on every stream in the chain when one fails. Hand-wired.pipe()calls don't do that, which is how a failed transform leaves a source cursor open or aCOPYhanging. With pg-copy-streams 6 and later, destroying theCOPYstream sendsCopyFail, which aborts theCOPY. The explicitBEGIN/ROLLBACKaround it also undoes theTRUNCATE. - Detecting the end of the COPY. Since pg-copy-streams 4.0, a
COPY FROMstream signals completion with the standardfinishevent, notend.pipeline()already waits forfinish, so theCOMMITonly runs after the server has accepted every row. - Don't use
pool.query()for COPY. The pg-copy-streams README is explicit: the pool's convenience method returns a Promise and doesn't support custom query objects. Take a client withpool.connect()and callclient.query()on it, as the script does. - Failed COPYs leave dead space. PostgreSQL stops a
COPY FROMat the first bad row. Rows already sent aren't visible, but they still take disk space untilVACUUMreclaims it. A large load that fails near the end is worth a manualVACUUMon the destination table. - Raw text has one catch. Because values are Postgres text, the transform works with strings:
"12.50", not12.5, and"t"/"f"for booleans. Parse only the columns you actually compute on, and turn them back into strings Postgres accepts before returning. That's also why no conversion is needed on the way out: the COPY docs define text-format values as the strings produced by each type's output function, or accepted by its input function. - Consistent snapshot. A single
SELECTsees one snapshot of the source for its whole run, even while the table keeps changing. The cost is that a long read on a busy primary holds back cleanup of dead rows until it finishes. Run big extracts against a replica, or use the watermark approach so each run only reads recent changes.