Skip to content

Import

The import wizard guides users through uploading one or more files, mapping columns to your schema, and resolving value mismatches. Rows then enter the grid, where users clean and edit them before submission.

Type DataEditorFormat[] | false
Default All formats

Which file formats the user can import. Set to false to disable import entirely.

importFormats={["csv", "xlsx"]}
type DataEditorFormat = "csv" | "tsv" | "xlsx" | "json" | "xml";

Shared with exportFormats. Each value maps to one or more file types:

  • csv: .csv
  • tsv: .tsv
  • xlsx: the spreadsheet token. On import it accepts .xlsx, .xls (legacy Excel), .xlsb, and .ods; on export it writes .xlsx.
  • json: .json
  • xml: .xml
Type RemoteSource[]

Custom data sources rendered as buttons on the upload step. You own the integration: auth, pickers, downloads. The SDK calls your fetch function and processes the result.

Return a File object to go through the standard parse pipeline (CSV/XLSX/etc.), or return structured records to skip parsing entirely.

type RemoteSource = {
id: string;
label: string;
icon: string;
description?: string;
fetch: () => Promise<File | Record<string, unknown>[]>;
};
remoteSources={[
{
id: "google-sheets",
label: "Google Sheets",
icon: "<svg>...</svg>",
fetch: async () => {
const data = await myGoogleSheetsLib.pick();
return data.rows;
},
},
]}
Type CustomImportFormat[]

File formats the SDK cannot read, handled by your own code. The SDK accepts the file, hands it to your handle function, and stages whatever comes back as ordinary preview cards.

type CustomImportTable = {
name: string;
rows: Record<string, unknown>[];
};
type CustomImportFormat = {
label: string;
extensions: string[];
mimeTypes?: string[];
handle: (
file: File,
ctx: { signal: AbortSignal },
) => Promise<File | Record<string, unknown>[] | CustomImportTable[]>;
};
customFormats={[
{
label: "PDF",
extensions: [".pdf"],
handle: async (file, { signal }) => {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/extract", { method: "POST", body, signal });
return res.json();
},
},
]}
  • Record<string, unknown>[] stages one table under the file’s name. The keys become headers in first-seen order across the whole array, and a missing key reads as an empty cell.
  • CustomImportTable[] stages several tables from one file, each under its own name. Every table gets its own card and its own column-matching screen, so an invoice can yield line items and totals separately.
  • File goes through the built-in parser, so a handler can hand back CSV, XLSX, JSON, or XML. The card carries the returned file’s name. A returned file never reaches a handler again, so a .pdf handler that returns a .pdf reads it once.

extensions matches the end of the file name, case-insensitively, and needs the leading dot. [".pdf"] matches Invoice.PDF, and ["pdf"] matches nothing.

mimeTypes matches the browser’s file.type, exactly or by wildcard. ["image/*"] claims every image the OS labels, whatever the extension.

{ label: "Image", extensions: [], mimeTypes: ["image/*"], handle: scanImage }

A format with an empty extensions list matches on MIME alone. A file goes to the first format that claims it, so when two entries overlap the earlier one wins. Files no format claims take the built-in path unchanged.

importFormats: false turns import off completely, custom handlers with it.

Handlers run concurrently and off the built-in parse queue, so the rest of a batch keeps reading while a slow extraction works. That file’s card stays pending, and Next stays disabled until every card is ready.

The user can cancel a pending card with the cross in its corner. Cancelling aborts ctx.signal, and a result that arrives afterwards is discarded. Pass the signal to fetch, or check signal.aborted between steps, so the work itself stops.

Throwing fails the file. The thrown message is shown to the user verbatim, so throw text a user can act on, and wrap a low-level error to keep an internal message off the screen.

handle: async (file, { signal }) => {
try {
return await extract(file, signal);
} catch (cause) {
throw new Error("This scan is too blurry to read. Try a clearer copy.");
}
}

A throw with no message falls back to “Could not process the file”. Every failure also reaches onError as a PARSE_ERROR with source: "custom-handler". An empty array is a separate outcome. The file was read and held no table, and the user is told that no tables were found.

The file travels from the user’s browser to wherever your handler sends it, under your own processing agreement. Nothing about it passes through Updog infrastructure, and Updog stores nothing.

Some files read fine and still hold no single table. A report wraps its data in a banner and a footer, one sheet carries two tables side by side, a roster is laid out as a document. onUnstructuredFile hands that file to your own code, which reads it however it likes and gives back tables.

onUnstructuredFile={async (file, { signal }) => {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/read-tables", { method: "POST", body, signal });
return res.json(); // CustomImportTable[]
}}

You get the File and the abort signal, and you return what a customFormats handler returns, so rows, named tables, or a File in a format Updog already reads. Named tables fit a sheet holding more than one. Each becomes its own card with its own checkbox, and the user drops the ones they do not want.

Updog scores every sheet it reads for how safely it reads as one rectangular table. A sheet that scores badly triggers one call carrying the whole file, whichever sheet it was. The scoring runs only when this prop is set.

The measure is internal and moves as it meets more real files, so build against what the handler receives.

Return null and the file stages the ordinary way, exactly as if the prop were absent. A throw, an empty result, and anything slower than 30 seconds all land there too. The user sees an ordinary card either way and is never told a call happened.

Cancelling the card aborts ctx.signal, and a result arriving afterwards is discarded.

The same place a customFormats file goes. From the user’s browser to wherever your handler sends it, under your own processing agreement. Nothing about it passes through Updog infrastructure, and Updog stores nothing.

Files that reach this handler are the messy ones, and messy spreadsheets hold names, pay rates, and shift patterns. Whoever reads them on your side is reading that, so tell your users where their file goes.

The upload step takes several files at once. The picker allows a multiple selection, and the drop zone accepts a whole batch. Files parse one at a time in drop order, each showing a placeholder card while it is read.

A file that parses becomes a card with its name, its row count, and a checkbox. A multi-sheet workbook fans out to one card per sheet, each labelled with its sheet name and the file it came from. Cards start checked, except a file or sheet with no data rows, which starts unchecked. More files can be added from the card grid at any time, and Start over clears the whole selection.

Only checked cards go on to the rest of the wizard, so unchecking a card before Next is how a user drops a file they picked by mistake.

A file that fails to parse, whether corrupt, unreadable, or too large, never produces a card, and the remaining files in the batch keep parsing. One bad file does not cost the user the rest of the upload. The SDK reports every failure through onError as a PARSE_ERROR, and a file rejected for its size also shows the user a toast.

Updog inspects the first rows of each file to decide which row holds the column headers, looking at where each column’s values switch from text labels to a consistent data type (numbers, dates, booleans). A title or metadata row above the real header is skipped automatically.

When detection is not confident about a file, the wizard adds a header-selection step where the user picks the header row from a preview. With several uploaded files, each uncertain file gets its own screen in upload order, and Next moves to the next one; files whose header was detected confidently are skipped entirely.

On the column-matching step, Change header row reopens header selection for the file being matched, including a file whose header was detected confidently and whose screen was skipped. Choosing a different row matches the columns again, and any column you mapped by hand keeps your choice when its header reads the same on the new row.

When a file contains only data and no detectable header (for example, a raw export whose first row is already values), Updog generates placeholder column names (Column 1, Column 2, …) and treats every row as data, so nothing is dropped. You then map those columns to your schema in the matching step using the previewed values.

Users can upload several files at once. Each file becomes its own unit, and a multi-sheet workbook adds one unit per selected sheet. Column matching repeats per unit, so the same schema column can be fed by a differently named header in each file.

The later steps are shared. Value matching runs once and collects values per schema column across every file. A select column fed by Work in one file and Company in another shows one merged list. The primary key is a single choice applied to every file. Its step appears when there is something to merge into, either rows already in the grid or a second file, and there is a key to offer, either a mapped unique column or a multi-column primaryKey whose every part is mapped. A multi-column key appears as one choice named after its columns. Pick No primary key to append every row instead of merging.

Each unit lands as its own data source. A whole file takes the file name, a sheet takes workbook.xlsx - Sheet name.

Files import one after another. If one of them fails, the wizard stays open with every mapping the user made still in place, so a retry starts from those mappings. The files that already landed keep their rows in the grid and are skipped by the retry, so nothing is imported twice. The retry resumes with the file that failed and the ones queued behind it.

Cancelling closes the wizard and drops whatever had not been imported yet.

Type (headers: string[], columns: DataEditorColumn[]) => Record<string, string | null> | Promise<...>

Override column matching during import. Called when the user first reaches the column-matching step for each file, with that file’s headers and your column definitions. Return a map of { csvHeader: columnId | null }. Entries set to null or omitted fall back to built-in matching. When the callback throws or takes longer than 30 seconds, the SDK uses built-in matching instead. Uploading a different file or changing the header row re-invokes it on the next visit to the step; navigating back and forth between steps does not. With several files, each file gets its own matching screen and its own mapping, so the same header name can map to different columns in different files.

onColumnMatch={async (headers, columns) => {
// Call your AI or matching service
const mappings = await myMatchingService.match(headers, columns);
return mappings;
}}
Type (valuesToMatch: Record<string, ValueMatchInput>) => ValueMatchOutput | Promise<ValueMatchOutput>

Override value matching during import for select columns. Called once, when the user first reaches the value-matching step, with all select columns’ imported values and allowed options. It runs once for the whole upload, however many files there are. The SDK collects a select column’s values across every file, so a column fed by Work in one file and Company in another arrives as one merged list.

When the callback throws or takes longer than 30 seconds, the SDK uses built-in fuzzy matching instead. Your returned matches persist across later column-mapping edits, and navigating back and forth between steps leaves the callback alone. Adding or removing a file re-invokes it on the next visit to the step, and the matches it already returned stay in place if that second call fails. Changing a file’s header row discards the matches the callback returned and falls back to built-in fuzzy matching.

type ValueMatchInput = {
importedValues: string[];
options: string[];
};
// Return: { columnId: { importedValue: optionValue | null } }
type ValueMatchOutput = Record<string, Record<string, string | null>>;

Values set to null skip auto-matching. Unmapped values fall back to built-in fuzzy matching.

onValueMatch={async (valuesToMatch) => {
// valuesToMatch = { country: { importedValues: ["espana", "fr"], options: ["Spain", "France"] } }
return {
country: { espana: "Spain", fr: "France" },
};
}}

In the value-matching step a select value is imported only when it is mapped, and values left unmatched are dropped. By default (enableCustomValue true) the user keeps an off-list value by creating a new option for it, and created options persist on the column. An off-list value becomes an option only when someone creates it. Your validators decide whether the result is valid. Set enableCustomValue: false on the column’s select editor to turn inline option creation off, leaving a strict closed enum that maps only to existing options.

A multiselect column stores a string[], so a raw cell holding several values is split into tokens before matching. The SDK auto-detects the separator among ,, ;, |, newline, and tab by checking which one splits cells into tokens that resemble your options (exact or fuzzy). Each resulting token then flows through value matching like a single select value. The value-matching step shows the detected separator and lets the user override it.

Detection needs the file’s tokens to resemble your options. When the two vocabularies share nothing (file values red, green against option codes R/G/B), set the column’s delimiter explicitly so splitting stops depending on detection. A cell that is itself a whole option containing the delimiter (option "Smith, Jr") is never split.

The SDK also recovers a cell that uses a different separator from the rest of the column, such as a stray ; where the column is comma-separated. A token that is no option on its own, but splits on another candidate separator into pieces that each resemble an option, is split further. Recovery holds a piece to the same standard value matching uses, so a piece written slightly differently from the option it means (Penicillin against the option Penicillins) keeps the split and reaches value matching as its own token. A token whose pieces resemble nothing stays intact, so a value that merely contains a separator survives.

A date column is read by whichever shape explains the most of its values.

Written as Example
ISO 2026-05-01, 2026-5-1
Year first with a slash or a dot 2026/05/01, 2026.05.01
Day or month first with a slash, a dot, or a dash 01/05/2026, 01.05.2026, 01-05-2026
Two digit year 01/05/26, 1/5/26
Digits only 20260501
Month as a word 1 May 2026, May 1, 2026, 01-May-2026, May-01-2026
Month as a word in the editor locale 1 мая 2026
Japanese 2026年5月1日
An Excel serial number 46143

A time after the date is dropped, so 2026-05-01T10:30:00Z and 2026-05-01 10:30:00 both store the day. A two digit year from 00 to 68 reads as the 2000s and from 69 to 99 as the 1900s. A serial number is taken only when every value of the column is one, because a lone number is far more often an ID.

A number column is read the same way, by whichever pair of separators explains the most of its values.

Written as Example
Point decimal, comma grouping 1,234.56
Comma decimal, point grouping 1.234,56
Space grouping 1 234,56, 1 234.56
Apostrophe grouping 1'234.56, 1'234,56
Lakh grouping 12,34,567.89
No grouping 1234.56, 1234,56

A currency symbol, a percent sign, and accounting parentheses around the digits are dropped, and a percent keeps its magnitude, so 45% stores 45. A letter coded currency such as USD or is kept, because letters are ambiguous and stripping them would turn an ID into a number.

A column whose values never separate two shapes falls to the browser locale. When the tied shapes would read the column apart, the SDK reports it through onError so you can see which one it applied. A column of plain integers ties every pair of separators and every one of them gives the same number back, so nothing is reported.

The SDK reads a time column the same way.

Written as Example
Hours and minutes 14:30, 9:05
With seconds 14:30:05, 9:05:07
With a fraction of a second 14:30:05.250, 14:30:05,250
On a 12-hour clock 2:30 PM, 2:30 p.m., 2.30pm, 2:30:05 PM

The SDK drops a date in front of the time and an offset behind it, so 2026-05-01T14:30Z and 14:30+02:00 both store 14:30. A time of day never moves into another zone, so what stays is the time on the wall. Nothing reads a lettered zone name such as 2:30 PM PST, and nothing reads 25:00 or 09:60. The SDK stores 24:00 as the end of the day.

Type { columns?: Record<string, string[]>; values?: Record<string, string[]> }

Extra synonyms layered on top of the built-ins, in two tables that stay apart. columns feeds column matching (header against column), values feeds value matching (imported value against select option). Each key is the canonical target, either a column ID or title, or a select option value. The array lists aliases the matching engine should treat as equivalent. The SDK merges your entries with the built-ins as a union per key, so your list extends the built-in one.

synonyms={{
columns: {
// incoming header → column
productSku: ["sku", "article_no", "item_code"],
firstName: ["first", "given_name", "fname"],
},
values: {
// incoming cell value → select option
Active: ["live", "enabled"],
},
}}

Either half may be omitted. Keeping the two apart stops an alias learned inside a dropdown from competing for a column, and vice versa.

Built-in value synonyms already cover common categorical vocabularies (gender M/F, seniority Jr/Sr, employment type FT/PT, status, marital status, priority, yes/no), so abbreviations like M, Jr, or FT auto-map without any configuration.

Updog stores nothing on a server. To make repeat imports auto-match the headers and values your users already mapped once, persist what they mapped and feed it back through synonyms. Every onComplete result carries learnedSynonyms: { source, target } mappings the user changed by hand, split into columns and values.

Record which list a row came from — the two sides go back in as two tables.

<DataEditor
synonyms={storedSynonyms}
onComplete={async (result) => {
const { columns, values } = result.learnedSynonyms;
await saveSynonyms([
...columns.map((pair) => ({ ...pair, side: "columns" })),
...values.map((pair) => ({ ...pair, side: "values" })),
]);
await saveRows(result.sources);
}}
/>

The synonyms prop groups aliases under each canonical, so on the next mount turn your stored rows into that shape:

const synonyms = { columns: {}, values: {} };
for (const { side, source, target } of storedRows) {
(synonyms[side][target] ??= []).push(source);
}

A pair you have already fed back stops appearing in learnedSynonyms, because the matcher now produces it on its own. Each row reaches you once.

Type boolean
Default true

Allow creating new columns for unmatched headers during import. When enabled, a user keeps the data from a file column that matches nothing in your schema by creating a column for it during the import.