Importing CSV
Load a CSV or spreadsheet into a table, with every cell checked against its column before anything is written.
You can load rows into a table from a CSV file or an Excel workbook, either in the console or from a handler. Both use the same import engine, and it works the same way in each:
- Nothing is written until the whole file checks out. Every cell is coerced and checked
against the column that will hold it first: enum membership, string length, integer range,
decimal scale, JSON validity,
NOT NULL. - A bad value is an error, never a guess. It is reported by spreadsheet line and column. It is never silently truncated, replaced with a default, or swapped for a "close" value.
- References and scope follow the API's rules. A reference is a
_uidholding the referenced row's uid, every imported row gets a freshuid, and a file can never setorg_id.
An import from a handler is an ordinary governed write: rows go in through the same path as a create, so the table's create permission, its switched-off operations, your database quota and organization scoping all apply, and each row is a change event that realtime subscribers receive. An import in the console is you, the app's owner, editing your own data, like adding rows by hand in the table editor.
invoices, vendors, payments). Your app has only the tables you create, so put your own table and column names in their place. About the example appIn the console
Open the import
Open the table in the app's Tables section and choose Import. Pick a source:
Upload a file from your machine (.csv, or an Excel workbook, which is converted to CSV in
your browser), or From storage, a file already in one of the app's
storages.
Choose whose rows these are
In a table scoped by organization, every row belongs to one of your app's organizations. Pick which one. The import waits until you do. A shared table has no such choice.
Map the columns
Each header in the file is matched to a column, pre-filled with a best guess you can change. For each header choose:
- an existing column,
- Do not import, to leave it out, or
- Create, to add a new column to the table from the file. The type is proposed from the values (text, whole number, decimal, true/false, date, date and time, JSON), and you can change it. New columns are always nullable, because the table may already hold rows.
A reference to another table is mapped under its _uid name and takes the referenced row's uid,
the same as the API: an invoices file carries vendor_uid, not a vendor id. A uid column in
the file is never imported; every imported row gets a fresh uid.
Migrating old data? Tick Let me map the audit dates to map the file's own created_at,
updated_at and deleted_at, so last year's invoices do not all claim to have arrived today.
id, org_id, created_by and updated_by are never importable.
Fix what the preview finds
The preview shows the values as they will be stored (coerced, not as typed in the file) and every problem, by line and column:
{ "line": 412, "column": "status", "value": "Pending", "message": "'Pending' is not one of: received, matched, approved, paid, disputed, on_hold" }A required column the file does not fill (NOT NULL, with no default) is called out by name
before anything else, because it would fail every row. Map a header onto it, give a value to use
on every row, or make it optional in the table editor.
Import
By default an import is all or nothing: any bad row and nothing is written. Tick Import the valid rows and skip the rest to keep the good rows and leave the reported ones behind.
Exports re-import cleanly
The table editor's Export writes the same shape an import reads: uid and _uid references,
no ids. Because uids survive the move and ids do not, an export loads into this app or another one
without remapping a single reference.
From a handler
sdk.importCsv runs the same engine inside a handler, so you can offer imports
in your own app. Pair a dry run with a commit, the way the console does:
export default async function importInvoices(data = {}, ctx) {
const { sdk } = ctx;
const options = {
table: "invoices",
csv: data.csv, // or csvBase64
mapping: { "Invoice #": "invoice_number", "Vendor": "vendor_uid", "Total": "amount" },
defaults: { currency: "USD" },
onError: "abort",
};
// 1. Validate everything, write nothing.
const preview = await sdk.importCsv({ ...options, dryRun: true });
if (preview.errorRowCount > 0 || preview.missingRequiredColumns.length > 0) {
return { ok: false, errors: preview.errors, missing: preview.missingRequiredColumns };
}
// 2. Commit.
const result = await sdk.importCsv(options);
return { ok: true, imported: result.imported };
}The options:
| Option | Meaning |
|---|---|
table | The destination table. |
csv / csvBase64 | The file, as text or base64. To import a file from storage, read it with sdk.storage.download first. |
mapping | Header to column: { "Email Address": "contact_email" }. A header left out falls back to a column of the same name. |
defaults | Values written to every row, checked like a cell. The answer to a required column no header fills. |
dryRun | Validate and report, write nothing. |
onError | "abort" (default) imports nothing if any row is bad; "skip" imports the clean rows. |
emptyAsNull | Treat an empty cell as NULL rather than an empty string. |
ignoreUnknownColumns | Drop headers with no matching column instead of failing. |
batchSize | Rows per insert batch. |
maxBytes / maxRows | Lower the ceiling for this call. They can only tighten the deployment's limit, never raise it. |
A dry run returns the resolved mapping, the headers, rowCount, errorRowCount, the first
errors, previewRows (coerced) and missingRequiredColumns. A commit returns imported and
skippedRows. Under onError: "abort", skippedRows is always 0, because a bad row raises
instead, so a partial import is never mistaken for a complete one.
Importing needs the handler's sdk:import capability. Inside
sdk.withTransaction, a failed import rolls back with everything else.
Errors
error.code | When |
|---|---|
CSV_EMPTY, CSV_NO_ROWS, CSV_NO_HEADER | The file has nothing to import. |
CSV_PARSE_FAILED | The file is not valid CSV (an unclosed quote, a ragged row). |
CSV_TOO_LARGE, CSV_TOO_MANY_ROWS | Over the size or row ceiling. |
CSV_UNKNOWN_COLUMNS | A mapped column does not exist (and ignoreUnknownColumns is off). |
CSV_NO_MAPPED_COLUMNS | No header lines up with any column. |
CSV_UNKNOWN_DEFAULTS | A default names a column the table does not have. |
CSV_INVALID_DEFAULT | A default cannot be stored in its column, which would fail every row. |
CSV_MISSING_REQUIRED_COLUMNS | A required column has nothing mapped onto it. details.columns names them. |
CSV_ROWS_INVALID | Rows hold values their columns cannot store, under onError: "abort". |
ROW_REFERENCE_NOT_FOUND | A _uid value names no row in the referenced table. |
Duplicates are not skipped quietly. A row that collides with a unique column fails the insert,
and onError: "skip" covers values a column cannot store, not collisions, so de-duplicate the
file before importing.