The invisible characters that break lookups

Two strings can look identical, compare unequal, and send you debugging the wrong thing for an hour. The cause is usually a character you cannot see.

Why invisible characters exist at all

Unicode is not only a list of printable symbols. It also carries characters whose job is to affect layout, joining, direction or line breaking — which means they have no glyph of their own. That is correct and necessary for rendering many scripts. It is also why a copied string can carry bytes no one intended to type.

The usual suspects

CharacterCode pointLooks likeCommon source
Zero-width spaceU+200BNothingWord processors, some CMS editors, copy from a web page
Zero-width no-break space (BOM)U+FEFFNothingStart of a UTF-8 file saved by certain tools
Non-breaking spaceU+00A0A spaceHTML  , Word, French typography
Narrow no-break spaceU+202FA thin spaceCopy from a formatted document
Soft hyphenU+00ADNothing (or a hyphen at a line break)Word documents hyphenated for layout
Zero-width joinerU+200DNothingEmoji sequences, Indic scripts
Left-to-right markU+200ENothingBidirectional text copied from a UI
Word joinerU+2060NothingCopy from some editors

The bug patterns

Exact-match lookups fail for one user

A user types their email; the app trims whitespace and compares. It fails. The string looks the same in every log and on every error page. The cause is usually a U+00A0 or U+200B pasted along with the value from a document or a password manager that copied formatting. trim() in most languages removes ASCII whitespace and, depending on the runtime, may or may not remove U+00A0 — String.prototype.trim in JavaScript does handle it, but many bespoke trim helpers use /^\s+|\s+$/ which historically did not.

A BOM breaks the first CSV header

A UTF-8 file saved with a BOM starts with EF BB BF. If the reader does not strip it, the first header is id rather than id — and every lookup of id misses on that one file.

Two strings that look identical, compare unequal

This is the homoglyph problem: Cyrillic а and Latin a, or a Greek ο and a Latin o. They render nearly identically and are different code points. It matters for identifiers, domain names and any place a value is compared literally.

Line breaks that are not \n

Besides \r\n, text copied from some sources contains U+2028 (line separator) and U+2029 (paragraph separator). They look like ordinary line breaks and behave differently in parsers, template engines and JSON (where they must be escaped).

Finding them

An ordinary editor will not show you these, and len() will not tell you which character is which. The reliable check is to look at the code points: a zero-width character has a code point but no glyph, so an inspector that lists every character will surface it immediately. The code point inspector does exactly this and flags the invisible ranges.

"admin"  vs  "admin​"     # the second has a trailing zero-width space
hex: 61 64 6d 69 6e    vs    61 64 6d 69 6e e2 80 8b

Cleaning them safely

Blanket removal is dangerous. Some of these characters are load-bearing: a zero-width joiner is what makes an emoji sequence render as one glyph, and the bidi marks are required for correct display of Arabic and Hebrew. Removing them unconditionally corrupts legitimate text.

  • For a single field (email, username, id), strip the known problem characters — U+200B, U+FEFF, U+00A0 — and normalise with NFC. These should never appear in an identifier.
  • For prose, do not strip joiners or bidi marks. Normalise to NFC instead, which unifies composed and decomposed accents without changing visible content.
  • For a BOM, strip it once at read time, at the start of the file only — a U+FEFF anywhere else is content.
  • For comparison, normalise both sides (NFC) and, if the input may contain lookalikes, decide deliberately whether to map them.

How to find them without an inspector

An inspector is the reliable tool, but there are quick checks you can run in a shell when you cannot paste the string into a browser.

# show non-ASCII bytes in a file
grep -nP '[^-]' file.txt

# show the hex of a suspicious line
sed -n '42p' file.txt | xxd | head

# count zero-width spaces specifically (UTF-8: e2 80 8b)
grep -c $'​' file.txt

# find a no-break space (c2 a0) — often mistaken for an ordinary space
grep -n $' ' file.txt

The output tells you the byte sequence, not the character name. That is enough to decide: if a line that should be pure ASCII contains a byte above 0x7F, something invisible or accented got in.

Zero-width characters in code and config

The worst place for an invisible character is a source file or a config, because it can make code that looks correct behave differently. A zero-width space inside an identifier produces a name that is not the one you meant, so a lookup fails while the source reads normally. A non-breaking space in an indented config (YAML, Python) is not whitespace to the parser, so the indentation level is wrong in a way the editor does not show.

This is why many editors ship a “show invisibles” mode and why linters flag non-ASCII in source. If a YAML file fails with an indentation error that you cannot see, check for U+00A0 before rewriting it.

What each character actually does

CharacterEffect when presentWhy it exists
U+200B zero-width spaceAllows a line break; counts as a character in comparisonsLet browsers break long words without a hyphen
U+200C zero-width non-joinerPrevents a ligature or joinCorrect rendering in Persian, Indic scripts
U+200D zero-width joinerJoins the next character into the same glyphEmoji sequences, Indic conjuncts
U+FEFF BOMIgnored at the start of a file; a stray character elsewhereMarks UTF-8 encoding for legacy readers
U+00A0 no-break spaceA space that does not break at a line endTypography: keep a number with its unit
U+00AD soft hyphenInvisible unless the line breaks thereOptional hyphenation points in long words
U+202A–U+202E bidi controlsChange the direction of following textMixed left-to-right and right-to-left text

Notice the pattern: almost every one of these is load-bearing for some text. That is why the fix is “strip them from fields where they cannot be meaningful” rather than “strip them everywhere”.

Where they come from

In practice, invisible characters arrive through three routes. Copying from a rendered document — a web page, a PDF, a Word file — carries whatever the layout engine inserted, including soft hyphens and no-break spaces. Copying from a UI that formats values — a dashboard that renders an email in a styled span may include a zero-width space for line breaking. Generated files — some exporters emit a BOM, and some libraries insert zero-width spaces to control wrapping.

A useful debugging instinct: when a value fails an exact match and looks correct, paste it into an inspector before checking the matching logic. The logic is usually right; the string is not what it appears to be.

Sanitising a field without breaking the text

There is no universal “remove invisibles” step, because the right set depends on what the field holds. A reasonable policy for an identifier field — username, email, a key — is to normalise to NFC and then remove everything in the format and invisible-operator categories, plus the no-break spaces. For prose, do the opposite: keep the joiners and bidi marks, and only normalise. The distinction is whether the value is meant to be read or compared.

# identifier policy, sketched
s = s.normalize("NFC")
     .replace(/[\u200B-\u200F\u2060\uFEFF]/g, "")   # zero-width + bidi marks
     .replace(/[\u00A0\u202F\u2007\u2009]/g, " ")     # exotic spaces -> normal space
     .trim()

Note that this turns no-break spaces into ordinary spaces rather than deleting them. Deleting them would join two words that a user separated; converting keeps the separation and discards the typography, which is usually what an identifier wants.

The BOM, specifically

A BOM is the one invisible character with an official role: at the very start of a UTF-8 file it identifies the encoding for readers that would otherwise guess. The correct handling is to strip exactly one occurrence at offset zero when reading, and to leave any other U+FEFF alone — a U+FEFF in the middle of a document is a zero-width no-break space, which is content.

This is why “strip BOM” implemented as a global replace is wrong: it deletes meaningful characters from the body. It is a small bug with a large blast radius, because it silently alters text that passed through the sanitizer.

Check it yourself

The whitespace cleaner removes the invisible and exotic whitespace characters and reports what it removed, without touching joiners or bidi marks. The UTF-8 inspector shows the bytes of every character, and the code point inspector names them.

Questions this guide raises

Where do zero-width spaces come from?

Copying from web pages and content management systems, PDF extraction, translation tools that insert word-joiner characters, and rich-text editors that preserve formatting marks. They are also used deliberately by some platforms to allow line breaks in long identifiers, which is why they can appear in otherwise clean text.

Does trim() remove them?

It removes whitespace, which includes the non-breaking space U+00A0 in JavaScript, but not the zero-width space U+200B: that is a format character, not whitespace, so trim leaves it in place. That is why a value can look trimmed and still fail an exact comparison.

How do I remove them without breaking emoji?

Remove the specific characters you did not intend — U+200B, U+FEFF, U+00A0 and friends — rather than every invisible code point. The zero-width joiner U+200D is meaningful inside emoji sequences, and removing it splits a family emoji into separate people. The cleaner on this site lists what it removes for that reason.