Data ops
Your spreadsheet, in a real database
A CSV becomes typed SQL columns with real constraints — and the rows that don't fit are caught and named before they land, instead of becoming nulls you find in six months.
Most data starts in a spreadsheet. The usual way out of one is an import wizard that accepts everything you give it, turns the awkward rows into empty strings, and lets you discover six months later that 4% of your email addresses are the word unknown.
The interesting part of importing a CSV isn't reading the file. It's what happens to the rows that don't fit.
What you get
Pick a .csv, .tsv or .txt file. It's scanned for its columns and row count, you map each one to a field, and the result is real SQL columns in your own database — typed, constrained, and usable by anything else that connects to it. Not a proprietary table in someone's cloud that you reach through an export button.
Mappings are saved per view, so the second file with the same shape needs no thought. You can say whether the first line is headers, and how to align columns that don't line up by name.
The part that matters
Every row is validated before anything is written — the same rules a typed-in cell gets, not a lesser set: required fields, maximum lengths, formats like email, and cross-field constraints that depend on other columns in the same row.
A row that fails is reported with its own row number and the reason. It's skipped. The rest of the file imports.
That sounds obvious. It wasn't true here until recently, and the way it was wrong is worth describing, because it's the failure mode this whole feature exists to avoid. Rows were written 500 at a time in a single statement, with no validation in front of them. So one bad row failed the entire batch of 500 — and because the database objected to the statement rather than to a row, the error blamed the first row of the batch. You'd be told row 1 was invalid when row 412 was the problem, and 499 good rows wouldn't be imported.
Both halves are fixed: rows are checked individually first, and the batch that reaches the database contains only rows already known to be valid. When the database rejects a batch anyway — something no validation layer could have predicted — the error says so plainly, rather than picking a row to blame.
Streaming, honestly
The file is parsed in chunks, and the parser is paused while each batch is in flight. That's backpressure: a large file doesn't get read faster than it can be written, and the whole file is never held in memory at once.
Which is why the size limit here is not a memory limit. It's a limit on how long you should be asked to sit in a dialog watching a progress bar.
What it doesn't do (yet)
- 100 MB per file, enforced when you choose the file. Worth admitting how this section came about: the limit was documented and not enforced anywhere, so I added the check before publishing this post rather than write a sentence that wasn't true. Split larger files and import them in sequence.
- Type inference suggests, it doesn't decide. Column types come from the mapping you confirm, not from sniffing the data. That's deliberate — a column of
1,2,3that becomes an integer when it was meant to be a product code is worse than being asked. - Upsert needs a unique index, and only the API offers it. Import can now match on a key: name the columns that identify a row and choose whether an existing one is kept or overwritten, so a corrected file is a re-import rather than a cleanup. Two limits come with it. The match columns must be covered by a unique index in your database — that is the only thing that lets a database recognise a duplicate at all, and without one the import is refused rather than silently duplicating. And it's currently reachable only through the API: the import dialog has no match-column picker yet, so a file imported through the app still only inserts.
- On MySQL the match is broader than the columns you name. PostgreSQL matches on exactly those columns. MySQL has no equivalent and reconciles on any unique key on the table, so a row colliding on a different unique column is updated too.
- No preview of what an upsert would change. You find out how many rows were updated versus inserted from the counts afterwards, and skipped rows are counted rather than listed, so you can't see which ones they were.
- No dry run. You find out which rows fail by importing, then reading the rejected list. The rows that pass are already in.
- Rejected rows aren't downloadable as a file. They're listed with their row numbers and reasons; you fix the CSV yourself.
- Errors are per row, not per field-and-row. A row with three problems reports its problems together rather than as a grid you can navigate.
- Progress reporting during a long import is a spinner and a count, not an estimate.
The CSV import guide covers the mapping dialog in detail. And once the data is in, it has a REST API whether you asked for one or not.
Verified 27 Aug 2026: 13 tests across the bulk-insert and import-mapping suites, including the per-row validation cases added when this behaviour was fixed; the 100 MB limit was enforced in code before publishing because it had only ever been documented. Re-verified on 2026-08-27 when matching on a key arrived — WorkspaceDatabaseServiceUpsertTest (8 cases pinning the generated SQL for both PostgreSQL and MySQL, including that the match columns are never rewritten to themselves and that an all-key row becomes DO NOTHING rather than a no-op update) plus two BulkInsertResourceTest cases asserting that asking to match without naming the columns is refused rather than quietly falling back to a plain insert.