Forms to CSV 00 Overview
How your forms become CSV
Every form your organization publishes becomes datasets in this API. Its fields become columns, and a repeating table inside it becomes a dataset of its own. Nobody at Provision defines those columns: they follow the form as your team built it, and change when the form changes.
A worked example
The example beside this text is a small form and the CSV it exports. The numbers match each field to the columns it produces:
- A number field becomes one column, named from the field's key (
water_temp_c), not its label. - A chemical select becomes two columns: the chemical's name, and its id in
_idfor joining toresources_chemicals. Every resource select works this way. - A checkbox group (field type
selectboxes) becomes onet/fcolumn per option, namedfield__option— whereoptionis the option's stored value, which is not always the label you see. Here they match; read the real names fromGET /v1/datasets. - A signature becomes the signer's name in
_nameand the date stored with it in_date. - A grid (field type
datagrid,editgridorprovision-editgrid) becomes a separate dataset, named from the form name and the grid's label (form__grid), with one row per grid row. Each row carries its submission'sidto join on, and arow_index.
Every row also carries a fixed envelope of columns that come from the submission, not from a field. A submission row has id, record_id, its status, the site and the timestamps; a grid row has a smaller set: id, row_index, the site columns, the last_modified columns, and one <outer grid>_row_index column per grid it sits inside. CSV columns lists every envelope column and every field type.
- Water temperature (C)82.4
- SanitizerQuat 200
- Areas cleaned☑ Floor ☐ Drains
- Signed off byMaria Lopez
- Equipment checksSlicer 3: Yes · Conveyor B: No
daily_sanitation_check.csv
| column | value |
|---|---|
| id | 0191…a41c |
| record_id | 26-195-0042 |
| site_name | Plant 2 · Packing |
| … | … |
| water_temp_c | 82.4 |
| sanitizer | Quat 200 |
| sanitizer_id | 7a1e…09bd |
| areas_cleaned__floor | t |
| areas_cleaned__drains | f |
| signed_off_name | Maria Lopez |
| signed_off_date | 2026-07-14 08:12:40+00 |
| last_modified | 2026-07-14 08:12:44.518+00 |
daily_sanitation_check__equipment_checks.csv
| column | row 1 | row 2 |
|---|---|---|
| id | 0191…a41c | 0191…a41c |
| row_index | 1 | 2 |
| site_name | Plant 2 · Packing | Plant 2 · Packing |
| … | … | … |
| equipment | Slicer 3 | Conveyor B |
| clean | Yes | No |
| last_modified | 2026-07-14 08:12:44.518+00 | 2026-07-14 08:12:44.518+00 |
Submissions and grids
- Each published form becomes one submission dataset, with one row per submission, so
idis unique within it. A form with several recording steps still has one row per submission: every step's fields are columns of that row. If the same field key appears on more than one step, the column holds one value, chosen in this order: a filled value beats an empty one; then the step completed most recently wins. So a field answered on one step and left blank on another exports the answer. If that still leaves a tie, the surviving value is stable but arbitrary — it is settled by the steps' internal row ids, which are generated together when the submission is created and carry no step order. It does not change between rebuilds, but you cannot predict which step it came from, so do not design around "the later step wins". Use one recording step per field key when it matters which answer you get. - Each grid (repeating table) in the form becomes its own grid dataset, named
parent__grid, with one row per grid row.
A grid cannot be flattened into one CSV row, which is why it gets its own dataset. A form with a grid means two datasets for you to join.
Grid rows carry the id of the submission they were captured in, at every depth, so id is the join key, and it is not unique within a grid dataset. Grid rows also carry row_index, which tells rows of one submission apart and is needed to page them.
A submission contributes no grid rows when its grid is empty. Older or unusual submissions can also store something other than a list of rows under a grid, and rather than fail the dataset the export makes the best of it: rows stored as an object keyed by number export as normal rows, in number order; a single object exports as one row, with values wherever its keys match the grid's fields; and a plain value exports no rows. The submission itself exports normally, so a submission with nothing in the grid dataset is expected, not a lost page.
A submission appears only once a recording step has been saved. One that was created but never filled in has no row at all, not a row of empty fields. So your row count will not match the record count staff see in the app, and Pending is a status you will rarely, if ever, see in an export.
Nested grids
A grid inside another grid becomes its own dataset too, at any depth. Each level hangs off the one directly above it, so a nested grid's parentDataset is the outer grid's dataset, not the form's. Read the hierarchy from parentDataset, not from the key.
A nested grid's row_index counts all its rows within the submission in one unbroken sequence, rather than restarting in each parent row, so id plus row_index stays unique and it pages like any other dataset.
It also has one extra column per grid above it, <outer grid>_row_index, holding that grid's position inside its parent. For a grid directly inside a top-level grid, that equals the outer grid dataset's row_index, so join a nested row to the row it was captured under on the same id and <outer grid>_row_index = outer row_index. That join holds even when some outer rows have an empty nested grid.
Only the join to the top-level grid holds. An ancestor column is that ancestor's position inside its own parent, while every dataset publishes row_index as a sequence across the whole submission. Those agree only for a top-level grid. In a chain three or more deep, joining to an intermediate grid attaches rows to the wrong parent without any error. If you need to rebuild a hierarchy that deep, tell us: it needs a key we do not publish yet.
A parent row with an empty nested grid adds no rows to the nested dataset, just as a submission with an empty grid adds none to that grid's dataset. A missing nested row is not a missing parent row.
Forms to CSV 01 Columns
CSV columns
Your CSV is your form, flattened.
Every column of a form dataset is either part of a small fixed envelope (which submission, which site, when it last changed) or comes from a field your organization put on the form. A submission dataset and a grid dataset have different envelopes; fields work the same way in both.
This model is for form datasets only. Resources and deviations have fixed columns of their own, listed on their page.
Three things here apply to every dataset, not just form datasets: the CSV dialect, the rule that deleted records stop being exported, and, for deviations, the record and deviation codes.
Every CSV also ends with a _cursor column, on every dataset including resources and deviations. It is added by the export rather than stored on the dataset, and it holds this row's paging position — see The loop. It is listed in GET /v1/datasets alongside the real columns, so a loader built from that listing has somewhere to put it, and its value is opaque: store the string, do not model it. Nothing else in the export starts with an underscore, so you can drop it on that alone if you would rather not keep it.
The order of the dataset's own columns is not part of the contract, and editing a form changes them; see When someone edits the form. _cursor is appended after all of them, which is why it comes last — read it, like every other column, by header name rather than by position.
Envelope columns
These come from the submission, not from form fields. They are the envelope of a submission dataset; a grid dataset has the smaller set in Grid columns, however deeply it nests. Beyond these and the form fields, every CSV ends with one more column, _cursor, which the export appends and no dataset stores.
| Column | Label | Type | Meaning |
|---|---|---|---|
id | ID | uuid | Unique identifier of this submission. |
record_id | Record ID | text | The record code identifying the submission, unique within your organization. |
site_name | Site | text | Name of the site the submission was recorded against. |
site_id | Site - ID | uuid | Unique identifier of the site the submission was recorded against. |
site_path | Site - Path | text | The site's full ancestor chain as text, so matching one ancestor selects that whole site subtree. |
status | Status | text | Status of the submission. |
is_archived | Archived | boolean | Whether the submission has been archived. Stored separately from status. |
started_at | Started At (UTC) | dateTime | When the submission was started, in UTC. |
started_at_local | Started At (Site Local) | localDateTime | When the submission was started, in the site's own time zone; UTC when the site has none set. |
completed_at | Completed At (UTC) | dateTime | When the submission was completed, in UTC. |
completed_at_local | Completed At (Site Local) | localDateTime | When the submission was completed, in the site's own time zone; UTC when the site has none set. |
last_modified | Last Modified (UTC) | dateTime | The later of when the submission itself changed and when the entered data changed, in UTC. |
last_modified_local | Last Modified (Site Local) | localDateTime | The same last-change time in the site's own time zone; UTC when the site has none set. |
Each envelope timestamp appears twice: in UTC, and as *_local in the site's own time zone. Compute on the UTC column and use the local one for display: *_local values have no offset, so they are ambiguous around daylight-saving changes. Timestamps that come from form fields have no _local twin; they are UTC instants.
status is one of five values: Pending, InProgress, Completed, Cancelled, Rejected. Archived is never a status. Archiving is tracked separately: status keeps the status underneath (usually Completed), and is_archived says whether it was archived. The app shows such a record as Archived, so read both columns to match what staff see.
site_path is a chain of site ids, not names. It lists the row's site and every site above it, outermost first, joined by dots, ending with the row's own site_id. Matching one id anywhere in it selects that site and everything beneath, which makes it useful for rolling up a subtree. Split on . for the ancestors; it is not meant to be read by people.
Key your site dimension on site_id, not site_name. site_id never changes; site_name is the current name, so a join on it splits a renamed site's history in two, silently.
Every form dataset, submission and grid alike, has site_id, site_name and site_path, and so does deviations. Most resource datasets do not; see Site, status and record columns.
Because you only receive rows that still exist, a deletion is invisible to the cursor, and a record you already loaded stays in your warehouse unless you look for it. If deletions matter, re-read the dataset in full on a schedule (the same loop, with no cursor) and treat ids you no longer receive as deleted. This matters most for resource datasets, because live submissions keep referring to resources that no longer exist.
Verification columns
If the form has a verification step with fields on it, these columns appear too, and each verification field becomes a column prefixed verification_:
| Column | Label | Type | Meaning |
|---|---|---|---|
verified_at | Verified At (UTC) | dateTime | When the submission's most recent verification was completed, in UTC. |
verified_at_local | Verified At (Site Local) | localDateTime | The same verification time in the site's own time zone; UTC when the site has none set. |
verified_by | Verified By | text | Display name of the person who last changed that verification. |
They depend on verification fields, not the step: a verification step with no fields adds none of these columns, and neither does a form without one.
A verification can also change without the row's last_modified moving, which an incremental sync will not show: re-read in full if you report on these columns.
Each row reflects only one verification, and an in-progress one takes precedence over a completed one. A step saved but not finished shows an empty completion time and the in-progress editor's name, hiding a verification that did complete. When reporting on verification, treat a verifier without a completion time as not yet verified, not as missing data.
Grid columns
Every grid dataset, at any depth, has this smaller envelope, plus one <outer grid>_row_index column per grid it sits inside; see Nested grids.
| Column | Label | Type | Meaning |
|---|---|---|---|
id | ID | uuid | Unique identifier of the submission this row belongs to. |
row_index | Row Index | number | Position of this row within its submission, starting at 1. |
site_name | Site | text | Name of the site the submission was recorded against. |
site_id | Site - ID | uuid | Unique identifier of the site the submission was recorded against. |
site_path | Site - Path | text | The site's full ancestor chain as text, so matching one ancestor selects that whole site subtree. |
last_modified | Last Modified (UTC) | dateTime | The later of when the submission itself changed and when the entered data changed, in UTC. |
last_modified_local | Last Modified (Site Local) | localDateTime | The same last-change time in the site's own time zone; UTC when the site has none set. |
A grid row carries no record_id, status or start and completion times: reach them through the id it shares with its submission.
Field types
Most fields become exactly one column. These are the ones that do not, and they are where integrations usually go wrong, so check your forms for them.
| Field type | Column name | Columns | Cell value | Type |
|---|---|---|---|---|
areaSelect | <field> | 1 | The selected area's name, as snapshotted on the submission. | text |
areaSelect | <field>_id | 1 | The selected area's id, for joining to resources_areas.id; blank when no valid id was stored. | uuid |
checkbox | <field> | 1 | True or false as stored; blank when the stored value is neither. | boolean |
chemicalSelect | <field> | 1 | The selected chemical's name, as snapshotted on the submission. | text |
chemicalSelect | <field>_id | 1 | The selected chemical's id, for joining to resources_chemicals.id; blank when no valid id was stored. | uuid |
customSignature | <field>_name | 1 | The signer's first and last name joined by a space; blank on both sides when nothing was signed. | text |
customSignature | <field>_date | 1 | The date stored with the signature; blank unless it is written ISO-ordered and fully padded. | dateTime |
customerSelect | <field> | 1 | The selected customer's name, as snapshotted on the submission. | text |
customerSelect | <field>_id | 1 | The selected customer's id, for joining to resources_customers.id; blank when no valid id was stored. | uuid |
datetime | <field> | 1 | The date and time entered; blank unless it is ISO-ordered and fully padded, starting with a four-digit year. | dateTime |
datetimepicker | <field> | 1 | The date and time entered; blank unless it is ISO-ordered and fully padded, starting with a four-digit year. | dateTime |
documentSelect | <field> | 1 | The selected document's name, as snapshotted on the submission. | text |
documentSelect | <field>_id | 1 | The selected document's id, for joining to resources_documents.id; blank when no valid id was stored. | uuid |
employeeSelect | <field> | 1 | The selected employee's first and last name joined by a space, as snapshotted on the submission. | text |
employeeSelect | <field>_id | 1 | The selected employee's id, for joining to resources_employees.id; blank when no valid id was stored. | uuid |
equipmentSelect | <field> | 1 | The selected equipment's name, as snapshotted on the submission. | text |
equipmentSelect | <field>_id | 1 | The selected equipment's id, for joining to resources_equipment.id; blank when no valid id was stored. | uuid |
lotSelect | <field> | 1 | The selected lot's code, not its name — lots carry a code rather than a name. | text |
lotSelect | <field>_id | 1 | The selected lot's id, for joining to resources_lots.id; blank when no valid id was stored. | uuid |
number | <field> | 1 | The number entered; blank when the stored text is not a plain signed decimal, optionally with an exponent. | number |
productCategorySelect | <field> | 1 | The selected product category's name, as snapshotted on the submission. | text |
productCategorySelect | <field>_id | 1 | The category's id, for joining to resources_product_categories.id; blank when no valid id was stored. | uuid |
productSelect | <field> | 1 | The selected product's name, as snapshotted on the submission. | text |
productSelect | <field>_id | 1 | The selected product's id, for joining to resources_products.id; blank when no valid id was stored. | uuid |
radio | <field> | 1 | The chosen option's label, or the stored value when no option matches it. | text |
recipeSelect | <field> | 1 | The selected recipe's name, as snapshotted on the submission. | text |
recipeSelect | <field>_id | 1 | The selected recipe's id, for joining to resources_recipes.id; blank when no valid id was stored. | uuid |
select | <field> | 1 | The chosen option's label, or the stored value when no option matches it. | text |
selectboxes | <field>__<option> | one per option | True when that option was ticked, false when it was not, blank when the stored value is not true or false. | boolean |
signature | <field>_name | 1 | The signer's first and last name joined by a space; blank on both sides when nothing was signed. | text |
signature | <field>_date | 1 | The date stored with the signature; blank unless it is written ISO-ordered and fully padded. | dateTime |
supplierSelect | <field> | 1 | The selected supplier's name, as snapshotted on the submission. | text |
supplierSelect | <field>_id | 1 | The selected supplier's id, for joining to resources_suppliers.id; blank when no valid id was stored. | uuid |
survey | <field>__<question> | one per question | That question's answer: the option's label, or the stored value when no option matches it or none are configured. | text |
| anything not listed above — the default branch | <field> | 1 | The stored value as plain text. Also where a survey with no questions, and a radio, select or selectboxes with no configured options, land. | text |
Cell value says what a value means. How it is written in the CSV is covered in Reading the values: for example, a boolean is t or f, never true or false.
In the column names, <field> is the field's slugified key and <question> a survey question's. <option> is different: it is the option's stored value, slugified — not its label. The two are set separately in the form builder and often differ, so an option labelled "Floor" with the value opt1 gives you areas_cleaned__opt1, not areas_cleaned__floor. The option's label reaches the export only as the column's label. Take these column names from GET /v1/datasets, never from the labels you can see on the form.
A grid on a recording step is not in the table because it produces no column of its own: it becomes its own dataset, at any depth. See Nested grids. A grid on a verification step is not split out that way, so avoid putting one there.
- "Blank" in the table above means nothing was recorded, not an empty cell. A
_namecolumn joins a first and last name with a space, so an unsigned signature or an empty employee field exports a single space; see Reading the values. Trim before testing for emptiness. - Choice fields store a value but export its label, so the CSV reads the way staff see the form. One label is exported per stored value: options from every published version are merged on the stored value, and the newest published label for it wins, from the next rebuild on. An option only older versions had keeps its entry, so old rows still read correctly. Only a value that was never an option in any published version (usually imported or migrated data) passes through as it is. Because the label is what lands in the CSV, renaming an option changes the text of old rows too; key on something else if you need a value that never changes.
- A checkbox group (
selectboxes) becomes one boolean column per option, so adding an option adds a column. A singlecheckboxis one boolean column. - A reference field exports the selected record, not a scalar. A
selectthat draws its options from a data source instead of a configured list stores the record that was picked, and unless the form narrows it to a single property, that whole record is what lands in the cell, as a JSON document: an object with the record's own fields, nested objects and all. Nothing summarises or shortens it, so these are typically the widest cells an export has — a single one can run to hundreds of thousands of characters. Read them as JSON rather than as text, and see Reading the values for what that costs your parser. The*Selecttypes above are the ones that are not like this: they are listed because they export a name and an id instead.
Reading the values
The CSV dialect is fixed, for every dataset:
| Aspect | Behaviour |
|---|---|
| Encoding | UTF-8, with a byte-order mark (EF BB BF) before the header row |
| Header row | Always present, always the first line |
| Delimiter | Comma |
| Quoting | Double quote; an embedded quote is doubled ("") |
| Line endings | LF (\n), not CRLF |
| Embedded newlines | Allowed inside quoted fields, so your parser must handle multi-line rows |
| Maximum field size | None. No cap is enforced on a field, and a single one can run to hundreds of thousands of characters: a reference field exports the whole selected record as JSON, in one cell. See Field types |
| Timestamps | UTC, YYYY-MM-DD HH:MM:SS[.ffffff]+00, with a space between date and time, not the T of RFC 3339, and the offset written +00, not +00:00. The fraction has variable width: trailing zeros are trimmed, and it is left out on a whole second |
*_local envelope timestamps | Same layout in site-local time, with no offset. These columns report dataType localDateTime; see Column metadata |
| Dates | YYYY-MM-DD, never with a time or offset. These columns report dataType date; see Column metadata |
| Booleans | t / f, not true / false |
| Numbers | Plain decimal, . separator, no thousands grouping |
| Null vs empty string | A null is a bare empty field; an empty string is a quoted "" |
Five of these catch people out regularly:
- There is a byte-order mark. Decode as
utf-8-sig(Python) or strip the leadingEF BB BF. Decoded as plain UTF-8, your first header becomes\ufeffidinstead ofid, and every lookup on it misses. Inside a paging loop that reads like a paging bug rather than a decoding one: the first column isid, so the cursor you build from the last row comes out empty and the next call is a 400. Fix the decoding, not the loop. - Timestamps are not strict ISO 8601. Three things about them defeat a strict parser: a space instead of the
T, an offset written+00rather than+00:00, and a fractional part whose width varies from row to row and is absent on a whole second. Parse with an explicit format that allows all three, or with a lenient parser. - Booleans are
tandf. A check for"true"reads every boolean as false, silently. - Null and empty string differ (bare versus quoted), but most CSV libraries treat them the same. If the difference matters, check that your parser keeps it.
- A field can be larger than your parser allows. Python's
csvraises at 131,072 characters, so raise the cap before the first read:csv.field_size_limit(min(sys.maxsize, 2**31 - 1)). The cap is a C long, so a baresys.maxsizeis itself an error on Windows. A spreadsheet is the quieter version of the same problem: Excel keeps 32,767 characters of a cell and drops the rest without saying so, so load these datasets with something else.
A name column built from two parts is never empty. The <field>_name column of a signature or customSignature, and the name column of an employeeSelect, join a first and last name with a space. The space stays even when both halves are missing, so an unsigned signature or empty employee field exports as a single space: not blank, not NULL. Trim before testing for emptiness; WHERE signed_off_name <> '' does not filter these out, and a "signed" count built that way is wrong. The same goes for contact_name and employee name on resource datasets.
Every successful export also has Content-Type: text/csv and Content-Disposition: attachment; filename="<dataset>.csv", so a browser treats it as a download. The body is streamed, so there is no Content-Length to size a buffer from or check completeness against.
Record and deviation codes
record_id is the short code staff see on a record in the app. It is on every submission dataset (grid datasets do not have it; reach it through the id they share with their submission) and on deviations, which also has code, the deviation's own code, and linked_record_code, the code of the record linked to resolve it. These are the codes to quote to Provision users, and the columns integrations most often mistake for keys. A resource dataset can have an unrelated record_id column from a custom attribute; nothing in this section applies to that one.
They are not join keys. Every dataset's id is a uuid, and id (plus row_index on a grid dataset) is what you upsert on; see Incremental sync. Use codes for display and for talking to your Provision users.
A linked_record_code does not mean the deviation was resolved. It is filled in as soon as a record is linked as the intended resolution, before that record has resolved anything: the row shows status InProgress and is_resolved f, and may stay that way indefinitely. is_resolved is the column that answers the question; treat linked_record_code as "where to look", not "done".
The current formats are a date part and a daily counter, with deviations prefixed D-:
| Column | Where it appears | Current format | Example |
|---|---|---|---|
record_id | Every submission dataset, and deviations | YY-DDD-NNNN | 26-256-0001 |
linked_record_code | deviations | YY-DDD-NNNN | 26-256-0037 |
code | deviations | D-YY-DDD-NNNN | D-26-256-0001 |
YY is the last two digits of the year and DDD the day of the year (001 to 366), both in the time zone of the record's site, or in UTC when the site has no time zone set (a per-site setting that is not always filled in). Near midnight those give different days, so a code's date part is the site's calendar day, not yours, and not a reliable way to date a record: use last_modified or the record's own date columns. A site with no time zone at all gets UTC in its *_local columns as well. NNNN counts within your organization and restarts at 1 each day. Record codes and deviation codes count separately, so the same number can appear in both on one day.
Four things follow, and each breaks a parser built from the examples above:
Older rows have a different format, permanently. Before these codes, record and deviation codes were six-character strings of digits and capital letters without dashes, like H7K3RQ, from an alphabet that leaves out the lookalikes 0, 1, 5, I, O and S (nothing should depend on that). They were never rewritten, so records and deviations from before the change keep them, and a full sync returns both formats in the same column. An old code has no -, so branch on the format, not on a date: treat a code without - as an opaque identifier. Record codes and deviation codes changed at different times, so a record in the current format can have deviations in the old one, and the other way round.
A current code is not fixed width. The counter is padded to four digits only while it fits. When a day's counter passes 9,999 in one organization, the next record code is 26-256-10000, and deviation codes cross the same boundary on their own counter (D-26-256-10000). Anything assuming a fixed length (11 characters for a record code, 13 for a deviation code) or slicing by position breaks that day, without warning. Once you know a code has a -, split on - and take the last part as the counter.
Codes are unique within your organization, not globally. If one warehouse table holds several organizations, put the organization in the key, or use id, which is unique everywhere.
The numbering has gaps, and is not a sort order. A day's counter can skip values, so a missing number is not a missing record, and counts cannot check completeness. And because the counter is not fixed width, sorting codes is not sorting by time: as strings, 26-256-10000 sorts before 26-256-9999. Order by last_modified instead.
Forms to CSV 02 Form changes
When someone edits the form
Your columns are your form's fields, so editing the form changes your columns. The change is eventual, not immediate: exports are rebuilt in the background shortly after a form is published, so expect a short delay before a new column appears.
Row data can lag the app slightly too: a submission saved seconds ago may not be in the very next export. It keeps the last_modified it was saved with, not the time it became exportable, so it can surface just behind a cursor you already hold — and a strict cursor never offers it again. Cover it as A row can arrive just behind your cursor describes, and do not treat an export as a real-time read.
Build your consumer to cope with everything below: select known columns by header name, ignore columns you do not recognise, and treat a missing column as empty rather than fatal.
Read the header row
Columns come out in whatever order the dataset defines, so column order is not part of the contract: adding a field to a form can move existing columns.
Parse by header name. A positional parser breaks the first time someone edits the form, and it breaks by putting values in the wrong columns, which is far worse than failing outright.
A long backfill can see the header change between pages, when a form is published mid-way. Read the header of every response; see The loop.
Columns accumulate
Columns accumulate. A dataset's columns are the union of the fields of every version of the form ever published, and publishing cannot be undone, so columns are added and in practice never removed. The one exception is a retyped field: it exports only the columns its newest type produces. Two smaller ones are below: emptying a grid or survey, and relabelling a grid that a nested grid joins on.
| Change to the form | Effect on the CSV |
|---|---|
| Add a field | A new column appears after the next rebuild. Existing rows have it empty. |
| Remove a field | The column stays, empty for new rows. |
| Rename a field's label | Column name unchanged. label follows the new label after the next rebuild. |
| Change a field's key | A new column appears and the old one stays. You get both. |
| Rename an option label | The exported text changes, for old rows too. See below. |
| Add a checkbox-group option | A new boolean column appears, at top level and inside grids alike. |
| Rename a grid | The dataset is renamed in place, keeping its rows. The old name is gone: it leaves GET /v1/datasets and its export returns 404. |
| Change a field's type | After the next rebuild the field exports as its new type: a single column changes type, and a type that exports different columns (a signature, a resource select, a checkbox group, a survey) replaces the old columns with new ones. See below. |
- The column count only grows. A long-lived form has a column for every field it ever had. Select the columns you need by header name.
- A removed field's history is never dropped. Fields and options removed in a later version keep their columns, because old submissions still have data in them. The exception is emptying a grid or survey completely: a published version with no fields or questions left in it drops the columns the older versions had. A retyped field is different; see Changing a field type.
- Two field keys can share a column name, and the second gets
_2; see the naming rules.
Renames
Renaming a grid moves its dataset, and the old name stops working. Rows, numbering and access all carry over to the new name; nothing is copied or left behind. The old name disappears from GET /v1/datasets, and its export returns 404, not an empty result, so a loader pinned to it stops with an error rather than going quietly stale.
Dataset names come from the form's name and grid labels: renaming a form or relabelling a grid renames its dataset, and because a grid's key is built from its parent's, renaming a form moves every grid dataset under it too. Column names come from field keys, so relabelling a field only changes its label. The exception is a nested grid's <outer grid>_row_index column, named from the outer grid's label: relabelling the outer grid renames the column you join on. Store the key from GET /v1/datasets, re-read the list on a schedule, and alert when a key vanishes: that is a rename.
Renaming an option label rewrites history. Options are merged on their stored value and the current label is exported, so every row gets the new label, however old. If you need a choice that never changes, key on something other than the exported text.
A rewrite like that moves no row's last_modified, so an incremental sync never offers the changed rows again. Re-read the dataset in full after a form edit that changes what old rows export.
Changing a field type
Editing a field in place reaches the export from the next rebuild. A field's label and type come from the newest published version, so renaming it updates label, and retyping it changes the column's type, not at the moment of publishing but shortly after. Retyping is the disruptive one: values change shape under a consumer that already inferred a type. Prefer adding a new field.
A retype that changes a field's column shape drops its old columns. A field exports only the columns its newest type produces. Turn a checkbox group into a radio and its <key>__<option> columns go, along with their historical values, replaced by one <key> column; a resource select turned into text loses its <key>_id. A field whose old and new types both export a single <key> column (text, number, date and time, checkbox, radio, select) keeps its column.
Keeping the column does not mean keeping the values. A radio or select with options exports the option's current label, while a text field exports the stored value, so retyping one into the other rewrites every cell — old rows included — while the column's name and its published dataType both stay text. Nothing in GET /v1/datasets marks that, so re-read the dataset in full after such a retype.
A retype empties cells that do not fit the new type. The new type applies to every row, including rows recorded under the old one, and a stored value it cannot represent exports as empty, not as text. The usual case is a text field holding n/a or TBC becoming a number. Watch date and time fields, where a plausible value can still come out empty: they read only ISO-style values with the four-digit year first, 2026-02-13, optionally followed by a time (T or a space, seconds and fractions optional) and optionally an offset (such as Z, +02:00, +0200 or +02). So 02/13/2026, 13/02/2026, 2026-2-3 and 20260213 all export empty. That is deliberate: 02/13/2026 is ambiguous, and an empty cell is better than a wrong guess. The submission itself is untouched and still shows in the app; only the exported cell is empty. If a retyped column matters, check old rows for empty cells before relying on it.