Skip to main content

COPY TO / COPY FROM

PostgreSQL's COPY statement bulk-transfers data between the server and a client, in text, CSV or binary format, far faster than row-by-row INSERTs or SELECTs. postgrejs exposes it as Node.js streams: connection.copyTo() returns a Readable for COPY ... TO STDOUT, and connection.copyFrom() returns a Writable for COPY ... FROM STDIN.

Both methods forward raw bytes exactly as the server sends or expects them - nothing is parsed, decoded or re-encoded. The statement's own FORMAT clause (text, csv or binary) decides what those bytes look like.

COPY TO STDOUT​

import fs from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Connection } from 'postgrejs';

const connection = new Connection();
await connection.connect();

const out = await connection.copyTo(`COPY users TO STDOUT (FORMAT csv, HEADER true)`);
await pipeline(out, fs.createWriteStream('users.csv'));
console.log('rows exported:', out.rowCount);

copyTo() resolves as soon as the server accepts the copy. The export itself is not buffered in memory: a consumer that reads slowly pauses the underlying socket through normal stream backpressure. The returned CopyToStream must be consumed or explicitly destroy()-ed, since the connection stays mid-statement until the copy finishes.

CopyToStream properties​

KeyTypeDefaultDescription
overallFormatDataFormat (0 text | 1 binary)-Format of the copy as a whole, from CopyOutResponse. Set once the copy starts.
columnFormatsDataFormat[]-Per-column format, from CopyOutResponse.
rowCountnumber-Rows the server reported for the copy. Set before 'end' is emitted, so it is always safe to read once the stream has finished.

waitStarted(): Promise<void> resolves once the server has accepted the copy (i.e. once overallFormat is set) and rejects if the statement never starts one - for example if it was not actually a COPY ... TO STDOUT:

const out = await connection.copyTo(`COPY users TO STDOUT`);
await out.waitStarted(); // throws if the SQL wasn't a COPY TO STDOUT

Passing a statement that isn't a COPY ... TO STDOUT rejects the copyTo() call itself with an error, and the connection remains usable for further queries.

COPY FROM STDIN​

import fs from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Connection } from 'postgrejs';

const connection = new Connection();
await connection.connect();

const input = await connection.copyFrom(`COPY users FROM STDIN (FORMAT csv)`);
await pipeline(fs.createReadStream('users.csv'), input);
console.log('rows imported:', input.rowCount);

'finish' on a CopyFromStream means the server has accepted and committed the whole copy. The stream waits for CommandComplete, not just for the last byte to be written, so rowCount is already set by the time a pipeline() over it resolves.

KeyTypeDefaultDescription
rowCountnumber-Rows the server accepted. Set before 'finish' is emitted.

waitStarted(): Promise<void> resolves once the server is ready to receive data (CopyInResponse) and rejects if the statement never asks for any.

If the source of data fails partway through, destroying the stream (which pipeline() does automatically) sends CopyFail to the server instead of leaving the connection stuck waiting for more data - the partial copy is rolled back and the connection stays usable:

import { Readable } from 'node:stream';

const input = await connection.copyFrom(`COPY users FROM STDIN`);
const source = Readable.from(brokenGenerator()); // throws partway through
await pipeline(source, input).catch(err => {
// CopyFail was sent; `connection` can still run further queries.
});

Round-tripping a binary export​

Binary format is the most compact and preserves exact types, which makes it convenient for copying data between tables or servers:

import { Readable } from 'node:stream';

const exported = await connection.copyTo(`COPY users TO STDOUT (FORMAT binary)`);
const chunks: Buffer[] = [];
for await (const chunk of exported) chunks.push(chunk);

const input = await connection.copyFrom(`COPY users_backup FROM STDIN (FORMAT binary)`);
await pipeline(Readable.from([Buffer.concat(chunks)]), input);

Binary COPY FROM: Loading JS Rows Directly with copyFromRows()​

copyFrom() sends whatever bytes you hand it — text, CSV, or binary, however you formatted them yourself. copyFromRows(table, source, options?) works differently: it always speaks binary. There is no text/CSV mode. It takes plain JS rows, encodes every value itself with the destination columns' own binary encoders, and drives COPY ... FROM STDIN (FORMAT binary) on the wire. You hand it values, never bytes:

const { rowCount } = await connection.copyFromRows('users', [
[1, 'John', 10.5],
[2, 'Jane', 20.0],
], { columns: ['id', 'name', 'amount'] });

source can be an array, a generator, or a Readable (already an AsyncIterable). Rows are pulled, not pushed, so a source far larger than memory streams in with backpressure: the loop pauses instead of a queue growing. Each row is either a positional array (matching columns, or the table's own column order if columns is omitted) or an object keyed by column name:

await connection.copyFromRows('users', [
{ id: 1, name: 'John', amount: 10.5 },
]);

Measured on 200,000 rows of mixed types, copyFromRows() took 113ms against 460ms for the equivalent CSV copyFrom() — about 4x, with client-side encoding counted on both sides. Binary is faster for two reasons: the server skips parsing the payload, and writing a 4-byte integer costs less than formatting its decimal text.

Column types​

Binary COPY does no conversion server-side, so the column types have to be exactly right. By default, copyFromRows() finds them itself with one extra round trip (a Describe of select <columns> from <table> where false against the destination), so schemas, quoting, search_path, and views all resolve exactly as they will for the real COPY. Pass columnTypes to skip that probe when the OIDs are already known. That is worth it only on a hot path, and only when they're guaranteed to match the table, because the server cannot reconcile a wrong OID here: it either rejects the stream outright or, for two types that happen to share a byte width, silently stores the bytes as the wrong value.

Handling bad values​

A value the destination column can't encode — 'abc' into an integer column, {} into a numeric one — aborts the copy by default, naming the offending row and column instead of PostgreSQL's opaque "incorrect binary data format":

try {
await connection.copyFromRows('users', [['abc', 'x']], { columns: ['id', 'name'] });
} catch (e) {
e.message; // mentions row 0 and column "id"
}

For a load large enough that abandoning it over one bad record costs more than the record is worth, onInvalidValue: 'null' writes NULL in just that column and keeps the row, and onInvalidValue: 'skip' drops the row entirely. Both report how many they affected (nulledValues/skippedRows on the result), so a tolerant load still reports what it nulled or dropped:

const r = await connection.copyFromRows('users', rows, {
columns: ['id', 'name'],
onInvalidValue: 'skip',
});
r.rowCount; // rows actually sent
r.skippedRows; // rows dropped for an unencodable value

Neither option touches NaN/Infinity in a float/numeric column — PostgreSQL stores those as values distinct from NULL, so they encode normally and are never treated as "invalid".

See API: Connection.copyFromRows() and CopyFromRowsOptions for the full reference.

See API: CopyToStream/CopyFromStream for the full class reference.