java 93 lines · 4 tabs

Streaming a Large CSV Export in Spring Boot with StreamingResponseBody

Shared by codesnips Jul 2026
4 tabs
@RestController
@RequestMapping("/api/exports")
public class ExportController {

    private final CsvStreamWriter csvStreamWriter;

    public ExportController(CsvStreamWriter csvStreamWriter) {
        this.csvStreamWriter = csvStreamWriter;
    }

    @GetMapping(value = "/users", produces = "text/csv")
    public ResponseEntity<StreamingResponseBody> exportUsers() {
        StreamingResponseBody body = outputStream -> csvStreamWriter.writeUsers(outputStream);

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_TYPE, "text/csv; charset=UTF-8")
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"users.csv\"")
                .header(HttpHeaders.CACHE_CONTROL, "no-store")
                .body(body);
    }
}
4 files · java Explain with highlit

Exporting a large table as CSV over HTTP is a classic memory trap: building the whole file in a StringBuilder or a List<Row> before writing it forces the entire dataset into heap, which either OOMs the server or spikes GC pauses under concurrent requests. The fix is to treat the export as a pipeline where rows flow from the database straight to the socket, keeping only a small window in memory at any moment.

In ExportController, the endpoint returns a StreamingResponseBody instead of a materialized body. Spring hands that lambda the raw OutputStream of the servlet response and invokes it on a separate task executor, so the request thread is released while bytes are flushed incrementally. The Content-Type and Content-Disposition headers are set up front, so the browser begins downloading before the query finishes. Crucially, the controller never holds the full result — it delegates writing to CsvStreamWriter.

CsvStreamWriter owns the actual serialization. It wraps the OutputStream in a BufferedWriter and asks UserExportRepository for a Stream<UserRow>. Because the stream is lazy and backed by a forward-only cursor, each forEach iteration writes one CSV line and periodically calls flush(), bounding memory to the buffer size regardless of row count. The try (Stream ...) block matters: closing the stream releases the JDBC ResultSet and connection.

UserExportRepository is where the memory discipline is enforced. It uses Spring's JdbcTemplate with queryForStream, sets a small setFetchSize hint, and disables auto-commit so PostgreSQL streams rows via a server-side cursor rather than buffering them all client-side — a common pitfall, since the default driver behavior loads everything.

The trade-off is that the HTTP response is committed early, so errors mid-stream can't be turned into a clean 500 with a JSON body; the download simply truncates. Callers should treat a short or malformed file as failure. This pattern is the right reach whenever the export size is unbounded or user-controlled, and it scales to millions of rows on a fixed heap.


Related snips

Share this code

Here's the card — post it anywhere.

Streaming a Large CSV Export in Spring Boot with StreamingResponseBody — share card
Link copied