Skip to main content

Large Objects

PostgreSQL's large object (lo_*) facility stores binary data outside any table row, reachable a piece at a time through a file-like descriptor rather than loaded whole the way a bytea column must be. It scales to about 4TB per object, against roughly 1GB for bytea. postgrejs wraps it as the LargeObject class, with read/write/seek/tell methods and Node Readable/Writable adapters.

A large object belongs to no row: nothing deletes it when the row that references its OID is deleted. Call unlinkLargeObject(oid) when the data is no longer needed, or run PostgreSQL's vacuumlo utility to find orphaned objects.

Opening a large object

MethodDescription
connection.createLargeObject(mode?)Creates a new, empty large object and opens it. Returns Promise<LargeObject>.
connection.openLargeObject(oid, mode?)Opens an existing large object by OID. Returns Promise<LargeObject>.
connection.unlinkLargeObject(oid)Deletes a large object and its data.

Both open methods take a mode, from the LargeObjectMode enum:

export const LargeObjectMode = {
read: 0x00040000,
write: 0x00020000,
readWrite: 0x00060000,
} as const;

createLargeObject() defaults to LargeObjectMode.readWrite; openLargeObject() defaults to LargeObjectMode.read.

A large object's descriptor is only valid inside a transaction. If the connection is not already in one, opening it starts one automatically, and LargeObject.close() commits that transaction - but only the one it started itself; a transaction the caller already had open is left for the caller to commit or roll back.

Writing and reading

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

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

// Write a blob
const lo = await connection.createLargeObject();
await pipeline(fs.createReadStream('video.mp4'), lo.writable());
await lo.close();
const oid = lo.oid; // keep this - a large object belongs to no row

await connection.query('update media set video_oid = $1 where id = $2', {
params: [oid, 1],
});

// Read it back later
const reader = await connection.openLargeObject(oid);
await pipeline(reader.readable(), fs.createWriteStream('video-copy.mp4'));
await reader.close();

LargeObject methods

MethodDescription
read(length: number): Promise<Buffer>Reads at most length bytes from the current position.
write(data: Buffer): Promise<number>Writes at the current position; returns bytes written.
seek(offset: number | bigint, whence?: number): Promise<bigint>Moves the read/write position. whence: 0 from start (default), 1 from current position, 2 from end.
tell(): Promise<bigint>The current read/write position.
size(): Promise<bigint>Total size in bytes. Leaves the position unchanged.
truncate(length: number | bigint): Promise<void>Cuts the object down to length bytes.
readable(options?: LargeObjectStreamOptions): ReadableStreams from the current position to the end.
writable(options?: LargeObjectStreamOptions): WritableStreams writes from the current position onward.
close(): Promise<void>Closes the descriptor; commits the transaction if this call started it. Safe to call twice.

LargeObjectStreamOptions.chunkSize (default 65536) controls how many bytes each internal loread/lowrite round trip transfers - the readable and writable streams each issue one query per chunk:

await pipeline(
fs.createReadStream('big.bin'),
lo.writable({ chunkSize: 256 * 1024 }),
);

Random access

Because a large object is addressed by position, you can read a slice without transferring the rest:

const lo = await connection.createLargeObject();
await lo.write(Buffer.from('...large content...'));

await lo.seek(1000);
const slice = await lo.read(16); // 16 bytes starting at offset 1000
console.log(await lo.tell()); // 1016n

await lo.truncate(2048);
console.log(await lo.size()); // 2048n

await lo.close();

unlinkLargeObject() deletes the object entirely:

await connection.unlinkLargeObject(oid);

See API: LargeObject for the full class reference.