Skip to main content

Guides

The Regex That Actually Removes Invisible Unicode Characters

CleanPastedText editorial · Updated

Text workbench

Try it on your text

Cleaning mode

Removes hidden characters and standardizes AI-style punctuation.

Try a real example

Each sample contains a problem you cannot see.

Original

Pasted text

0 chars · 0 words

Cleaned

Ready to copy

0 changes

Text stays in this browser

Same words · No AI rewriting · No content logging

What regex actually removes invisible Unicode characters?

A property escape over Unicode's Format category, not a whitespace shorthand. Zero-width spaces, word joiners, byte order marks, soft hyphens, and bidirectional controls all share one thing: the Unicode Character Database classifies every one of them as general category Cf ("Format"). A single regex can target the whole group at once:

function stripInvisible(text) {
  return text
    // Cf: zero-width space/joiner, BOM, soft hyphen, bidi controls, tag
    // characters — except ZWNJ/ZWJ, which change spelling in some scripts.
    .replace(/\p{Cf}/gu, (ch) => (ch === "\u200C" || ch === "\u200D" ? ch : ""))
    // Zs: fold exotic Unicode spaces to a plain one instead of deleting them.
    .replace(/\p{Zs}/gu, (ch) => (ch === " " ? ch : " "));
}

The u flag is not optional, and forgetting it fails silently rather than loudly. Unicode property escapes were added in ES2018 and only exist in Unicode-aware mode; drop the flag and \p{Cf} is parsed under the older Annex B grammar as the identity escape \p (a literal "p") followed by the literal text {Cf}. No error, no warning — the pattern just matches the four-character string p{Cf} and quietly ignores every real invisible character in the input.

Why doesn't \s already catch this?

Because whitespace and "format character" are different Unicode concepts, and \s only ever matches the first one. JavaScript's \s is defined against a specific WhiteSpace list that happens to include the non-breaking space (U+00A0) and, for historical script-tag reasons, the byte order mark (U+FEFF) — which is why those two feel like they get cleaned up "for free." A zero-width space (U+200B) or word joiner (U+2060) is category Cf, not whitespace by any definition, so \s walks straight past it no matter how many times you apply it. Python's \s behaves the same way for the same reason: it tracks Unicode whitespace, not the Format category.

Removing invisible characters in Python

Python's built-in re module has no \p{...} syntax at all — porting a JavaScript or PCRE pattern verbatim raises re.error: bad escape \p at compile time, since re has no concept of a Unicode property test. Two ways around it:

Stdlib only, filtering by category name directly:

import unicodedata

def strip_invisible(text: str) -> str:
    keep_anyway = "\u200c\u200d\t\n\r"  # ZWNJ, ZWJ, and real whitespace
    return "".join(
        ch for ch in text
        if unicodedata.category(ch) not in ("Cf", "Cc") or ch in keep_anyway
    )

unicodedata.category(ch) returns the exact two-letter general category for a single character — the same value the Unicode Character Database itself assigns — so this needs no external dependency and stays accurate as long as the Python interpreter's bundled Unicode version does.

With the third-party regex package (not the standard library — pip install regex), the same \p{Cf} syntax as JavaScript works directly:

import regex

def strip_invisible(text: str) -> str:
    return regex.sub(r"(?V1)[\p{Cf}--[\u200c\u200d]]", "", text)

-- inside the character class is set subtraction — one of the regex package's own extensions over re, gated behind the (?V1) flag shown at the start of the pattern (the module defaults to VERSION0, which only understands plain sets). Plain re and native JavaScript regex have no set-subtraction equivalent at all, which is why the JavaScript version above filters ZWNJ/ZWJ back in with a callback instead.

What a Cf/Zs regex still misses

Unicode's general categories are exact, but there are more of them than "format" and "space." Two gaps matter most in practice:

CategoryExamplesWhy \p{Cf} misses it
Variation selectors (Mn)U+FE00–U+FE0F, U+E0100–U+E01EFClassified as Nonspacing_Mark, not Format — they're the channel used to smuggle hidden bytes onto an ordinary character or emoji.
Control characters (Cc)U+0000–U+001F, U+007F–U+009FA separate general category from Format entirely, so a Cf-only pattern leaves every C0/C1 control character (besides tab and newline) untouched.

Neither omission is a bug in the regex — it's doing exactly what a Format-category test is supposed to do. It just means "strip Cf" and "strip every invisible or hidden-data character" are two different jobs, and conflating them is how a homemade cleaner can report success on text that still carries a payload.

When a regex isn't enough

A one-line regex is the right tool for a script that only needs to handle a known, narrow case — stripping a BOM before JSON.parse, say (covered in fixing "Unexpected token in JSON at position 0"). For arbitrary pasted text, the gaps above compound: emoji made of a base character plus a variation selector need the selector kept, while the same selector appearing after plain text needs it removed, and no fixed regex can tell those two cases apart without first parsing the surrounding sequence. The checker at the top of this page does that parsing and names every character it finds by codepoint before removing anything, entirely in your browser. For the full catalog of characters in play, see the complete invisible Unicode characters list or the developer-focused guide to sanitizing copied text for code, JSON, and CSV.

Common questions

Frequently asked questions

What's the single regex that removes invisible Unicode characters in JavaScript?

/\p{Cf}/gu matches every character in Unicode's "Format" general category, which covers zero-width spaces, word joiners, byte order marks, soft hyphens, and bidirectional controls in one property escape. It requires the u flag — drop the flag and \p{Cf} is parsed as the identity escape \p (a literal "p") followed by the literal text "{Cf}", so it silently matches the 4-character string "p{Cf}" instead of throwing or matching any real invisible character. It also does not, by itself, know that ZWNJ and ZWJ (U+200C, U+200D) carry real meaning in Persian, Hindi, and other scripts, so a correct strip has to special-case those two before deleting the rest of the match.

Why doesn't \s match a zero-width space?

Because \s matches whitespace, and a zero-width space is not classified as whitespace — it's Unicode general category Cf (Format), not Zs (Space_Separator) or the newline/space characters JavaScript's WhiteSpace production lists explicitly. JavaScript's \s already includes the non-breaking space (U+00A0) and even the byte order mark (U+FEFF, added to the WhiteSpace production for legacy script-tag compatibility), which is why people are often surprised \s catches those two but walks straight past U+200B, U+2060, and the rest of the Cf block.

How do I remove invisible characters in Python without a third-party library?

Filter by category with the standard-library unicodedata module: ''.join(ch for ch in text if unicodedata.category(ch) not in ('Cf', 'Cc') or ch in '\u200c\u200d\t\n\r'). Python's built-in re module has no \p{...} syntax — re.compile(r'\p{Cf}') raises re.error: bad escape \p, which at least fails loudly. That's still a common trip-up for anyone porting a JavaScript or PCRE pattern, since \p{...} is valid syntax in both of those.

Is there a Python regex module that supports \p{Cf} like JavaScript does?

Yes — the third-party regex package (pip install regex; import regex, not re) implements Unicode property escapes, so regex.sub(r"\p{Cf}", "", text) works exactly like the JavaScript pattern. It's a drop-in for most re code, but it is still a dependency, so a script that only needs to run once doesn't necessarily need it over the stdlib unicodedata approach.

What does a Cf/Zs regex still miss?

Two whole categories: variation selectors (U+FE00–U+FE0F and U+E0100–U+E01EF), which are general category Mn (Nonspacing_Mark) rather than Cf, and are the channel used to hide extra bytes after an ordinary character or emoji; and C0/C1 control characters (category Cc), which \p{Cf} was never meant to cover. A regex that only tests Cf and Zs will report clean text that still carries either of those.

How do I check that my regex actually caught everything?

Run the text through a character-level checker before and after your regex and diff the two reports — a checker that names every codepoint it finds, like the one on this page, will show you exactly which categories survived. That's a faster way to validate a homemade pattern than reading the Unicode tables by hand, and it catches the emoji-safety and script-preservation edge cases a quick regex tends to miss.

Continue reading

Related guides & tools