# CSV export

Download the grid as a CSV file, with formula escaping, Excel-safe encoding, and an honest account of anything the file could not contain.


`serializeCsv` turns a row-model snapshot into a CSV file and `defaultSaveFile`
delivers it to the user's disk. The button below calls exactly that pairing on a
real grid — click it and a real `products.csv` downloads:

### Example: Export CSV

A products grid with a real Export CSV button wired to the grid handle's exportCsv, downloading an actual CSV file through defaultSaveFile.

Source: https://pretable.ai/examples/export-csv.md

```tsx ExportCsvGrid.tsx
"use client";

import { useRef } from "react";

import { PretableSurface } from "@pretable/react";
import type { PretableColumn, PretableSurfaceGrid } from "@pretable/react";

import { columns } from "./columns";
import { products, type Product } from "./data";

const VIEWPORT_HEIGHT = 280;

export function ExportCsvGrid() {
  const grid = useRef<PretableSurfaceGrid<
    Product,
    string,
    readonly PretableColumn<Product>[]
  > | null>(null);

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        Nothing is checked below, so the button exports every row — check a few
        first to see <code>onlySelected</code> narrow the file instead.
      </p>
      <button
        onClick={() => grid.current?.exportCsv({ onlySelected: true })}
        style={{ marginBottom: 8 }}
        type="button"
      >
        Export CSV
      </button>
      <PretableSurface<Product>
        ariaLabel="Products"
        columns={columns}
        getRowId={(row) => row.id}
        onGridReady={(ready) => {
          grid.current = ready;
        }}
        rowSelectionColumn={{ enabled: true }}
        rows={products}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { Product } from "./data";

export const columns: PretableColumn<Product>[] = [
  { id: "sku", header: "SKU", widthPx: 110 },
  { id: "name", header: "Product", widthPx: 200 },
  { id: "category", header: "Category", widthPx: 130 },
  {
    id: "unitsInStock",
    header: "In stock",
    type: "number",
    widthPx: 100,
  },
];
```

```ts data.ts
export interface Product extends Record<string, unknown> {
  id: string;
  sku: string;
  name: string;
  category: string;
  unitsInStock: number;
}

export const products: Product[] = [
  {
    id: "p1",
    sku: "WH-1001",
    name: "Wireless headphones",
    category: "Audio",
    unitsInStock: 84,
  },
  {
    id: "p2",
    sku: "SP-2044",
    name: "Bluetooth speaker",
    category: "Audio",
    unitsInStock: 37,
  },
  {
    id: "p3",
    sku: "KB-3312",
    name: "Mechanical keyboard",
    category: "Peripherals",
    unitsInStock: 52,
  },
  {
    id: "p4",
    sku: "MS-3390",
    name: "Wireless mouse",
    category: "Peripherals",
    unitsInStock: 118,
  },
  {
    id: "p5",
    sku: "MN-4501",
    name: "27-inch monitor",
    category: "Displays",
    unitsInStock: 21,
  },
  {
    id: "p6",
    sku: "MN-4520",
    name: "Ultrawide monitor",
    category: "Displays",
    unitsInStock: 9,
  },
  {
    id: "p7",
    sku: "DK-5810",
    name: "Laptop dock",
    category: "Accessories",
    unitsInStock: 46,
  },
  {
    id: "p8",
    sku: "CB-5899",
    name: "USB-C cable, 2m",
    category: "Accessories",
    unitsInStock: 260,
  },
];
```


Nothing is checked when that grid loads, so the button exports every row even
though it asks for `onlySelected: true`. Check a few rows and click again to see
the file narrow.

The two halves are separate on purpose: the serializer is pure, so you can
upload the result, hand it to a worker, or assert on its bytes in a test without
a browser.

## Getting a file

The framework-agnostic form serializes a snapshot directly, without a React
component in the loop at all:

```tsx
import {
  defaultSaveFile,
  resolveDataScope,
  serializeCsv,
} from "@pretable/react";

const file = serializeCsv({
  rowModelSnapshot,
  columns: grid.getColumns(),
  scope: resolveDataScope(dataHonesty, processing),
});

if (file) defaultSaveFile(file, { name: "positions" });
```

`serializeCsv` requires a `scope`, and `resolveDataScope` is how you answer it
honestly. It returns `"all"` only where the grid can prove the loaded records
are the whole matching population. Filtering that the engine did itself
qualifies: with no `processing` prop, or with `processing.filter` set to
anything other than `"external"`, the rows in the model are that population.
Under external filtering it takes evidence — `resultMeta.total` must be `exact`
and count no more records than the grid has loaded. Everything else is
`"loaded"`.

That second arm is the reason the helper is public. A server that applied the
filter knows a matching count the grid cannot re-derive from the rows it was
handed, so an export over those rows is a window onto the result rather than
the result, and an estimated total proves nothing either way. A `"loaded"`
scope is exactly what puts the `unloaded-rows` omission below into the returned
file, so hardcoding `"all"` does not make the export complete — it only stops
it saying that it isn't. See [matching totals](/docs/server-data/totals).

From a `<PretableSurface>`, the same thing is one call on the grid handle —
this is exactly how the button above is wired:

```tsx
export function ProductGrid({ products }: { products: Product[] }) {
  const grid = useRef<PretableSurfaceGrid<
    Product,
    string,
    readonly PretableColumn<Product>[]
  > | null>(null);

  return (
    <>
      <button
        onClick={() => grid.current?.exportCsv({ onlySelected: true })}
        type="button"
      >
        Export CSV
      </button>
      <PretableSurface<Product>
        ariaLabel="Products"
        columns={columns}
        getRowId={(row) => row.id}
        onGridReady={(ready) => {
          grid.current = ready;
        }}
        rowSelectionColumn={{ enabled: true }}
        rows={products}
        viewportHeight={280}
      />
    </>
  );
}
```

Two things there are load-bearing. `<PretableSurface<Product>` needs its type
argument written out — inference from `rows` alone widens the row to `object`
and the column types stop lining up. And `exportCsv` lives on the handle
`onGridReady` gives you rather than on a ref to the component, because the
surface ships no toolbar: the button is yours, so the trigger has to be too.

`onlySelected` restricts the file to the checked rows, as in the example
above. **An empty selection exports everything**, deliberately — a button
that silently downloads a zero-row file is indistinguishable from one that is
broken. Pass `rowIds` instead to name the rows yourself; passing both throws,
because they are two ways to say the same thing and quietly preferring one
would drop rows you asked for.

Three props on the surface shape every export from it, mirroring `onCopy` and
`copyToClipboard` on the clipboard side: `csvOptions` sets defaults that a
per-call option overrides, `onExport` replaces the serialization step and can
return `null` to cancel, and `saveFile` replaces delivery — upload it, hand it
to a worker, or call `defaultSaveFile(file, { name })` to keep the download and
choose the name.

A failed save is not thrown. It is warned and announced, because by then the
user has already pressed the button and needs to be told rather than to have an
exception raised behind them.

## The file tells you what it could not contain

Every mainstream grid ships a partial file silently. AG Grid drops server-side
stub rows on a branch whose own comment says so, with no counter and no log.
MUI's lazy-loading path emits one blank row per skeleton, so the row count looks
right while the data is gone. Neither tells the person who clicked the button.

`serializeCsv` returns `omissions` — a list of reasons the file is short, each
carrying its own evidence.

| `kind`             | Also carries             | Means                                        |
| ------------------ | ------------------------ | -------------------------------------------- |
| `unloaded-rows`    | `scope`                  | The grid held a window, not the population.  |
| `collapsed-groups` | `expansionOverrideCount` | Grouping hid rows inside collapsed branches. |

`complete` is derived from `omissions.length === 0`, never set independently, so
it cannot drift from the reasons. A boolean alone was the wrong shape: "is this
complete" is an open question, and a flag has to be updated every time a new way
to be incomplete is discovered. A union does not — a new reason is a new
variant, so an exhaustive `switch` becomes a compile error rather than a silently
wrong `true`.

The marker is deliberately **not** written into the file. RFC 4180 has no comment
syntax, so a marker row is a data row: pandas reads it as a record with one
populated column and `NaN` across the rest. Trading a silent short file for a
silently corrupted one is not an improvement. `defaultSaveFile` puts `-PARTIAL`
in the **filename** instead, which travels with the artifact when it is emailed
onward and costs the bytes nothing.

## Formula escaping

A cell beginning `=`, `+`, `-`, `@`, tab or carriage return is executed as a
formula by Excel, Numbers and Sheets. Escaping is **on by default**, and it
vouches on the value rather than on the column's declared type.

A genuine `number`, `bigint`, `boolean` or `Date` cannot begin a formula, so
those are exempt by their JavaScript type. Everything else is judged on the
string it produced.

That distinction is the whole design. Gating on `column.type` looks equivalent
and is not: a row is `Record<string, unknown>`, so a string from an API sits
happily in a column declared `number`, and its formula ships unescaped. Gating
on the leading character instead has the opposite failure — it corrupts negative
numbers, which is a shipped bug in Jira (`-1000` exported as `'-1000` across
9.9.0–9.12.2) and is live in MUI X today.

```ts
serializeCsv({
  // …
  options: { escapeFormulas: (value) => value.startsWith("=") },
});
```

Escaping does cost something, and it is worth knowing before you leave it on.
Inside a `text` column the trigger set catches ordinary data — `+1 555 010 0100`,
`@brianlove`, `-5 to -3` all gain a leading apostrophe. Excel hides it; pandas,
Postgres `COPY` and `csv.reader` do not. Pass `false` for a machine-consumed
export, or narrow the predicate as above.

## Options

| Option                 | Type                                        | Required | Description                                                                                              |
| ---------------------- | ------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------- |
| `delimiter`            | `string`                                    | no       | Field separator, default `","`. Excel follows the OS list separator, which is `;` across much of Europe. |
| `bom`                  | `boolean`                                   | no       | Prepend a UTF-8 BOM, default `true`. Excel does not detect UTF-8 without one.                            |
| `includeHeaders`       | `boolean`                                   | no       | Emit the header row, default `true`.                                                                     |
| `escapeFormulas`       | `boolean \| PretableFormulaEscapePredicate` | no       | Default `true`, value-vouched as above.                                                                  |
| `includeGroupRows`     | `boolean`                                   | no       | Emit group header rows, default `true`.                                                                  |
| `includeAggregateRows` | `boolean`                                   | no       | Emit group aggregate values, default `true`.                                                             |
| `columnIds`            | `readonly string[]`                         | no       | Column subset **and** order. An unknown id throws rather than narrowing the file in silence.             |
| `rowIds`               | `ReadonlySet<TRowId>`                       | no       | Restrict to these data rows — how selection-only export is expressed.                                    |

`TRowId` is your grid's row-id type, not the `string | number` union. That is
deliberate: typed against the union, a `Set<number>` on a string-id grid
type-checked, matched nothing, and produced a header-only file — a mistyped id
silently emptying the export, on a page whose whole subject is refusing to drop
rows quietly.

## Encoding and format

The output is RFC 4180: `CRLF` line endings, minimal quoting, and inner quotes
doubled. Quoting is minimal rather than universal because quoting every field
destroys the one in-band convention CSV has for distinguishing `NULL` from an
empty string — Postgres writes `NULL` as a bare empty field and an empty string
as `""` — and it buys nothing against formula injection, which quoting has never
prevented in any spreadsheet.

Values come from the column's configured formatter, the same one the grid
displays, so the file matches the screen. Note the consequence: a grouped number
like `1,234.57` contains the delimiter, so it is quoted, so Excel imports it as
text and the column will not sum. That is the cost of honouring a format you
configured.

## Delivery

`defaultSaveFile` builds a `Blob` and clicks a synthetic `<a download>`.

That is chosen over `showSaveFilePicker` for one decisive reason: `<a download>`
has **no user-activation requirement**, so it still works after an `await`, while
the picker is transient-activation-gated and throws once any asynchronous work
has happened. Chrome's own guidance is to open the picker _before_ doing the
work — which would make the user name a file before knowing whether the export
succeeded.

Filenames are sanitized for Windows, macOS and Linux at once, because everything
the browser would otherwise do to a name is lossy, silent, and differs by OS:
Chromium replaces `:` with `_` on **every** platform, strips leading dots, and
diverges between Windows and POSIX on trailing ones.

## Next

<Card title="Clipboard" href="/docs/grid/clipboard">
  Cmd+C copy, which shares this page's formatting pipeline.
</Card>
