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.
One statement at a time on the querier
Section titled “One statement at a time on the querier”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.
How each driver streams
Section titled “How each driver streams”| 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) |
. |
MariaDB (mariadb) |
query |
MSSQL (mssql) |
The driver’s stream, paused while the loop is behind |
SQLite (better-sqlite3, node:sqlite, bun:sqlite) |
. |
| MongoDB | Native Cursor |
Turso Cloud (@tursodatabase/) |
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.
Next steps
Section titled “Next steps”- Pagination: reading a large result a page at a time.
- Transactions: how a stream interacts with an open transaction.
- Aggregate Queries: let the database reduce the rows instead.