Back to all postsA paper arrow with a yellow tail joined to a teal head along a curved seam

How to Transform Rows During a CSV Import

Column matching assumes that each file column maps to one field in your schema. That works when the file and the schema split the data at the same boundaries.

A Full name column breaks that assumption. If your schema stores firstName and lastName separately, matching alone cannot produce both values. Your code has to transform the cell before either field receives the right value.

For the example we will use a record label's app that stores a royalty line in six fields. The statement reference, the artist's first and last name, the track, the currency and the amount. The statement a distributor sends carries the same data in four columns.

royalties.csv
ABCD
1RefArtistTrackPayment
2RS-2041Anna van der BergSlow Tide$1,250.00
3RS-2042Ines DuarteHarrow Lane$980.40
4RS-2043Petr HavlikNightjar$1,004.50
1Ref,Artist,Track,Payment2RS-2041,Anna van der Berg,Slow Tide,"$1,250.00"3RS-2042,Ines Duarte,Harrow Lane,$980.404RS-2043,Petr Havlik,Nightjar,"$1,004.50"

Artist contains both name fields. Payment contains both currency and amount. The file packs four schema fields into two columns, so a one-to-one mapping is not enough.

Two columns hold four fields

Column matching connects Ref to the statement reference and Track to the track automatically. Artist and Payment have no one-to-one match, so the person importing the file maps them to the closest fields on offer, First name for Artist and Amount for Payment.

The import still finishes with zero validation errors. The number reader removes the dollar sign and the grouping comma from $1,250.00, and 1250.00 is a valid amount. Anna van der Berg is a valid string for firstName. The schema validates both values without knowing that either cell held more than one field.

statementRef RS-2041
firstName Anna van der Berg
lastName (empty)
track Slow Tide
currency (empty)
amount 1250.00

The result is structurally valid but incomplete. The currency is gone, and the whole artist name sits in firstName.

Mapping cannot fix this, because no choice of target field splits a cell. The value itself has to be split before it reaches the fields, and the import has one place built for that code.

The callback runs after reading, before the rows land

onRowImport is a prop on the editor. The SDK calls it after parsing, column mapping, value matching, and each column's transformer, but before the resulting rows reach the editor.

file → parse → column mapping → value matching → transformer → onRowImport → column reader → editor

For each imported row, the callback receives two representations. row contains the values already mapped to your schema, such as Slow Tide under track. raw contains the original cells keyed by the file headers, unmapped columns included.

row.track → "Slow Tide"
raw["Track"] → "Slow Tide"
raw["Payment"] → "$1,250.00"

Both are inputs to the callback. Mutating them does not change the import. row is a copy, and the import never reads raw again. To change the imported row, return the fields that should be replaced.

The royalty schema has six fields. currency and amount also declare their editor types.

import type { DataEditorColumn } from "@updog/data-editor";
export const columns: DataEditorColumn[] = [
{ id: "statementRef", title: "Statement" },
{ id: "firstName", title: "First name" },
{ id: "lastName", title: "Last name" },
{ id: "track", title: "Track" },
{ id: "currency", title: "Currency", editor: { type: "currency" } },
{ id: "amount", title: "Amount", editor: { type: "number" } },
];

Keep Payment mapped to Amount. The importer reads that column with the number format it detected across the file, so row.amount already holds 1250.00 when the callback runs. The callback then supplies what the mapping could not express, the two name fields and the currency.

onRowImport={(rows) =>
rows.map(({ raw }) => {
const [firstName, ...surname] = raw["Artist"].split(" ");
return {
firstName,
lastName: surname.join(" "),
currency: raw["Payment"].replace(/[\d\s.,'-]/g, ""),
};
})
}

The currency line throws the number away and keeps what is left. It deletes the digits, the spaces, the separators and the minus sign, so $1,250.00 leaves $ and EUR 1.250,00 leaves EUR. The amount survives that line untouched, because the mapping already read it.

Each returned key replaces that field in the imported row. Fields the callback does not return keep the values the import pipeline already produced, so statementRef, track and the mapped amount need no special handling.

The callback may also return a promise. The SDK waits for it before adding the rows to the editor, with a 30-second timeout per call.

After the callback, the first row carries all six fields.

statementRef RS-2041
firstName Anna
lastName van der Berg
track Slow Tide
currency USD
amount 1250.00

The callback split Artist across the two name fields and took the currency symbol out of Payment, while the mapping supplied the amount. The callback returned $, and the row stores USD. The diagram's column reader step made that conversion, and it reads every returned value the same way.

Each returned value goes through the column's reader

Values returned from onRowImport enter the column pipeline again. The column reader first converts them to the type the cell expects, then the column's transformer runs on the result.

Return the value in a form the reader understands. A currency column resolves recognised forms such as $, usd, and US Dollar to USD. A number column accepts a JavaScript number or canonical numeric text, so both 1250.5 and "1250.00" are valid. A date column accepts an ISO date string such as 2026-08-19. A multiselect accepts an array of values.

If the reader cannot convert a value, it leaves that value in the cell for validation to flag.

We measured that boundary on Updog Importer 0.1.90 with the royalty statement used in this example.

returned stored validation
"1,250.00" 1,250.00 Invalid number
"1250.00" 1250.00 —
1250.5 1250.5 —
"$" / "usd" / "US Dollar" USD —
new Date(…) 2026-08-19T00:00:00.000Z Invalid date

That boundary is why the callback leaves the amount to the mapping. The file's $1,250.00 reads clean because the importer detects the column's number format across the whole file before reading its cells. One value alone carries no such evidence, since 1,250 is twelve hundred and fifty under one convention and one and a quarter under another. A returned value arrives without that verdict, so the number reader expects canonical numeric text or a JavaScript number.

Map the column when the importer can read it, and return a value only where mapping cannot express what the field needs. A callback that rebuilds the amount by hand takes the file's convention on faith, and replaceAll(",", "") turns a statement writing 1.250,00 into 1.25000, which the reader takes as 1.25 and validation accepts. That is the opening mapping's silent loss again, one thousandth of the payment, with nothing on screen to catch it.

An empty string clears its field. null in place of a row object removes the row. An object past the end of the input chunk appends a new row, and its values pass through the same reader.

The reader resolves representation. It cannot decide where Anna van der Berg splits between firstName and lastName. That decision belongs in onRowImport.

Splitting the name is the callback's decision

Anna van der Berg splits at the first space into Anna and van der Berg. That rule lives in the callback. The column reader and the schema have no opinion about where a name divides.

It is a heuristic. A Dutch particle may belong to the surname, a Spanish name may carry two family names, and a multi-word given name breaks the same rule from the other side.

Because onRowImport runs before the rows enter the editor, the result remains reviewable. The imported row opens with Anna under First name and van der Berg under Last name. If the heuristic gets a name wrong, the person reviewing the statement can correct the cells before the data leaves the editor.

The callback makes the initial split. The editor is where that decision can be checked and corrected.

One call per 5,000 rows

The SDK processes imported rows in chunks of 5,000. It calls onRowImport once per chunk, sequentially and in file order. The final chunk contains whatever rows remain.

The callback's second argument, meta, describes the current chunk and the import around it. chunkIndex, chunkCount and isLastChunk locate the chunk within the file. workbook contains the file name and its headers. mapping contains the resolved header-to-field mapping. context is the object passed through the editor's context prop.

meta.signal is an AbortSignal. It aborts when the import is cancelled and when the callback exceeds its 30-second limit. A callback that starts its own request passes the same signal to fetch, so that work stops with the import.

await fetch(url, {
signal: meta.signal,
});

A cancelled import then ends quietly, because the person stopped it. A callback that runs out of time fails instead.

A callback failure stops further processing. Throwing, rejecting, timing out, or returning an invalid value produces one HOOK_ERROR through onError. Chunks that already reached the editor remain there. The unprocessed part of the file is restored, and the wizard stays open so the import can be retried.

Return nothing and the chunk stays unchanged. Return an array at least as long as the input and each element answers one row, a row object to keep the row or null to drop it. Elements past the input length append new rows.

Anything outside that contract fails the chunk. The error identifies the invalid result where it can, element 0 is undefined included.

Nothing else runs the callback

onRowImport runs only for rows that come through the file import flow. Rows supplied through loadData, pasted into the grid, or edited directly in a cell bypass it.

The callback changes values alone. A returned key that matches no schema field is ignored, and no returned value attaches an error to a cell. Once the reader has taken those values, the column validators evaluate them the same way they evaluate values that came straight from the file.

Not every import needs onRowImport. If the file's columns already match the schema boundaries, column matching is enough. Value matching resolves option values, and the currency reader normalizes a currency symbol that arrives in its own column.

Use the callback when the file's row and your schema disagree about structure. One cell may hold values that belong in several fields, or several cells may need combining before the row reaches the editor.

For this royalty statement, the importer reads the file, the mapping supplies the amount, onRowImport splits Artist and takes the currency out of Payment, and the row opens in the grid for review before your app receives it.

If another distributor sends First name and Last name as separate columns, mapping covers the artist directly. The callback then keeps to the currency.