Import: a spreadsheet into the grid
The other direction: a file someone was handed, into the grid, with the columns lined up, the numbers as numbers and the bad rows flagged before anything lands. This page does it a step at a time on inline text first, so every example runs without a file picker, and then with a real file through the dialog. Data import is the reference behind it.
Import is @svgrid/enterprise: installEnterprise(api) puts
importData on the grid's api, and SvImportDialog is the finished UI
around it. .xlsx is read by the package's own values-only reader,
which needs the jszip peer; nothing leaves the browser.
The examples share an empty grid of orders and the text a customer's export usually looks like.
<script lang="ts">
import { SvGrid, tableFeatures, rowSortingFeature, type GridColumns } from '@svgrid/grid'
import { installEnterprise, SvImportDialog, type EnterpriseGridApi, type ImportResult } from '@svgrid/enterprise'
type Order = { orderId: number; customer: string; email: string; total: number; shipped: string }
const features = tableFeatures({ rowSortingFeature })
const columns: GridColumns<Order> = [
{ field: 'orderId', header: 'Order ID', width: 100 },
{ field: 'customer', header: 'Customer', width: 170 },
{ field: 'email', header: 'Email', width: 200 },
{ field: 'total', header: 'Total', width: 110, align: 'right', format: { type: 'currency', currency: 'USD' } },
{ field: 'shipped', header: 'Shipped', width: 110, format: { type: 'date' } },
]
// What an export from somebody's CRM looks like: their headers, their
// spelling of a currency, a note column nobody asked for.
const csv = [
'Order #,Customer Name,Customer Email,Total,Ship date,Internal Note',
'1001,Atomic Foods,[email protected],"$1,240.00",2026-09-03,call back',
'1002,Novax Labs,[email protected],$310.50,2026-09-04,',
'1003,Orbital Systems,orbital.example,-$45.00,2026-09-04,refund?',
'1004,Quantum Loom,[email protected],"$2,000.00",2026-09-05,',
].join('\n')
const describe = (r: ImportResult<Order>) => `${r.rows.length} rows from ${r.total}, ${r.errors.length} error${r.errors.length === 1 ? '' : 's'}${r.skipped ? `, ${r.skipped} blank` : ''}`
</script>
The call
api.importData({ file }) reads a File, a Blob or a string of
inline text, sniffs the format (.xlsx, .csv, .tsv or .json) and
returns the parsed rows without writing anything: headers as the file
spelt them, rows mapped and typed, errors, and the counts. Writing
is a second step, api.addRows, so the app decides.
<script lang="ts">
let rows = $state<Order[]>([])
let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)
let note = $state('')
async function run() {
if (!api) return
const result = await api.importData({ file: csv, format: 'csv' })
note = `headers: ${result.headers.join(' | ')} - ${describe(result)}`
api.addRows(result.rows, 'bottom')
}
</script>
<button type="button" onclick={run}>Import the sample</button>
<p style="font-size: 12px">{note}</p>
<SvGrid data={rows} {columns} {features} containerHeight={220} onApiReady={(a) => (api = installEnterprise(a))} />
The rows land, and mostly in the wrong place: nothing told the importer
that Order # is orderId. A header it is not told about falls
through to a default (lowercase, spaces to underscores, punctuation
dropped), so Customer Name became a field called customer_name and
Order # a field called order_, neither of which the grid has a column
for.
Mapping the headers
columnMap is source header to target field: a rename per header, and
null for a column that should not land at all, which is how a note
column or a personal-data column stays out of the client.
<script lang="ts">
let rows = $state<Order[]>([])
let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)
let note = $state('')
async function run() {
if (!api) return
const result = await api.importData({
file: csv, format: 'csv',
columnMap: { 'Order #': 'orderId', 'Customer Name': 'customer', 'Customer Email': 'email', 'Total': 'total', 'Ship date': 'shipped', 'Internal Note': null },
})
note = describe(result)
api.addRows(result.rows, 'bottom')
}
</script>
<button type="button" onclick={run}>Import with the headers mapped</button>
<p style="font-size: 12px">{note}</p>
<SvGrid data={rows} {columns} {features} containerHeight={220} onApiReady={(a) => (api = installEnterprise(a))} />
autoMap: true writes that map for you from the grid's own columns,
matching a header to a column by its label first and its field second,
after dropping case, spaces and punctuation, so Unit Price, unit_price
and UnitPrice all find a column labelled Unit price. It is an exact
match after that, not a guess: Customer Name does not find customer,
which is what columnMap is for. It also reads each
column's format to decide the type a cell should coerce to. Anything
you pass in columnMap still wins over the guess:
await api.importData({ file, autoMap: true, columnMap: { 'Order #': 'orderId' } })
Numbers as numbers
Every cell walks through the coercions a spreadsheet export needs:
$1,240.00 becomes 1240, 1,234,567 a number, true a boolean, an
ISO date stays an ISO string, an empty cell is ''. The Total column
above is already summable; the grid's currency format then draws it.
A cell that will not convert keeps its text, which is what the next
step catches.
A validator flags the bad rows
validator(row, rowIndex) returns { field, message } entries for a
row, and they come back in result.errors with the row index, so a
review screen can point at the cell. The refund line and the address
with no @ in the sample are what a validator is for:
<script lang="ts">
let rows = $state<Order[]>([])
let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)
let errors = $state<ImportResult<Order>['errors']>([])
let note = $state('')
async function run() {
if (!api) return
const result = await api.importData({
file: csv, format: 'csv',
columnMap: { 'Order #': 'orderId', 'Customer Name': 'customer', 'Customer Email': 'email', 'Total': 'total', 'Ship date': 'shipped', 'Internal Note': null },
validator: (row) => {
const out: Array<{ field: string; message: string }> = []
if (row.total < 0) out.push({ field: 'total', message: 'must be 0 or more' })
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.email)) out.push({ field: 'email', message: 'not an email address' })
return out
},
})
errors = result.errors
note = describe(result)
const bad = new Set(result.errors.map((e) => e.rowIndex))
api.addRows(result.rows.filter((_, i) => !bad.has(i)), 'bottom')
}
</script>
<button type="button" onclick={run}>Import the rows that pass</button>
<p style="font-size: 12px">{note}</p>
{#if errors.length}
<ul style="font-size: 12px; margin: 0 0 8px; padding-left: 18px">
{#each errors as e (e.rowIndex + e.field)}<li>row {e.rowIndex + 1}, {e.field}: {e.message}</li>{/each}
</ul>
{/if}
<SvGrid data={rows} {columns} {features} containerHeight={220} onApiReady={(a) => (api = installEnterprise(a))} />
One row is held back with two reasons, the refund whose address has no
@; three land. Whether a row with an error is skipped, fixed or
refused with the whole file is the app's call, which is why the default
is a preview.
Preview, then commit
commit: true appends the rows in the same call, at commitAt
('top', 'bottom' or an index), and refuses to write when there is
any error, from the validator or from a cell that would not coerce, so
a review screen still has to show result.errors:
const r = await api.importData({ file, autoMap: true, validator, commit: true, commitAt: 'top' })
if (r.errors.length) showReview(r) // nothing was written
The dialog
SvImportDialog is the review flow, finished: drop a file or paste,
the headers auto-mapped to the grid's columns with a select per header
to retarget one, a typed preview with the bad cells highlighted, Append
or Replace all, and the commit through the api. A header mapped to
"Import as new field" opens a small column configurator, so a CSV into
an empty grid can define the columns as it maps them. Drop any .csv
or .xlsx on it:
<script lang="ts">
let rows = $state<Order[]>([])
let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)
let note = $state('')
const validator = (row: Order) => (row.total < 0 ? [{ field: 'total', message: 'must be 0 or more' }] : [])
</script>
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px">
<SvImportDialog {api} {validator} onImported={(r) => (note = `imported ${r.rows.length} rows`)} />
<span style="font-size: 12px">{note}</span>
</div>
<SvGrid data={rows} {columns} {features} containerHeight={240} onApiReady={(a) => (api = installEnterprise(a))} />
The file is read once; retargeting a header re-maps in memory, so an
.xlsx is never parsed twice. createColumns={false} turns the
configurator off, allowReplace={false} hides Replace, defaultMode
picks the initial one, and onImported hands the rows to the store or
the database the app keeps beside the grid.
Open the live example: Import - dialog + auto-mapping (Data Export & Import)
Guard-rails for a file you did not make
An upload is untrusted input. maxBytes rejects a file before it is
read, maxRows caps the rows (overLimit: 'truncate' keeps the first N
and flags truncated instead of throwing), maxErrors stops collecting
past N, dedupeBy drops repeats by a field keeping the last, encoding
reads a windows-1252 export, signal cancels a long parse and
onProgress feeds a bar. The dialog defaults to 25 MB and 500 errors.
const controller = new AbortController()
const r = await api.importData({
file, autoMap: true,
maxBytes: 25 * 1024 * 1024, maxRows: 200_000, overLimit: 'truncate', maxErrors: 500,
dedupeBy: 'orderId', signal: controller.signal, onProgress: ({ ratio }) => (progress = ratio),
})
Headers named __proto__, constructor or prototype are dropped,
never assigned; the .xlsx reader resolves no external references and
expands no entities. Past a few hundred thousand rows, the right home
for the parse is a server.
An importer of your own
The dialog is built from three exported pieces, split so the file is parsed once and mapped as often as the user changes a select:
import { readImportMatrix, autoMapColumns, inferImportColumnTypes, mapImportMatrixAsync } from '@svgrid/enterprise'
const { format, matrix } = await readImportMatrix(file, 'auto', { maxBytes })
const columnMap = autoMapColumns(matrix[0], api.getColumns())
const columnTypes = inferImportColumnTypes(api.getColumns())
const { rows, errors } = await mapImportMatrixAsync(matrix, { columnMap, columnTypes, signal, onProgress })
Open the live example: Excel / CSV import (Data Export & Import)
See also
- Data import - the reference: every option and prop, the formats, the result shape, performance.
- Export a report - the way out.
- Cell data types - the column formats auto-mapping reads its types from.
- The spreadsheet's files - opening an
.xlsxinto a sheet document instead of a grid.
Live examples
- Import - dialog + auto-mapping - The round-trip partner of export: SvImportDialog drops in a drag-drop / paste importer that auto-maps a file's headers to your columns, coerces each value with the column's own format, previews the typed rows with bad cells flagged, then appends the clean ones. Reads .xlsx, CSV, TSV, JSON.
- Excel / CSV import - File picker + column mapping + per-row validation preview before commit. Reads xlsx / csv / tsv / json with format auto-detect.
Related articles
- Importing CSV into a Svelte Data Grid - Parse a user-uploaded CSV file into objects, map its headers to grid columns, validate rows before committing, and handle the edge cases that actually bite you in production.
- Paste from Excel into a Svelte Data Grid - How to wire up clipboard paste so users can drop a copied Excel or Google Sheets block directly into SvGrid - TSV parsing, type coercion, validation, and row growth all covered.
- A Fill Handle (Drag to Fill) in SvGrid - Build a working spreadsheet-style fill handle on top of SvGrid's cell selection and editing - pointer tracking, range highlighting, series fill, and undo/redo integration all covered.