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
Two name fields, and you need both:
-
Nis structured —Family;Given;Middle;Prefix;Suffix. The semicolons are separators, so a semicolon inside a name must be escaped as\;. -
FNis 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";
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)