A JDBC query over millions of rows OOMs the client before the first row is read. Why, and how do you stream it?
answer
- Where does the memory go before next() runs
- The API word is hint, not instruction
- Each driver adds its own preconditions
- Something stays open the whole time you read
basics
~20 sMost JDBC drivers buffer the entire result set into client memory when executeQuery returns, so ResultSet.next just walks that buffer. Streaming requires telling the driver to use a server-side cursor, which each driver gates behind its own preconditions.
solid answer
~50 sBy default many drivers read the whole result into memory during `executeQuery()`, so the OOM happens before your loop starts — which is why iterating with `ResultSet.next()` does not help. The fix is a server-side cursor, requested through `setFetchSize`, but the specification calls fetch size only a *hint*, and drivers attach conditions. PostgreSQL's driver honours it only when auto-commit is off and the `ResultSet` is `TYPE_FORWARD_ONLY`, in which case it opens a named cursor and fetches in blocks. MySQL's Connector/J ignores ordinary fetch sizes and streams row-by-row only when you call `setFetchSize(Integer.MIN_VALUE)` on a forward-only, read-only statement, or when the connection property `useCursorFetch=true` is set with a positive fetch size. Streaming has a real cost: the connection and its transaction stay pinned for the whole read, so keep the per-row work cheap or chunk the query by primary key instead.
code
java · 15 lines// PostgreSQL: a server-side cursor needs auto-commit off
// and a forward-only ResultSet
conn.setAutoCommit(false);
try (PreparedStatement ps = conn.prepareStatement(
"SELECT id, payload FROM event",
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY)) {
ps.setFetchSize(1000);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
handle(rs.getLong("id"), rs.getString("payload"));
}
}
conn.commit();
}go deeper
Recall that a ResultSet is not automatically lazy — many drivers load the whole result first — and that selecting fewer rows and columns is the simplest fix.
Explain what setFetchSize is specified to be, why it is only a hint, and what a server-side cursor changes about where the rows live between the server and the client.
Diagnose from the symptom: prove the driver is buffering, know your driver's preconditions for a cursor, and weigh the cost of a pinned connection and long transaction against chunking the query by key.
Own the shape of bulk read paths across the service: which jobs get their own connection budget or a replica, when a consistent snapshot justifies a long cursor, and how export jobs stay resumable rather than restarting from zero.
## Why the failure happens before the loop The mental model most developers carry is that `ResultSet` is a lazy stream and `next()` pulls one row from the server. For many drivers in their default configuration that is wrong: `executeQuery()` reads the complete result off the socket into client memory and returns a `ResultSet` that is a cursor over that in-memory buffer. This is a reasonable default — it frees the server-side resources immediately and makes the connection reusable — but it means the memory cost is proportional to the whole result, and the heap dump shows the failure inside `executeQuery`, not inside your row loop. The first diagnostic question is therefore not "how do I process rows more cheaply" but "is the driver materialising everything up front". A thread dump or profile taken during the failure usually answers it immediately. ## Fetch size is a hint, not a switch `Statement.setFetchSize(int)` and `ResultSet.setFetchSize(int)` tell the driver how many rows to fetch per round trip. The specification is explicit that this is a hint; a driver is free to ignore it. In practice each driver defines its own contract: - **PostgreSQL (pgjdbc):** a non-zero fetch size produces a server-side named cursor, but only if auto-commit is off and the `ResultSet` is `TYPE_FORWARD_ONLY`. The cursor lives inside the transaction, which is exactly why auto-commit must be disabled — with auto-commit on, the transaction would end when the statement completes and the cursor would vanish. - **MySQL (Connector/J):** ordinary positive fetch sizes do not change the default buffering. Row-by-row streaming is requested with the sentinel `setFetchSize(Integer.MIN_VALUE)` on a `TYPE_FORWARD_ONLY`, `CONCUR_READ_ONLY` statement. Alternatively the connection property `useCursorFetch=true` enables server-side cursors, and then a positive fetch size takes effect. - **Oracle:** honours `setFetchSize` directly as the array-fetch size, with a small default (10), so tuning it upward is a throughput lever rather than a memory-safety switch. Because the contracts differ, code that must run against more than one database either configures fetching per driver or avoids relying on streaming at all. ## What streaming costs A server-side cursor is not free. The connection is occupied for the entire read — you cannot issue another query on it, and with MySQL's row-by-row streaming the driver requires the `ResultSet` to be consumed or closed before the connection is usable again. In a pooled application that means one pool slot is held for the duration of a job that may run for minutes. The cursor also lives inside a transaction, so a long stream is a long-running transaction: it holds a snapshot open, which on MVCC engines prevents cleanup of superseded row versions, and it inflates the exposure window for lock contention. A streaming export that runs for an hour is a database operations problem, not just an application one. And the per-row work matters more than it looks. If the loop body makes a network call or writes to another system, the stream advances at the speed of that call while the cursor and connection stay open. Read into bounded batches and do the slow work outside the cursor's lifetime. ## The alternative: chunk the query Often the better answer is not to stream at all but to split the query. Order by the primary key and fetch a bounded page at a time, carrying the last key seen forward as the lower bound of the next query. Each query is short, opens and closes its own transaction, holds nothing between chunks, and can be retried or resumed after a failure without redoing everything. The cost is that the data may change between chunks, so the technique fits export and backfill jobs whose correctness tolerates that, better than it fits a point-in-time consistent snapshot — which is precisely where a single streaming cursor earns its cost. Offset-based paging is the trap here: skipping a growing offset makes the database read and discard more rows on every chunk, so the job gets slower the further it goes. ## A checklist Confirm the driver is buffering, rather than guessing. Select only the columns you need — result-set width multiplies whatever buffering exists. Set the fetch size *and* satisfy that driver's preconditions, then verify with a heap profile that memory is now flat. Keep the loop body cheap and free of remote calls. Bound the time a cursor stays open, and prefer key-range chunking for long-running jobs that can tolerate a moving target. Finally, remember the connection is pinned: size the job so it does not starve request-serving traffic of the connections it needs.
- What does holding a streaming cursor open actually cost in a pooled service?One pool slot is occupied for the whole read, and the enclosing transaction keeps a snapshot alive, which on MVCC engines blocks cleanup of superseded row versions. A slow loop body makes both worse. Long exports should therefore run against a separate connection budget or be chunked into short transactions.
- Why does offset-based chunking degrade while key-range chunking does not?Skipping an offset still requires the database to produce and discard every preceding row, so each chunk costs more than the last and the job slows down as it progresses. Filtering on the last key seen uses the index to jump straight to the next range, so every chunk costs the same.
- Why can't you run another query on the same connection while MySQL's driver is streaming?In row-by-row streaming mode the un-read rows are still in flight on that connection, so the driver requires the ResultSet to be fully consumed or closed before another statement can use it. Work that needs a second query during the loop must borrow a second connection.
The default ResultSet is a bucket the driver fills completely before handing it to you; a server-side cursor is a tap you open and drink from. The tap uses less space, but it stays connected to the plumbing the whole time you are drinking.
saying these in an interview costs you the question
- Thinks ResultSet.next() always pulls one row from the server
- Believes setFetchSize is honoured identically by all drivers
- Sets fetch size but leaves auto-commit on with PostgreSQL
- Streams for an hour inside one open transaction
- Pages a large export with a growing OFFSET