CSV Exports Your Customers Can Actually Use: Encoding, Dates, and the Excel Problem

Data exports look trivial until a customer opens one in Excel and the invoice total is a date. A practical guide to CSV that survives encoding, commas, localized parsing, huge row counts, and users who will re-import it.

CSV Exports Your Customers Can Actually Use: Encoding, Dates, and the Excel Problem

Every SaaS ships an export button, and every export eventually produces the same support ticket: "the file is broken" or "my numbers changed." The CSV itself is rarely broken. What broke is the contract between your exporter and the spreadsheet on the other side, and that contract has about five clauses. Here's the version we build into Laravel and Go apps.

One format decision first: CSV unless there's a reason

CSV wins by default: it opens anywhere, diffs well, streams cheaply, and non-technical users know what to do with it. Reach for XLSX only when the file genuinely needs multiple sheets or formulas, and for API-to-API handoffs offer JSON or NDJSON instead. A common middle path worth building early: exports stay CSV, while the same query also powers a JSON endpoint for integrations. Two consumers, one source of truth.

Don't invent a "CSV with custom delimiters" unless the customer's accountant demands semicolons — and note that this demand is real in much of Europe, because localized Excel defaults to ;. Which leads to the biggest trap in the whole topic.

Encoding: the byte that starts a spreadsheet war

Excel on Windows still assumes the system codepage for CSVs unless the file declares otherwise. A UTF-8 CSV without a BOM shows every non-ASCII character (customer names, city names, the € sign) as mojibake in Excel, even though it opens perfectly in everything else.

The practical rule: write UTF-8, and prepend the UTF-8 BOM (\xEF\xBB\xBF) for files aimed at spreadsheet users. Purists call the BOM ugly; your customers call it their names displaying correctly. If you also emit a .json variant, leave the BOM out there — json_decode chokes on it.

Escape like the parser will

CSV looks schema-free and is anything but. The rules that matter, per RFC 4180:

  • Quote any field containing the delimiter, a quote, or a newline.
  • Double up embedded quotes ("").
  • Don't rely on the client to generate correct CSV. In Laravel, fputcsv or the league/csv package handles this; in Go, encoding/csv does. Hand-rolled string concatenation is how names like O'Brien, Jr. corrupt rows silently.

Dates, numbers, and Excel's aggressive guessing

Excel parses anything that resembles a date or number in the user's locale. Three recurring casualties:

  • IDs with leading zeros (00123) lose the zeros. Prefix text-typed codes with a tab or emit them as ="00123" when the file is Excel-bound.
  • ISO timestamps (2026-09-30T08:10:00Z) become date serials in some locales and remain text in others. Export timestamps in a single explicit format, state it in a header comment or README row, and keep it ISO. Ambiguity here is worse than verbosity.
  • Decimal commas vs points. If the file will be opened in a locale using decimals commas, either emit a locale-tagged variant or document that the point is the decimal separator. Never let the exporter guess from the request's Accept-Language — that's how the same file parses differently for two people in the same office.

Big exports: stream, paginate, chunk

The memory-safe way to build an export is the same in any stack: don't load the dataset, stream it.

// Laravel: queue the job, stream the file, hand over a link
ExportJob::dispatch($user, $filters);

// in the job, chunk through the query
 Order::query()->filter($filters)->chunkById(1000, function ($orders) use ($csv) {
     foreach ($orders as $order) {
         fputcsv($csv, $order->toExportRow());
     }
 });

Same in Go: write rows straight to an http.ResponseWriter with Content-Disposition: attachment, and a million rows cost no more memory than ten. For very large datasets, generate the file in a background job and email or serve a link — the request that triggers a five-minute export is a request that times out.

The metadata nobody thinks about until they need it

An export without context becomes a mystery file in a month. Cheap additions that pay off: a header row with unambiguous column names (order_created_at_utc, not date), the filter parameters recorded in the filename (orders_2026-09_filtered-by-status-paid.csv), a stable column order so diffing two exports works, and one README row or # comment line stating the timestamp format and encoding. Power users will script against these files; the format contract you set here is an API you can't easily change later.