There is no empty character — here is what the twenty candidates actually do

Short answer: Unicode has no code point that means “nothing”. Every character you can put into a string is a character, and each one makes the string one code point longer. The nearest candidate, U+0000 NULL, is a real character with a real encoding: JSON.stringify writes it as \u0000, encodeURIComponent writes it as %00, and neither JavaScript’s trim() nor Python’s strip() will take it off — a seven-character string measured seven characters before and after. What people mean by “empty character” is really one of two different questions, and they do not have the same answer: which character paints nothing on screen, and which character a program will agree is blank. I ran twenty candidates through both JavaScript and Python. Ten come off under both languages’ trim. One — U+FEFF — comes off under JavaScript and stays under Python. Nine survive both.

Two questions hiding inside one search

Ask for an empty character and you are usually asking one of two things. Sometimes you want a character you can put in a name field so the field looks unfilled. Sometimes you are staring at a string that prints as nothing, failing a comparison, and you want to know what is in it.

Those are not the same list. A character that paints no ink is a question about fonts and general categories; a character that passes a blank check is a question about whichever predicate the program happens to call. The second one is not even stable within a single language. Java ships two trimmers with different definitions — the older one takes anything at or below U+0020, so it removes a trailing NUL, while the newer one asks a whitespace predicate instead, so it removes U+2000 and leaves the NUL alone. The two sets overlap by about ten characters, which is why people end up calling one and then the other. A string holding a single space is, depending on which method you reach for, both not empty and blank. Same string, two answers, one standard library.

Twenty candidates, three answers

Measured on this machine: Python 3.13.14 (unicodedata 15.1.0) and Node 22.22.2 (ICU 78.2, Unicode 17.0). trim is String.prototype.trim; strip is str.strip() with no argument; \s is the regex class with the Unicode flag in both. The last column is what comes out after NFKC.

Code pointCatJS lenJS trimPy strip\sAfter NFKC
U+0000 NULLCc1nononoU+0000
U+0009 TABCc1yesyesyesU+0009
U+0020 SPACEZs1yesyesyesU+0020
U+00A0 NO-BREAK SPACEZs1yesyesyesU+0020
U+00AD SOFT HYPHENCf1nononoU+00AD
U+180E MONGOLIAN VOWEL SEPARATORCf1nononoU+180E
U+2000 EN QUADZs1yesyesyesU+0020
U+2007 FIGURE SPACEZs1yesyesyesU+0020
U+200B ZERO WIDTH SPACECf1nononoU+200B
U+200C ZERO WIDTH NON-JOINERCf1nononoU+200C
U+200D ZERO WIDTH JOINERCf1nononoU+200D
U+2028 LINE SEPARATORZl1yesyesyesU+2028
U+2029 PARAGRAPH SEPARATORZp1yesyesyesU+2029
U+202F NARROW NO-BREAK SPACEZs1yesyesyesU+0020
U+205F MEDIUM MATHEMATICAL SPACEZs1yesyesyesU+0020
U+2060 WORD JOINERCf1nononoU+2060
U+3000 IDEOGRAPHIC SPACEZs1yesyesyesU+0020
U+3164 HANGUL FILLERLo1nononoU+1160
U+FEFF ZERO WIDTH NO-BREAK SPACECf1yesnoJS onlyU+FEFF
U+E0020 TAG SPACECf2nononoU+E0020

Read the category column and the trim column together and the shape of the problem appears. Everything the two languages agree on removing is a separator: Zs, Zl, Zp, plus the two ASCII controls. Everything they leave behind is a Cf format character or a letter. “Whitespace” and “invisible” are simply different properties, and the trimmers only know about the first one. That is the whole reason a field can look untouched and still fail === "".

The one character the two languages fight over

U+FEFF is the single row where JavaScript and Python disagree, and it disagrees on three counts at once. JavaScript’s trim() removes it, JavaScript’s \s matches it, and so a blank check in a browser or in Node reports the string as empty. In Python 3.13, '\ufeff'.isspace() is False, the \s class does not match it, and strip() leaves it exactly where it was.

That matters more than it sounds, because U+FEFF is the byte-order mark. It is the character that arrives at the front of a file when something saved text as UTF-8 with a signature, and it is the one most likely to be sitting at position zero of a paste you did not type. A JavaScript validator cheerfully reports the field as blank. The same bytes handed to Python come back non-empty, and the two halves of one system now disagree about whether the user typed anything.

U+2028 and U+2029 deserve a footnote here. Both are whitespace to both languages, and both come off under trim — but unlike the six real spaces they never normalize down to U+0020, because they are line and paragraph separators rather than space separators. They trim like whitespace and refuse to become a space.

Normalization settles the argument before you get a vote

Six of the twenty stop being exotic the moment you normalize: NFKC rewrites U+00A0, U+2000, U+2007, U+202F, U+205F and U+3000 into an ordinary U+0020. Measured in Python 3.13, a comparison of a leading no-break space against a leading space is False before normalization and True after it. Which means a uniqueness check that runs after a normalization pass treats your pasted no-break space as the space bar, and one that runs before does not. The two forms also hash differently, so a lookup key computed on the raw paste will miss a record that was stored normalized.

The other half of the table is the half that bites. U+200B, U+00AD, U+180E, U+2060, U+FEFF and U+E0020 came out of NFC, NFD, NFKC and NFKD byte-identical in every test. No normalization form will ever make them go away, and no amount of normalizing will turn them into something a trim will remove. U+3164 is the odd one out in a different way: it is not deleted either, it is rewritten. NFKC turns the Hangul filler into U+1160, which is a different invisible character. Of the twenty rows, the plain space and U+3164 are the only two that Python reports as printable — and U+3164 is printable because it is a letter, not because it shows you anything.

The invisible character that costs two slots

Length is the test people trust most, so here is where it breaks. maxlength on an input counts UTF-16 code units, not characters you can see. Measured in Chromium:

What went into a field with maxlength="5"What the field holds
abcde then Xabcde — the sixth letter refused
abcd then U+200B5 units, the last one invisible, nothing on screen
eight U+200B5 units, all invisible, field looks empty and is full
abc then U+E0020 then dabc plus the tag character; the d never arrives
abcd then U+E0020abcd — the tag character dropped silently
one U+E0020 into maxlength="1"nothing at all, 0 units
one emoji into maxlength="1"nothing at all, 0 units

The tag character needs two code units and the emoji needs two code units, so in a one-character field both are refused outright — the browser will not half-insert a surrogate pair. In a five-character field the arithmetic gets quietly hostile. After abcd there is one slot left and the tag character needs two, so it is discarded with no message and no error. After abc there are two slots left, the tag character takes both, and the letter typed next has nowhere to go. Somewhere else on the same page, five zero-width spaces have filled the field completely and the input looks untouched.

Two functions, one string, opposite answers

Length is not the only test that gives two answers. Collation does it too. In Node 22, "sample" === "\u200Bsample" is false, and "sample".localeCompare("\u200Bsample") returns 0 — the collator treats the zero-width space as ignorable and reports the two strings as equal. Swap the zero-width space for a no-break space and localeCompare returns 1. So a duplicate check built on collation will reject the zero-width version as already taken while a strict comparison treats it as brand new, and both are behaving exactly as specified.

That is the pattern, not the exception. Every layer that touches your string has its own definition of nothing, and they were each written for a different job. Trimmers care about separators. Normalizers care about canonical equivalence. Collators care about sort weight. Length cares about code units. None of them is wrong, and none of them agrees with the others.

A blank check that actually works

Order matters, and this is the part most snippets get wrong. Normalize first, so the exotic spaces become a real space; strip the format characters next, because no normalization form will touch them; trim last, to take off the space that step one manufactured.

const blank = (s) => s.normalize("NFKC").replace(/[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF\uFFF9-\uFFFB]/gu, "").trim().length === 0;

import unicodedata, re
CF = re.compile("[\u00AD\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF\uFFF9-\uFFFB]")
def blank(s): return CF.sub("", unicodedata.normalize("NFKC", s)).strip() == ""

Neither snippet touches U+3164, U+1160, U+115F or U+FFA0, because those are letters and no whitespace or format filter will ever see them. Add them to the class if your data comes from anywhere near a blank-name generator. Leave U+200C, U+200D and U+FE0F out of it entirely unless the text has no emoji and no Arabic, Persian or Devanagari — removing the joiner is what splits a family emoji into separate people.

What this page does not tell you

Nothing here tells you which character a particular site’s name field will accept. That is that site’s validator, it changes without notice, and the only way to know is to try it — a page that hands you “the” character for a blank name is guessing on your behalf. If you want the characters themselves rather than an explanation of them, the list of invisible Unicode characters has copy buttons and says which ones survive which validators.

The numbers are from the runtimes on this machine, and one detail is worth carrying away: the Python here carries Unicode 15.1.0 while Node here carries Unicode 17.0. Two runtimes, two versions of the standard, potentially two answers to the same normalization question. Run the table on your own stack before you build anything that has to agree with someone else’s.