typescript 119 lines · 3 tabs

Streaming CSV import (Node streams)

Shared by codesnips Jan 2026
3 tabs
import { Writable } from "node:stream";
import type { Pool } from "pg";

interface Row {
  email: string;
  name: string;
  signup_at: string;
}

export class BatchInsertStream extends Writable {
  private buffer: Row[] = [];
  public inserted = 0;

  constructor(private pool: Pool, private batchSize = 500) {
    super({ objectMode: true, highWaterMark: batchSize * 2 });
  }

  async _write(row: Row, _enc: string, callback: (err?: Error) => void) {
    this.buffer.push(row);
    if (this.buffer.length >= this.batchSize) {
      try {
        await this.flush();
      } catch (err) {
        return callback(err as Error);
      }
    }
    callback();
  }

  async _final(callback: (err?: Error) => void) {
    try {
      await this.flush();
      callback();
    } catch (err) {
      callback(err as Error);
    }
  }

  private async flush() {
    if (this.buffer.length === 0) return;
    const rows = this.buffer;
    this.buffer = [];

    const values: unknown[] = [];
    const tuples = rows.map((r, i) => {
      const b = i * 3;
      values.push(r.email, r.name, r.signup_at);
      return `($${b + 1}, $${b + 2}, $${b + 3})`;
    });

    await this.pool.query(
      `INSERT INTO users (email, name, signup_at) VALUES ${tuples.join(",")}
       ON CONFLICT (email) DO NOTHING`,
      values,
    );
    this.inserted += rows.length;
  }
}
3 files · typescript Explain with highlit

This snippet demonstrates a memory-safe CSV import pipeline built on Node's streaming primitives, the kind of code that ingests a multi-gigabyte export without ever loading the whole file into memory. The core idea is to treat the file as a flow of records rather than a buffer, letting Node's built-in backpressure throttle reads whenever the downstream database writer falls behind.

In BatchInsertStream, a Writable in objectMode accumulates parsed rows into an in-memory buffer and flushes to Postgres only when it reaches batchSize. The crucial detail is the _write callback: it is invoked once per record, and the stream will not pull the next record until callback() fires. By deliberately withholding callback() during a full-batch flush (via await this.flush()), the writer creates natural backpressure that propagates all the way back up the pipe to the file read stream. The _final hook handles the partial last batch, and flush clears the buffer before awaiting the query so a failed insert does not silently re-send stale rows.

The RowValidator tab is a Transform stream that sits between the CSV parser and the writer. It normalizes and validates each object, pushing only clean records downstream and tracking rejected counts instead of throwing, so one bad line does not abort a million-row job. Rejected rows can optionally be diverted to a dead-letter file, a common ETL requirement.

The importCsv orchestrator wires everything together with pipeline from stream/promises. Using pipeline rather than manually chaining .pipe() matters because it guarantees every stream is destroyed and errors propagate correctly even if one stage fails midway, avoiding the classic leaked-file-descriptor and hung-process bugs that plague hand-rolled pipes. The csv-parse library supplies a battle-tested parser configured to emit objects keyed by header.

The trade-off is throughput versus memory: larger batchSize reduces round-trips but raises peak memory and lengthens transaction time. This pattern is the right tool when input size is unbounded or untrusted, when constant memory usage is required, and when partial-failure tolerance beats all-or-nothing correctness.


Related snips

Share this code

Here's the card — post it anywhere.

Streaming CSV import (Node streams) — share card
Link copied