Invisible Unicode Characters: The Zero-Width Space and Its Relatives
The bug report says the two strings are identical. The equality check says they are not. Somewhere in one of them is a character that renders as nothing — a zero-width space from a PDF, a non-breaking space from a word processor, a byte-order mark from a Windows editor — and until you can see it, you cannot fix it. This guide catalogues the characters that hide in text, where each one comes from, what it breaks, and how to find and remove it.
The short version
- Around a dozen Unicode characters take no space on screen. They arrive by copy-paste and break comparison, parsing, search and filenames.
U+200Bzero width space,U+FEFFbyte order mark andU+00A0no-break space account for most incidents.- The bidirectional overrides (
U+202Eand friends) can make text display in a different order than it is stored — the "Trojan Source" attack. - Lookalike letters from other scripts — Cyrillic
аfor Latina— are the same problem in a different form. - Strip
\p{Cf}(format characters) and normalise to NFC at input boundaries; keep a tool that shows code points for when something still slips through.
The characters, and where they come from
| Code point | Name | Typical source | What it breaks |
|---|---|---|---|
U+200B | Zero width space | PDF text extraction, web page copy, chat apps, some keyboards | Exact matching, search, tokenisation, JSON keys |
U+200C / U+200D | Zero width non-joiner / joiner | Legitimate in Persian, Arabic, Indic scripts and emoji sequences; stray copies elsewhere | Length checks, deduplication |
U+FEFF | Byte order mark | Windows editors and Excel exports at file start; concatenated files mid-stream | JSON and YAML parsers, shebang lines, CSV headers ("id" is not "id") |
U+00A0 | No-break space | Word processors, HTML , macOS Option+Space | Anything that splits on ' '; numbers with thousands separators failing to parse |
U+2060 | Word joiner | Typesetting tools | Same as ZWSP |
U+00AD | Soft hyphen | Hyphenated web and e-book text | Search: "document" does not match "document" |
U+202A–U+202E, U+2066–U+2069 | Bidirectional controls | Right-to-left text; deliberately, in attacks | Display order: what you see is not what is stored |
U+2028 / U+2029 | Line / paragraph separator | Older Mac apps, some JSON producers | Historically illegal inside JavaScript string literals; JSON embedded in scripts |
U+FFFD | Replacement character | A decoder that met invalid bytes | Nothing itself — it is the evidence that data was already lost |
U+0000 | Null | Binary read as text, C string bugs | Rejected by many databases and file systems |
How they get into your data
Very few of these are typed on purpose. They travel. A user copies an address from a PDF, whose text layer inserts zero-width spaces at line-break opportunities, and pastes it into a form. A spreadsheet is saved as "CSV UTF-8" by Excel, which prepends a BOM that the importer reads as part of the first column name. A product description is written in Word, where the typographer's habit of a non-breaking space before a unit becomes U+00A0 in the export. A support agent pastes an id from a chat client that decorated it with a word joiner to stop it wrapping.
None of it is visible at any of those steps, which is why the same character ends up in a database, an API response and a log file before anyone notices. The symptoms are indirect: a lookup that fails for one record, a CSV with a column called email, a search that cannot find text that is plainly on the page, a filename that exists on the Mac but "does not exist" on the Linux server.
The ones that lie about order
The bidirectional control characters exist so that Arabic and Hebrew can be mixed with Latin text correctly. U+202E RIGHT-TO-LEFT OVERRIDE forces everything after it to display right-to-left until a pop character. Placed inside a filename, invoicefdp.exe is stored as invoice[RLO]fdp.exe but displays as invoiceexe.pdf — an executable wearing a PDF's name.
The same trick inside source code is the "Trojan Source" class of vulnerability (CVE-2021-42574): a comment or string containing bidi overrides can make code that a reviewer reads as harmless compile as something else, because the editor renders the reordered text while the compiler consumes the stored order. Modern GitHub, VS Code and most compilers now warn on these characters, and a code review tool that shows code points would have caught it from the start. Treat any bidi control outside genuinely right-to-left content as hostile.
Lookalikes: visible but wrong
A related family is not invisible at all — it looks exactly like something else. Cyrillic а (U+0430), Greek ο (U+03BF) and Latin a and o are indistinguishable in most fonts. A domain like раypal.com with two Cyrillic letters is the classic homograph attack, and the same substitution in a username or package name gets past a human reviewer every time. Browsers defend domains by refusing to display a name that mixes scripts within a label; you can apply the same rule to any identifier: a word whose letters come from more than one script is suspect. Punctuation has lookalikes too — curly quotes for straight ones, en dashes and minus signs for hyphens — which are harmless in prose and fatal in a config file or a command line.
See exactly what is in the string
Layerbit's Unicode Inspector lists every code point with its UTF-8 and UTF-16 bytes, names the invisible and bidi characters, flags words that mix scripts and lookalike punctuation, compares the four normalization forms, and copies a cleaned version — in your browser, so pasting a password or a customer record is safe.
The other way two strings differ
Even with no invisible characters, café can be two different strings: U+00E9 (one code point, "precomposed") or U+0065 U+0301 (e plus a combining acute accent). They render identically, they are canonically equivalent under Unicode, and === says they are different. Normalization fixes this by picking one form. NFC composes where possible and is what you should store; NFD decomposes; the K forms additionally fold compatibility characters, turning fi into fi and full-width A into A, which is right for loose identifier matching and wrong for content.
The place this bites in practice is macOS filenames. HFS+ stored names in a decomposed form, so a file created on a Mac and synced to Linux can have a name that does not byte-match the composed name in your database. Normalize to NFC on the way in — str.normalize('NFC') in JavaScript, unicodedata.normalize('NFC', s) in Python, Normalizer.normalize(s, Form.NFC) in Java — and the problem does not arise.
Finding and removing them
To see what is there, print code points rather than characters: [...s].map(c => c.codePointAt(0).toString(16)) in JavaScript, [hex(ord(c)) for c in s] in Python, xxd or cat -A on a file. To remove the invisible ones in bulk, use Unicode property classes rather than a hand-written list:
// JavaScript: strip format characters, normalise spaces, compose accents
s.replace(/\p{Cf}/gu, '') // ZWSP, ZWJ, BOM, bidi controls, soft hyphen …
.replace(/[ - ]/g, ' ')
.normalize('NFC');
# Python
import re, unicodedata
s = ''.join(c for c in s if unicodedata.category(c) != 'Cf')
s = unicodedata.normalize('NFC', s)
Do this at the boundary — form submission, file import, API ingestion — and once. Be selective about ZWJ and ZWNJ if you handle Persian, Arabic, Indic scripts or emoji, where they are meaningful; stripping them corrupts the text. And keep the raw input somewhere for a while, because a cleaning step that is too aggressive is itself a data-loss bug.
Make the invisible visible first
Every problem in this guide is trivial once you can see the character and baffling until you can. A string is a sequence of code points, not of glyphs, and the tools that show you the code points — a hex dump, a code-point list, a diff that marks invisible characters — turn hours of "but they look the same" into a two-minute fix. Add one to the toolbox before the next bug report arrives.
Related guides and tools
Frequently asked questions
U+200B is a Unicode format character that occupies no space on screen and marks a point where a line may break inside a word. It enters text through copy and paste, most often from PDFs, web pages and messaging apps that insert it for layout. Because it is a real character in the string, it breaks exact matching, search and parsing while remaining invisible in every editor.
Use the Unicode format-character class rather than a hand-made list: in JavaScript s.replace(/\p{Cf}/gu, ''), in Python filter out characters whose unicodedata.category is 'Cf'. Then replace no-break and typographic spaces with ordinary spaces and normalize to NFC. Be careful with zero-width joiners in scripts and emoji sequences where they carry meaning.
The file starts with a byte order mark, U+FEFF, which Excel's "CSV UTF-8" export and many Windows editors prepend. A parser that does not strip it treats the three bytes as part of the first field. Open the file with a BOM-aware encoding (utf-8-sig in Python), or strip a leading U+FEFF before parsing the header row.
A technique (CVE-2021-42574) that uses Unicode bidirectional override characters inside source code comments or strings so that the code an editor displays differs from the code a compiler reads. A reviewer sees harmless logic while the stored order does something else. Modern editors and code hosts warn on these characters, and inspecting code points reveals them immediately.
One string uses the precomposed é (U+00E9) and the other uses e followed by a combining acute accent (U+0065 U+0301). They look identical and Unicode defines them as canonically equivalent, but they are different sequences of code points. Normalize both to the same form, NFC for storage, before comparing.