Dashboard

How to Add CSV Export to an AI-Built App

The three parts coding agents skip when you ask for CSV export: streaming large tables without loading everything into memory, correct RFC 4180 field escaping, and timezone-correct date formatting, with a working code pattern.

Steve Jefferson
Steve Jefferson
Developer Advocate
20 September 20261 min read

Adding a CSV export to an AI-built app takes more than a loop and a join-with-commas. The three parts coding agents reliably skip when you ask for "add CSV export" are streaming the file instead of building the whole thing in memory, escaping fields correctly when the data itself contains commas or quotes, and formatting dates in the user's timezone instead of the server's. Skip any of these and the feature works fine in your test with ten rows, then breaks on a customer's real export with fifty thousand rows, a comma in a company name, or a timestamp that's off by a few hours depending on where the export ran.

Why "Add CSV Export" Prompts Produce Broken Exports

A CSV export is one of those features that looks trivial and isn't. The naive version, fetch all rows, map them to comma-joined strings, send the response, works perfectly on a demo dataset and fails in three specific ways once it hits production data: it runs the server out of memory on a large table, it silently corrupts rows where a field contains a comma or a newline, and it prints timestamps in whatever timezone the database server happens to be configured in, which is rarely the timezone your user is sitting in. None of these show up in a quick manual test with a handful of rows, which is exactly why they slip through when a coding agent generates the feature in one pass.

Stream the Export Instead of Loading Everything Into Memory

Building the full CSV as one big string before sending it means holding the entire export in memory at once, row objects, string buffer, and the final send buffer, all at the same time. For a hundred-row export that's nothing. For a table with a few hundred thousand rows, it's the difference between a request that finishes in a couple of seconds and one that pins the server's memory and eventually crashes it, usually under real customer load rather than in testing.

The fix is to stream: open a database cursor, pull rows in batches, write each batch to the HTTP response as it arrives, and never hold more than one batch in memory. Using node-postgres with a cursor looks like this:

const Cursor = require('pg-cursor'); app.get('/export.csv', async (req, res) => { res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', 'attachment; filename="export.csv"'); const client = await pool.connect(); const cursor = client.query(new Cursor('SELECT id, name, created_at FROM orders WHERE tenant_id = $1', [req.tenantId])); res.write('id,name,created_at\n'); const readBatch = () => cursor.read(500, (err, rows) => { if (err) { client.release(); return res.end(); } if (rows.length === 0) { client.release(); return res.end(); } for (const row of rows) { res.write(toCsvRow(row) + '\n'); } readBatch(); }); readBatch(); });

The response starts sending data as soon as the first batch of 500 rows is ready instead of waiting for the whole query to finish, and memory usage stays flat regardless of whether the table has five thousand rows or five million.

Escape CSV Fields Correctly

This is the part that breaks silently. If a customer name is "Smith, Johnson & Co", a naive join-with-commas export turns one field into two columns and shifts every column after it, corrupting the row without throwing any error. The rule, defined in RFC 4180, is straightforward: if a field contains a comma, a double quote, or a newline, wrap the whole field in double quotes, and escape any double quote inside it by doubling it.

function escapeCsvField(value) { const str = String(value ?? ''); if (/[",\n\r]/.test(str)) { return '"' + str.replace(/"/g, '""') + '"'; } return str; } function toCsvRow(row) { return [row.id, escapeCsvField(row.name), row.created_at].map(escapeCsvField).join(','); }

Two details matter here. Always run every field through the escape function, not just the ones you think might contain a comma, since user-entered text is exactly the data you can't predict. And a newline inside a quoted field is legal CSV that most spreadsheets handle fine, but strip it explicitly if the export feeds a system that doesn't.

Format Dates in the User's Timezone, Not the Server's

Postgres timestamptz columns store an absolute point in time, but formatting one into a string requires picking a timezone, and if your export code doesn't specify one explicitly, you get whatever the server or driver defaults to, usually UTC. A user in California exporting a report full of 11pm timestamps that actually happened at 3pm their time will assume the export is broken, not that it's technically correct in a timezone nobody asked for.

Accept a timezone parameter from the client (most browsers can supply Intl.DateTimeFormat().resolvedOptions().timeZone automatically) and format explicitly against it rather than against the server's local time or a hardcoded UTC assumption:

const tz = req.query.tz || 'UTC'; const formatted = new Date(row.created_at).toLocaleString('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });

Store the raw timestamp in a consistent format like ISO 8601 if the export is meant for re-import into another system, and reserve human-formatted, timezone-adjusted dates for exports meant to be read directly by a person. Mixing the two in the same column is a common source of confusion when a CSV export gets piped into a second tool.

Putting It Together

A complete export endpoint combines all three: a streaming cursor so memory stays flat regardless of row count, an escape function applied to every field so commas and quotes in real data don't corrupt the output, and explicit timezone handling so dates mean what the user expects them to mean. Export features are usually one of the first things added after the core app is working, since customers start asking for their data out almost as soon as they start putting data in. If the app already has pagination built for its list views, the same cursor-based query pattern usually adapts directly into the export endpoint's streaming loop, since both are solving the same underlying problem of handling more rows than you want to hold in memory at once.

If the app is also multi-tenant, the export query needs the same tenant filter as every other query against that table, and it's worth testing the export specifically, since a background export job is exactly the kind of code path that can end up running outside the normal per-request tenant context and accidentally pull every tenant's rows instead of one.

Test the export against data designed to break it: a name with a comma and a quote in it, a description with an embedded newline, and rows timestamped near midnight in a timezone several hours off the server's. Real customer data will eventually contain all three, and it's cheaper to catch them with a few rows of deliberately awkward seed data than with a support ticket after launch.

Why does my CSV export break when a field has a comma in it?

Because the export code is joining fields with commas without checking whether the field itself contains one. Any field with a comma, quote, or newline needs to be wrapped in double quotes, with internal quotes doubled, following the RFC 4180 standard. Without that escaping, a comma inside a field gets read by spreadsheet software as a column separator, shifting every value after it one column to the right.

How do I export a large table to CSV without running out of memory?

Use a database cursor to pull rows in batches, typically a few hundred to a couple thousand rows at a time, and write each batch directly to the HTTP response stream as it's read, rather than building the full CSV string in memory first. This keeps memory usage roughly constant regardless of whether the export is a thousand rows or several million.

Why are the dates wrong in my CSV export?

Most likely the export is formatting timestamps using the server's default timezone, often UTC, instead of the timezone the user actually expects. Accept a timezone from the client and format dates explicitly against it, and keep raw ISO 8601 timestamps separate from human-readable, timezone-adjusted ones if the export needs to serve both a person and another system.

Should CSV exports run as background jobs instead of a direct request?

For small exports, a direct streamed response works fine and is simpler to build. Once exports regularly take more than a few seconds, or the underlying query is heavy enough to risk a request timeout, moving the export to a background job that emails or notifies the user with a download link once it's done is usually the more reliable choice, since it removes the export from the request-response timeout window entirely.

how to add a bulk import feature to an AI-built app

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.