
How to Import Contacts from CSV into Your SaaS
A staff export carries a staff number. An invoice carries an invoice number. A product export carries a SKU. The contact list below carries a name, an address, a phone number, and no id column.
Which column stands in for that id is your decision. Every merge in every upload after the first one follows from it, and so does every person a merge takes off the list.
The file that arrives
Saltmere Safety Training left a mailing tool and a booking spreadsheet, and both exports went into one file. 186 rows, eight headers. The first eight rows carry most of the problem, and rows 112 and 113 carry the rest.
| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| 1 | First name | Surname | Organisation | Work Email | Mobile | Account manager | Interests | |
| 2 | Nadia | Brandt | Heronwood Care | [email protected] | +44 7700 900418 | [email protected] | Manual Handling, Fire Safety | |
| 3 | Tomas | Vella | Vellamore Ltd | [email protected] | [email protected] | 07700 900731 | [email protected] | Fire Safety;First Aid |
| 4 | Ines | Cardoso | Vellamore Ltd | [email protected] | 07700 900731 | [email protected] | First Aid | |
| 5 | Amrit | Sandhu | Kelsby Works | [email protected] | 00447700900265 | [email protected] | Working at Height | |
| 6 | Amrit | Sandhu | Kelsby Works | [email protected] | +44 7700 900318 | [email protected] | Asbestos Awareness | |
| 7 | Rowan | Ferrand | Ashlyn Group | [email protected] | +447700900904 | [email protected] | Fire safety, manual handling | |
| 8 | Kofi Adjetey | Marlowe Heath Estates | [email protected] | (0044) 7700 900612 | [email protected] | MH; FS | ||
| 9 | Sofia | Nkemelu | Kelsby Works | 07700 900947 | [email protected] | First Aid | ||
| 102 rows not shown | ||||||||
| 112 | Clemency | Nettleship | Ovingham Print | [email protected] | 07700 900355 | [email protected] | Fire Safety | |
| 113 | Casimir | Nettleship | Ovingham Print | [email protected] | +44 7700 900372 | [email protected] | Manual Handling, First Aid | |
| 73 rows not shown | ||||||||
| 187 | Yusuf | Adeyemi | Kelsby Works | [email protected] | 07700 900788 | [email protected] | Working at Height | |
1First name,Surname,Organisation,E-mail,Work Email,Mobile,Account manager,Interests2Nadia,Brandt,Heronwood Care,[email protected],,+44 7700 900418,[email protected],"Manual Handling, Fire Safety"3Tomas,Vella,Vellamore Ltd,[email protected],[email protected],07700 900731,[email protected],Fire Safety;First Aid4Ines,Cardoso,Vellamore Ltd,[email protected],,07700 900731,[email protected],First Aid5Amrit,Sandhu,Kelsby Works,[email protected],,00447700900265,[email protected],Working at Height6Amrit,Sandhu,Kelsby Works,[email protected],,+44 7700 900318,[email protected],Asbestos Awareness7Rowan,Ferrand,Ashlyn Group,[email protected],,+447700900904,[email protected],"Fire safety, manual handling"8Kofi Adjetey,,Marlowe Heath Estates,[email protected],,(0044) 7700 900612,[email protected],MH; FS9Sofia,Nkemelu,Kelsby Works,,,07700 900947,[email protected],First Aid⋮102 rows not shown112Clemency,Nettleship,Ovingham Print,[email protected],,07700 900355,[email protected],Fire Safety113Casimir,Nettleship,Ovingham Print,[email protected],,+44 7700 900372,[email protected],"Manual Handling, First Aid"⋮73 rows not shown187Yusuf,Adeyemi,Kelsby Works,[email protected],,07700 900788,[email protected],Working at HeightTomas Vella and Ines Cardoso sit behind one mailbox. Two people called Amrit Sandhu work at Kelsby Works. Sofia Nkemelu has no address. Every number in the file sits inside 07700 900000 to 900999, the mobile range Ofcom reserves for drama, so none of them reaches anybody.
The schema it becomes
Seven fields, and two of them carry a transformer that runs before the value enters the editor.
import type { DataEditorColumn } from "@updog/data-editor";
const COURSES = [ "Manual Handling", "Fire Safety", "First Aid", "Working at Height", "Asbestos Awareness",];
const UK_MOBILE = /^(?:\+?44|0044|0)(\d{10})$/;
const toE164 = (value: unknown): string => { const digits = String(value).replace(/[^\d+]/g, ""); const match = UK_MOBILE.exec(digits); return match ? "+44" + match[1] : String(value).trim();};
export const columns: DataEditorColumn[] = [ { id: "firstName", title: "First name" }, { id: "lastName", title: "Last name", validators: [{ type: "required" }], }, { id: "email", title: "Email", transformer: (value) => String(value).trim().toLowerCase(), validators: [{ type: "email" }, { type: "unique" }], }, { id: "phone", title: "Phone", transformer: toE164, validators: [ { type: "regex", pattern: "^\\+[1-9]\\d{6,14}$", message: "Write the number as +447700900418", }, ], }, { id: "company", title: "Company", validators: [{ type: "required" }], }, { id: "owner", title: "Account manager" }, { id: "interests", title: "Interests", editor: { type: "multiselect", options: COURSES }, validators: [{ type: "oneOf", values: COURSES }], },];toE164 rewrites a UK mobile in any of the five shapes the file uses. The
email transformer trims the address and lowercases it. Both run on every value
arriving from outside the editor, so a file import, loadData, a remote source
and a paste of text from another app all call them, and the value the key
compares is the value the transformer produced. A value moving inside the grid
is left alone, so a manual edit, a fill, an undo and a redo never call them.
The value reaches the transformer in the shape the cell holds. A number and a
date reach it canonical, a select value reaches it as the option the person
confirmed, and a multiselect column hands it one token at a time.
Where the file and the schema disagree
Eight values across the 186 rows each land somewhere different.
| Value in the file | What it hits |
|---|---|
Work Email beside E-mail |
two headers, one field, and one of them strands |
[email protected] |
one mailbox on two people |
[email protected] |
a stored address that differs only in case |
(0044) 7700 900612 |
the fifth written form in one column |
Fire Safety;First Aid |
a second delimiter inside one cell |
MH; FS |
initials that reach no option |
Kofi Adjetey in First name |
a surname that was never split out |
an empty E-mail |
a row that can build no key |
The generic form of each one sits in common CSV import errors.
The second email header
E-mail and Work Email both mean the address field. Updog Importer pins exact
matches first, then solves the rest as one assignment where each field takes one
header. E-mail normalizes to email, so the exact pass takes the field and
reserves it. Work Email is then scored against the fields still free, and
reaches none of them.
First name → firstName exactSurname → lastName synonym, 90Organisation → company synonym, 90E-mail → email exact, pinnedWork Email → nothingMobile → phone synonym, 90Account manager → owner exact against the titleInterests → interests exactOne header per field is the rule that stops a second address column from
stealing the first one. Work Email still has three places to go. The person
points it at a field by hand, or takes the Create column option the dropdown
offers, or you add a workEmail field to the schema and the exact pass lands it
there on the next upload. The algorithm behind that screen is in
how to build a CSV column mapping UI.
The number written five ways
The file writes UK mobiles in five shapes. +44 7700 900418 on one row,
07700 900731 on the next, then 00447700900265, +447700900904 and
(0044) 7700 900612. The mailing tool and the booking sheet each wrote numbers
their own way, so a key on this column compares habits before it compares people.
E.164 is the numbering plan
that settles the shape. An international number carries fifteen digits at most,
and the first one, two or three of them are the country code. The prefix a caller
dials to leave their own country sits outside that count. One canonical form per
subscriber is what toE164 produces.
+44 7700 900418 → +44770090041807700 900731 → +44770090073100447700900265 → +447700900265+447700900904 → +447700900904(0044) 7700 900612 → +447700900612Nadia Brandt sits in your database on +447700900418, and her row in the file
writes the same subscriber as +44 7700 900418. Key on the phone column and the
transformer makes those one string, so her row merges.
That regular expression reads UK numbers and nothing else, mobile or not. A file
carrying numbers from more than one country needs a phone number library. The
transformer is where that library normalizes a number, and a
{ type: "function", fn } validator is where it judges one, so both halves stay
in code you own.
phone holds text, so those five written forms are yours to settle. A date
column and a number column reach the store in one shape already. Updog
Importer scores every shape it knows against the values in the column, and the
shape that explains the most of them takes the whole column. Twenty three
writings of one day land on 2026-05-01, among them 01/05/2026, 1 May 2026,
2026年5月1日 and the Excel serial 46143, with month names read in the editor's
own locale. Nine separator pairs do the same work for numbers, so 1 234,56,
1'234.56 and 12,34,567.89 all land as digits around one point. 1,234 reads
as 1234 in a column that carries 1,234.56 and as 1.234 in a column that
carries 1.234,56, and the column's own values are what decide. A value the
winning shape cannot explain stays text for validation to flag, and a column two
shapes explain equally well reaches you through onError.
The courses in one cell
Interests holds several courses per person, and the two exporters disagreed on
the separator. Updog Importer samples the column, scores , ; |, newline and
tab by how many cells each one splits into recognizable options, and gives the
whole column one verdict. The column holds 83 distinct values, and over those the
comma splits 46 where the semicolon splits 31, so the comma wins.
A cell written with the other separator survives that verdict. When a token still
holds a candidate delimiter, the splitter tries it, and keeps the result only
when it recognizes every piece that comes out. Each token it hands over then goes
through the match-values screen, and that is where Fire safety picks up the
spelling of the option it matched.
Manual Handling, Fire Safety → ["Manual Handling", "Fire Safety"]Fire Safety;First Aid → ["Fire Safety", "First Aid"]Fire safety, manual handling → ["Fire Safety", "Manual Handling"]MH; FS → []Neither MH nor FS resembles a course name, so the splitter keeps the cell
whole, and that one token reaches no option. The row lands with an empty
interests array and passes every rule on it. It reaches your API as a contact
booked on no courses, and the result carries nothing to say the cell held text.
Choosing the key
This file can build a key three ways, out of the address, out of the number, or out of the surname taken together with the company. Each one breaks on a different row of it.
Say your database already holds four of these people.
Nadia Brandt [email protected] +447700900418 Heronwood CareRowan Ferrand [email protected] +447700900904 Ashlyn GroupTomas Vella [email protected] +447700900731 Vellamore LtdAmrit Sandhu [email protected] +447700900265 Kelsby Works| The collision | email |
phone |
lastName + company |
|---|---|---|---|
Two people behind [email protected] |
merges two people into one row | merges, one handset between them | keeps both |
| One person whose address changed | adds a second row | merges | merges |
| Two people called Amrit Sandhu at Kelsby Works | keeps both | keeps both | merges two people into one row |
| Sofia Nkemelu, with no address | builds no key, so she is new on every upload | keys normally | keys normally |
| Kofi Adjetey, with the surname blank | keys normally | keys normally | builds no key, so he is new on every upload |
Every merge in that table happens against a stored row, because the match only ever runs against rows that came from somewhere else. Two rows inside one upload never merge with each other.
Run the eight rows above against those four stored contacts and the counts follow the table. Keyed on the address, four of them arrive as new. Keyed on the number, three. Keyed on surname and company together, three again, and a different three, because that key tells the two behind the mailbox apart and merges the two who share a name.
Load the whole file against those four contacts and the submit dialog reads
182 new rows will be created and 3 rows will be updated. Four rows of the
file matched a stored contact, and three stored rows changed. Both Vellamore rows
carry [email protected], both matched the same stored row, and the later
one won. That row held Tomas Vella and now reads Ines Cardoso. Tomas is gone
from a list of 186 people, and the unique check has nothing left to flag,
because after the merge that address sits on one row.
What the transformer decides
Values are compared after trimming, and the comparison keeps case. Take the
transformer off the email column and [email protected] in the file and
[email protected] in your database become two different keys, so that
contact arrives as a duplicate.
RFC 5321 is the reason
nobody folds it for you. The local-part of a mailbox "MUST BE treated as case
sensitive", and only the domain follows DNS rules. Folding the whole address is a
decision about your product.
Drop both transformers and the same run sends the values exactly as the file wrote them.
normalized as writtenprimaryKey: email 4 new rows 5 new rowsprimaryKey: phone 3 new rows 7 new rowsThe address key loses one merge to a capital letter. The phone key loses four,
because 07700 900731 and +447700900731 are the same subscriber written two
ways. The composite key moves by nothing, since no transformer touches a surname.
The column you key on and the normalization you put in front of it are one
decision.
The rows the person fixes
The grid marks three cells across the 186 rows.
Clemency Nettleship and Casimir Nettleship both work at Ovingham Print, and the tool that wrote this file built every address out of one initial and one surname. Two people at one company can share those, so the scheme itself manufactures a duplicate. Neither row merged into anything, so both are on the screen for somebody to fix.
[email protected] draws no error at all. That address matched a stored
row, the two file rows collapsed into it, and unique sees one value. The
duplicate the person could have fixed is the one the merge already swallowed.
Sofia Nkemelu draws nothing either. Every built-in rule except required passes
an empty cell, so her blank address is neither an invalid email nor a duplicate.
Put required on the address when a contact without one has no place in your
product, and leave it off when the phone-only contacts are real customers.
What Updog Importer does not ship
No phone number parsing. E.164 lives in your transformer, and so does every other country's numbering plan. No case folding on the key or on the uniqueness check. No survivorship rule, so when an imported row matches a stored one, the imported values win the whole row. Nothing that looks at two rows and calls them the same human on a resemblance.
Updog Importer reads CSV, TSV, JSON, XML, XLSX, XLS, XLSB and ODS. A contact list that arrives as a PDF or a photograph of a business card goes to a parser you supply, and the rows it returns walk the same path.
Reaching your backend
loadData hands the editor the contacts you already store, so the imported rows
have something to merge into. onComplete hands back one entry per source, each
row carrying isNew, isChanged, isDeleted and isValid.
import { DataEditor } from "@updog/data-editor";import { columns } from "./columns";
type Contact = { firstName: string; lastName: string; email: string; phone: string; company: string; owner: string; interests: string[];};
type Props = { open: boolean; onClose: () => void };
export function ContactImport({ open, onClose }: Props) { return ( <DataEditor<Contact> apiKey={import.meta.env.VITE_UPDOG_KEY} open={open} onClose={onClose} variant="uploader" columns={columns} primaryKey="email" loadData={async (onChunk) => { const response = await fetch("/api/contacts"); onChunk(await response.json()); }} onComplete={async (result) => { for (const source of result.sources) { const inserts = source.rows.filter( (r) => r.isNew && !r.isDeleted && r.isValid, ); const updates = source.rows.filter( (r) => !r.isNew && r.isChanged && !r.isDeleted && r.isValid, ); const response = await fetch("/api/contacts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ inserts, updates }), }); if (!response.ok) throw new Error(await response.text()); } }} /> );}A stored row nobody touched is left out of the result, so the second upload of this list reaches your API as the handful that moved. Throw when your endpoint fails. A handler that swallows its own error reads as success, and the editor clears with the rows unsaved.
What you built
Seven fields, two transformers, six validators and one key. The key is the short line, and it decided that Tomas Vella and Ines Cardoso are one person or two, that the two Amrit Sandhus at Kelsby Works are one person or two, and that Sofia Nkemelu arrives new every time somebody uploads this file. Pick it against the collisions your own contact lists carry, then normalize the column you picked, because that is the half that moves the count.