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, you start on the first row before the last one exists, and the database sends rows only as fast as your 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, which is what a pool call gives you:
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 is held by the stream, 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. Hooks that query through their querier are refused the same way while the stream is open.
release() closes a stream left open. The statement is logged when the loop ends, timed to its first row.
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.
Buffered engines give the client no cursor at all, 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.