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 _uid holding the referenced row's uid, every imported row gets a fresh uid, and a file can never set org_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.

The examples on this page use the tables of a sample invoicing app (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 app

In 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:

OptionMeaning
tableThe destination table.
csv / csvBase64The file, as text or base64. To import a file from storage, read it with sdk.storage.download first.
mappingHeader to column: { "Email Address": "contact_email" }. A header left out falls back to a column of the same name.
defaultsValues written to every row, checked like a cell. The answer to a required column no header fills.
dryRunValidate and report, write nothing.
onError"abort" (default) imports nothing if any row is bad; "skip" imports the clean rows.
emptyAsNullTreat an empty cell as NULL rather than an empty string.
ignoreUnknownColumnsDrop headers with no matching column instead of failing.
batchSizeRows per insert batch.
maxBytes / maxRowsLower 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.codeWhen
CSV_EMPTY, CSV_NO_ROWS, CSV_NO_HEADERThe file has nothing to import.
CSV_PARSE_FAILEDThe file is not valid CSV (an unclosed quote, a ragged row).
CSV_TOO_LARGE, CSV_TOO_MANY_ROWSOver the size or row ceiling.
CSV_UNKNOWN_COLUMNSA mapped column does not exist (and ignoreUnknownColumns is off).
CSV_NO_MAPPED_COLUMNSNo header lines up with any column.
CSV_UNKNOWN_DEFAULTSA default names a column the table does not have.
CSV_INVALID_DEFAULTA default cannot be stored in its column, which would fail every row.
CSV_MISSING_REQUIRED_COLUMNSA required column has nothing mapped onto it. details.columns names them.
CSV_ROWS_INVALIDRows hold values their columns cannot store, under onError: "abort".
ROW_REFERENCE_NOT_FOUNDA _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.

Next

On this page