Back to all postsA teal felt rotary telephone with a navy handset and cord on a warm cream background

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.

saltmere-contacts.csv
ABCDEFGH
1First nameSurnameOrganisationE-mailWork EmailMobileAccount managerInterests
2NadiaBrandtHeronwood Carenadia.brandt@heronwood.example+44 7700 900418ivy.crane@saltmere.exampleManual Handling, Fire Safety
3TomasVellaVellamore Ltdoffice@vellamore.examplet.vella@vellamore.example07700 900731ivy.crane@saltmere.exampleFire Safety;First Aid
4InesCardosoVellamore Ltdoffice@vellamore.example07700 900731ivy.crane@saltmere.exampleFirst Aid
5AmritSandhuKelsby Worksa.sandhu@kelsbyworks.example00447700900265dev.oyelaran@saltmere.exampleWorking at Height
6AmritSandhuKelsby Worksamrit.sandhu@kelsbyworks.example+44 7700 900318dev.oyelaran@saltmere.exampleAsbestos Awareness
7RowanFerrandAshlyn GroupR.Ferrand@ashlyn.example+447700900904ivy.crane@saltmere.exampleFire safety, manual handling
8Kofi AdjeteyMarlowe Heath Estatesk.adjetey@marloweheath.example(0044) 7700 900612roisin.tuohy@saltmere.exampleMH; FS
9SofiaNkemeluKelsby Works07700 900947dev.oyelaran@saltmere.exampleFirst Aid
102 rows not shown
112ClemencyNettleshipOvingham Printc.nettleship@ovinghamprint.example07700 900355roisin.tuohy@saltmere.exampleFire Safety
113CasimirNettleshipOvingham Printc.nettleship@ovinghamprint.example+44 7700 900372roisin.tuohy@saltmere.exampleManual Handling, First Aid
73 rows not shown
187YusufAdeyemiKelsby Worksy.adeyemi@kelsbyworks.example07700 900788dev.oyelaran@saltmere.exampleWorking at Height
1First name,Surname,Organisation,E-mail,Work Email,Mobile,Account manager,Interests2Nadia,Brandt,Heronwood Care,nadia.brandt@heronwood.example,,+44 7700 900418,ivy.crane@saltmere.example,"Manual Handling, Fire Safety"3Tomas,Vella,Vellamore Ltd,office@vellamore.example,t.vella@vellamore.example,07700 900731,ivy.crane@saltmere.example,Fire Safety;First Aid4Ines,Cardoso,Vellamore Ltd,office@vellamore.example,,07700 900731,ivy.crane@saltmere.example,First Aid5Amrit,Sandhu,Kelsby Works,a.sandhu@kelsbyworks.example,,00447700900265,dev.oyelaran@saltmere.example,Working at Height6Amrit,Sandhu,Kelsby Works,amrit.sandhu@kelsbyworks.example,,+44 7700 900318,dev.oyelaran@saltmere.example,Asbestos Awareness7Rowan,Ferrand,Ashlyn Group,R.Ferrand@ashlyn.example,,+447700900904,ivy.crane@saltmere.example,"Fire safety, manual handling"8Kofi Adjetey,,Marlowe Heath Estates,k.adjetey@marloweheath.example,,(0044) 7700 900612,roisin.tuohy@saltmere.example,MH; FS9Sofia,Nkemelu,Kelsby Works,,,07700 900947,dev.oyelaran@saltmere.example,First Aid102 rows not shown112Clemency,Nettleship,Ovingham Print,c.nettleship@ovinghamprint.example,,07700 900355,roisin.tuohy@saltmere.example,Fire Safety113Casimir,Nettleship,Ovingham Print,c.nettleship@ovinghamprint.example,,+44 7700 900372,roisin.tuohy@saltmere.example,"Manual Handling, First Aid"73 rows not shown187Yusuf,Adeyemi,Kelsby Works,y.adeyemi@kelsbyworks.example,,07700 900788,dev.oyelaran@saltmere.example,Working at Height

Tomas 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
office@vellamore.example one mailbox on two people
R.Ferrand@ashlyn.example 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 exact
Surname → lastName synonym, 90
Organisation → company synonym, 90
E-mail → email exact, pinned
Work Email → nothing
Mobile → phone synonym, 90
Account manager → owner exact against the title
Interests → interests exact

One 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 → +447700900418
07700 900731 → +447700900731
00447700900265 → +447700900265
+447700900904 → +447700900904
(0044) 7700 900612 → +447700900612

Nadia 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 n.brandt@heronwood.example +447700900418 Heronwood Care
Rowan Ferrand r.ferrand@ashlyn.example +447700900904 Ashlyn Group
Tomas Vella office@vellamore.example +447700900731 Vellamore Ltd
Amrit Sandhu a.sandhu@kelsbyworks.example +447700900265 Kelsby Works
The collision email phone lastName + company
Two people behind office@vellamore.example 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 office@vellamore.example, 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 R.Ferrand@ashlyn.example in the file and r.ferrand@ashlyn.example 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 written
primaryKey: email 4 new rows 5 new rows
primaryKey: phone 3 new rows 7 new rows

The 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 NettleshipEmailc.nettleship@ovinghamprint.example

Value must be unique.

Casimir NettleshipEmailc.nettleship@ovinghamprint.example

Value must be unique.

Kofi AdjeteyLast nameempty

This field is required.

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.

office@vellamore.example 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.