~/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: 
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() from node:stream/promises connects the cursor, the transform, and the COPY stream. When COPY falls behind, the cursor stops fetching.
  • Raw text in, raw text out. By default pg turns timestamptz into a JavaScript Date and json/jsonb into an object. Written back with String(value), a date becomes Tue Feb 24 2026 ... and an object becomes [object Object]. That is an easy bug to ship, because nothing fails until the load hits a timestamptz or jsonb column. This version tells the cursor to skip type parsing, so every value stays the text Postgres sent, which COPY reads back as-is.
  • An explicit column list. COPY table FROM STDIN without a column list assumes the table's physical column order. The script reads the destination's writable (non-generated) columns from information_schema.columns, names them in the COPY, 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, and pg-copy-streams 6 or later (npm install pg pg-query-stream pg-copy-streams). Both stream modules only work with the pure JavaScript client, not pg-native.
  • A source role with SELECT on the source table, and a destination role with INSERT (plus TRUNCATE if you use --truncate) on the destination table. Each role also needs USAGE on 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 timestamptz column:
    sql
    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.

bash
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:

text
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

javascript
/**
 * 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-stream wraps pg-cursor, which fetches batchSize rows per round trip instead of the whole result set. The readable side's highWaterMark defaults to the same batchSize, 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-size times the average row width, plus the transform's and COPY stream's own small buffers, whatever the table size. A table with wide rows (large text, jsonb or bytea values) holds far more memory per batch than one of narrow rows, so lower --batch-size for those.
  • Why pipeline() and not .pipe(). Node's docs say stream.pipeline() calls stream.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 a COPY hanging. With pg-copy-streams 6 and later, destroying the COPY stream sends CopyFail, which aborts the COPY. The explicit BEGIN/ROLLBACK around it also undoes the TRUNCATE.
  • Detecting the end of the COPY. Since pg-copy-streams 4.0, a COPY FROM stream signals completion with the standard finish event, not end. pipeline() already waits for finish, so the COMMIT only 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 with pool.connect() and call client.query() on it, as the script does.
  • Failed COPYs leave dead space. PostgreSQL stops a COPY FROM at the first bad row. Rows already sent aren't visible, but they still take disk space until VACUUM reclaims it. A large load that fails near the end is worth a manual VACUUM on the destination table.
  • Raw text has one catch. Because values are Postgres text, the transform works with strings: "12.50", not 12.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 SELECT sees 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.

References