DEV Community

Casey Marlin
Casey Marlin

Posted on

CSV to vCard: the version minefield (and how to stop imports garbling names)

Every phone and both big contact clouds will happily export your
address book — and then be remarkably picky about what they re-import.
If you've ever moved contacts via a spreadsheet and ended up with garbled
names or missing phone labels, the culprit is almost always the same:
which vCard you generated.

What a vCard actually is

.vcf is plain text, one BEGIN:VCARD … END:VCARD block per person.
A well-behaved 3.0 card:

BEGIN:VCARD
VERSION:3.0
N:García;José;;;
FN:José García
TEL;TYPE=CELL:+34600000001
EMAIL;TYPE=INTERNET:jose@example.com
ORG:Acme GmbH
END:VCARD
Enter fullscreen mode Exit fullscreen mode

Two name fields, and you need both:

  • N is structured — Family;Given;Middle;Prefix;Suffix. The semicolons are separators, so a semicolon inside a name must be escaped as \;.
  • FN is the display name, and it's required in 3.0/4.0. Omit it and some importers silently drop the card.

And the fact almost nobody uses: one .vcf can hold your entire address
book
— just concatenate the blocks. No zips, no file-per-contact.

The version minefield

Version Year Non-ASCII handling In 2026
2.1 1996 QUOTED-PRINTABLE Everything imports it; nothing should still write it
3.0 1998 plain UTF-8 The safe target. Google Contacts exports it; iOS/iCloud, Android, Outlook all import it cleanly
4.0 2011 plain UTF-8 Nicest spec, patchiest importer support — still

If you've ever opened a .vcf and seen =E4=BD=A0=E5=A5=BD where a name
should be, that's vCard 2.1's quoted-printable encoding — the #1 source
of "my contacts imported as gibberish". The fix is boring: generate
3.0, in UTF-8, and move on.
4.0 gains you nothing for a contact dump
and costs you compatibility.

From CSV rows to cards

The annoying part isn't the serializer — it's that every ecosystem's CSV
headers differ. Google's export says Given Name / Family Name,
Outlook's says First Name / Last Name, and your hand-made sheet says
whatever it says. Any converter is really a column-mapping problem
with 25 lines of formatting bolted on:

const esc = s => String(s ?? "")
  .replace(/\\/g, "\\\\").replace(/;/g, "\\;")
  .replace(/,/g, "\\,").replace(/\r?\n/g, "\\n");

const card = r => [
  "BEGIN:VCARD",
  "VERSION:3.0",
  `N:${esc(r.last)};${esc(r.first)};;;`,
  `FN:${esc([r.first, r.last].filter(Boolean).join(" "))}`,
  r.cell  && `TEL;TYPE=CELL:${esc(r.cell)}`,
  r.work  && `TEL;TYPE=WORK:${esc(r.work)}`,
  r.email && `EMAIL;TYPE=INTERNET:${esc(r.email)}`,
  r.org   && `ORG:${esc(r.org)}`,
  "END:VCARD",
].filter(Boolean).join("\r\n");

const vcf = rows.map(card).join("\r\n") + "\r\n";
Enter fullscreen mode Exit fullscreen mode

Wire rules are the same as iCalendar, because the formats share ancestry
(RFC 2425's directory profile): CRLF line endings, long lines folded
at 75 octets with a space starting each continuation line.

Phone numbers: keep E.164 (+34600000001) if you can. Importers don't
validate numbers, but sync engines dedupe far better when the format is
consistent.

Verify before you point it at your phone

My test loop: import the file into Google Contacts first — it
reports how many cards it read, and can undo. Then sync, AirDrop the
.vcf to an iPhone, or open it in Android's Contacts app. If Google reads
297 of 300 cards, the missing three almost always have an unescaped
semicolon or a blank FN.

For the mapping-table part I ended up building
a browser-based CSV ⇆ vCard converter with an
editable column-mapping step — it reads your headers, guesses the
mapping, and lets you correct the guess before writing 3.0 cards. Free
and client-side; nothing uploads.

But honestly, for a one-off with clean columns, the 25 lines above will
do. The format survived 30 years by being boring — write the boring
version of it.

Top comments (0)