Engineering
Engineering

Rethinking CSV Imports: From File Upload to Product Activation

Jasmine Frantz · 2026.8.6 · 9 min read

Most CRM imports treat CSV parsing as the problem. We think the real problem is helping customers get from "I have my data" to "I'm using the product" with as little friction as possible.

Importing existing customer data is one of the highest-friction moments in almost every CRM. If someone can't get their contacts, companies, and deals into your product quickly, they never reach the point where it starts creating value. Yet importing is the step most products treat as an afterthought.

When we rebuilt Introzy's importer, we stopped thinking about CSV parsing and started thinking about onboarding, and we committed to one principle:

One screen. One click. Nothing blocks.

A clean spreadsheet should require almost no interaction. A messy one should ask only the questions that matter. This post is about what that principle actually turned into: the pipeline, the confidence math, and the parts we deliberately haven't built yet.

The pipeline

The importer is a session-based pipeline split across three tiers. The browser does upload UX and row counting, then pushes the file straight to object storage via a signed URL. A thin BFF layer owns auth, org scoping, and session state. Everything heavy runs in background workers.

INGESTING → UNDERSTANDING → READY → IMPORTING → COMPLETE | FAILED

Client:  drop CSV → count rows → signed upload
BFF:     create import session → enqueue "understand" job
Worker:  parse → map columns → classify rows → compute diff → generate questions
Client:  review mapping, cards, and diff counts → commit
Worker:  write companies → people → deals, stamp every record with its batch

The "understand" phase is where the interesting decisions live, so let's take it apart.

Column mapping: three signals and an agreement gate

We map columns using three independent producers, none of which is trusted alone:

  1. Header rules. A normalized alias table ("Work Email", "E-mail Address", and friends map to email). When a rule hits, confidence is 1. When it doesn't, rules abstain rather than guess.
  2. An LLM on the data, not the headers. We sample up to 20 distinct values per column (capped at a 12,000-character budget, individual values truncated at 80 chars) and ask a small, fast model (Claude Haiku) what the column contains. A column full of email addresses is an email column no matter what the header says.
  3. Value-shape heuristics. Cheap regex-level checks over the same samples: does the majority of the column look like phone numbers, URLs, dates? A field needs at least a 0.5 majority ratio to be proposed; a 0.7+ ratio scores 0.95, anything weaker scores 0.7. Value-shape alone can never auto-accept a mapping.

The three signals then pass through an agreement gate. From the merge logic's contract:

Auto-accept needs ≥2 corroborating signals among {rules, LLM, value-shape}.
When rules abstain, a lone LLM signal still caps to Confirm
unless value-shape agrees.
When the LLM is down: rules win when present; rules-null +
value-shape → Confirm pre-fill only.

The output lands in one of three tiers, driven by real constants rather than vibes:

export const AI_IMPORT_CONFIDENCE_TIERS = {
  autoAcceptMin: 0.9,
  confirmMin: 0.6,
  confirmCapOnDisagreement: 0.89,
} as const

At 0.9 and above the mapping is applied silently. Between 0.6 and 0.9 the user sees a one-tap confirmation card. Below 0.6 it becomes an open question whose safe default is "leave this column unmapped." And when any two signals disagree, confidence is capped at 0.89 no matter how sure either signal is individually, which forces a human confirmation. Bad guesses destroy trust much faster than one extra click.

Crucially, none of these questions block. Unanswered cards resolve to their safe defaults at commit time. Answer the ones you care about, ignore the rest, and every default we apply is reported rather than hidden.

Row classification: rules, not vibes

Real exports mix people, companies, and deals in one file, so we classify every row rather than forcing users to split spreadsheets. Here we made the opposite choice from column mapping: no per-row LLM. Running a model over 50,000 rows is slow, expensive, and worst of all nondeterministic; the same file should classify the same way every time. Scoring rules do the job:

const fromType = normalizeTypeCell(row.type)
if (fromType != null) {
  return { entityType: fromType, confidence: 1, source: 'type_column' }
}
if (opts.strongDealFile && !personish) {
  return { entityType: 'deal', confidence: 0.8, source: 'deal_signature' }
}
if (companyish && !personish) {
  return { entityType: 'company', confidence: 0.85, source: 'inferred' }
}
if (personish || (!companyish && !opts.strongDealFile)) {
  return { entityType: 'person', confidence: personish ? 0.9 : 0.6, source: 'default' }
}

An explicit type column wins outright. A file-level "deal signature" (amount-like columns plus stage or pipeline columns) marks deal rows. Company identity without a person name means company. Rows we genuinely can't place become an ambiguity question whose safe default is skip: a conservatively skipped row is recoverable, a wrongly imported one is a mess.

The preview is a dry run, not a promise

Before a single record is written, the whole import runs as a dry-run diff against the org's real data. Every row lands in one of four buckets: create, update, no change, or skip, with field-level from/to detail on matches.

Matching is deliberately boring: people match on normalized email (primary first, then secondary emails), companies match on domain first and normalized name second, with personal email domains excluded from domain matching. Boring means predictable, and predictable means users can reason about what a reimport will do.

The skip rails are where the design earns its keep, because skips are as visible as creates:

if (!emailRaw) {
  skips.push({ ...base, reason: 'missing_email' })
  return
}
if (skipInFileDuplicates && seenEmailsInFile.has(email)) {
  skips.push({ ...base, reason: 'duplicate_in_file' })
  return
}
if (isOrgMemberMatch(match, orgMemberUserIds)) {
  skips.push({ ...base, reason: 'org_member', matchedUserId: match.id })
  return
}

That last rail matters more than it looks: a row that matches an actual teammate's account gets skipped rather than silently overwriting a coworker's profile. If a LinkedIn export has hundreds of contacts with no email address, those rows can't become people, and hiding that is how you get a support ticket claiming the import "lost" half someone's data. Showing it plainly, with the reason attached, turns a silent failure into an understood outcome.

Merge semantics default to fill-gaps, and the invariant is small enough to read in full:

/** Fill-gaps: blank never clears; populated fields never overwritten. */
function proposedChangeFillGaps(field, from, csvValue) {
  const to = blankToNull(csvValue)
  if (to == null) return null
  const fromNorm = blankToNull(from)
  if (fromNorm != null) return null
  return { field, from: null, to }
}

Even in overwrite mode, a blank CSV cell never clears existing data. And because matching is deterministic, running the same file twice lands everything in "no change." The user who isn't sure the first import worked can simply run it again. Nothing breaks.

Commit runs in the background

The moment someone clicks Import, the work moves to a queue. Jobs get stable IDs derived from the session, so a double-click can't enqueue the same import twice; workers run with bounded concurrency and three retry attempts with exponential backoff. Files are capped at 50MB and 50,000 rows.

Partial success is the normal case, not an exception. Writes proceed row by row; a malformed row records an error and the loop continues, so one bad row never throws away a thousand good ones. The session completes if anything succeeded and only fails when nothing did. When it finishes, a server-sent event finds the user wherever they are in the app, no progress bar to babysit.

Every record we write is stamped with its import batch ID, indexed, so a batch stays easy to find and audit after the fact.

What we haven't built yet

An engineering post that only lists wins is a marketing post. Three things the design calls for that aren't shipped:

  • Failed-row export. Today, row errors are recorded on the import batch with the first 50 reported in the result summary. The downloadable fix-and-reupload file is designed but not built.
  • Date and phone normalization. We normalize website URLs and lowercase emails, and that's it so far. Dates were deferred deliberately: guessing wrong between day-first and month-first formats corrupts data silently, which violates the trust rules above. We'd rather not normalize than normalize wrong.
  • Batch-level undo. The batch stamp on every record gives us the rail for it, but the product surface doesn't exist yet, and reimports are currently last-writer-wins.

The real goal

A good import doesn't feel like a feature. It feels like the product understood what you had and did the obvious thing with it.

Every decision above traces back to the same principle: one screen, one click, nothing blocks. Infer with corroboration, never a single signal. Ask in plain language with a safe default when you must. Show the work as a diff before committing it. And let people walk away.

Because the goal was never to parse a CSV. It was to get someone from "I have my data" to "I'm using the product" as fast as possible.

Try Introzy: Import your contacts, companies, and deals in a single click. Get started free →

We ship in public. Come build with us.

Introzy is free to start. No card required.