Skip to content
NewFrom Mongoose to SQL: migrating MongoDB to PostgreSQL7 min read

Cursor Streaming

findManyStream() reads a result too large to hold in memory. It takes the same query as findMany and returns an AsyncIterable that yields each row as the database sends it:

import { User } from './shared/models/index.js';
import { pool } from './uql.config.js';
for await (const user of pool.findManyStream(User, {
$select: { id: true, email: true },
$where: { status: 'active' },
})) {
console.log(user.email);
}

Memory stays flat, your loop starts on the first row before the database has read the last, and rows arrive only as fast as the loop reads them. Populated relations and $count work, and @AfterLoad runs on each row before your loop sees it.

While a stream is open on a querier, any other statement on that querier throws a UqlUsageError. Most drivers would queue it behind the open read, which never finishes while your loop waits on it. Run the other statement after the loop, or on another connection, as a pool call does:

for await (const user of pool.findManyStream(User, {
$where: { status: 'active' },
})) {
await pool.updateOneById(User, user.id, { status: 'notified' }); // a second connection
}

That fails in two cases: on SQLite and PGlite, whose one connection the stream holds, and inside a transaction, where the pool call joins the transaction and so runs on the streaming querier. There, collect what the loop decides and write it afterwards. A hook that queries through its querier, such as an @AfterLoad, is refused the same way while the stream is open.

release() closes a stream left open. The statement is logged when the loop ends, with its duration measured to the first row, since the time after that is your loop’s.

Driver How
PostgreSQL (pg), CockroachDB, Neon Client cursor via pg-query-stream
Bun SQL on Postgres or CockroachDB, PGlite Server-side cursor: DECLARE / FETCH FORWARD / CLOSE
MySQL (mysql2) .stream()
MariaDB (mariadb) queryStream()
MSSQL (mssql) The driver’s stream, paused while the loop is behind
SQLite (better-sqlite3, node:sqlite, bun:sqlite) .iterate()
MongoDB Native Cursor
Turso Cloud (@tursodatabase/serverless) The statement’s cursor, as the server steps it
Bun SQL on MySQL or MariaDB, LibSQL, D1 Buffered: the whole result, then yielded row by row

Neither bun:sql (oven-sh/bun#17181) nor PGlite’s client exposes a cursor, so UQL declares one in SQL and fetches 100 rows at a time. DECLARE needs a transaction: with none open, the stream opens one and ends it with the loop; inside yours, it leaves your transaction alone.

The buffered drivers expose no cursor, so the whole result sits in memory before the first row arrives. To walk a large result there, use findManyPage, which reads one page at a time.