What a Palindrome Checker Has to Decide Before It Can Answer
Published 6/2/2025 · 12 min read · Text & language tools
Daniel Okonkwo — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 5 sources
"Is this a palindrome" has no answer until four normalisation choices are made, and each one changes the verdict on real sentences. Run in Node: "A man, a plan, a canal: Panama" is not a palindrome as typed, nor with case folded, nor with whitespace removed — it becomes one only once punctuation is dropped, giving amanaplanacanalpanama. The Italian "I topi non avevano nipoti" needs case and whitespace but no punctuation rule. The German "Ein Esel lese nie" is already a palindrome with case folding alone, because its spaces happen to fall symmetrically. And the French "Ésope reste ici et se repose", the Spanish "Dábale arroz a la zorra el abad" and the Portuguese "Socorram-me, subi no ônibus em Marrocos" all need a fourth rule, diacritic folding, because É against e and á against a are different characters. The second half of the problem is that reversing a string is itself undefined. Reversing by UTF-16 code unit splits surrogate pairs and produces text that is not well-formed; reversing by code point detaches a combining accent from its letter, turning a decomposed Grüße into eß̈urG. Reverse by grapheme cluster, with Intl.Segmenter, and state the four policies.
Case, whitespace, punctuation and diacritics: four policies, six real sentences, and the answer changes with each one. Then the harder half — reversing a string is itself undefined, and code-unit reversal breaks emoji and detaches accents, demonstrated in Node.
The question is undefined until four choices are made
A palindrome reads the same forwards and backwards. That definition is complete only for a string of bare lowercase letters, and almost no real text is one. The moment a sentence has capitals, spaces, commas or accents, the question splits into four smaller ones: does an uppercase letter equal its lowercase form, do spaces count as characters, do punctuation marks count, and is an accented letter the same letter as its unaccented base? Four yes-or-no answers, sixteen combinations, and the ones that matter form a nested sequence, each admitting more sentences than the last.
The table above is that sequence applied to six sentences, run rather than argued. Read it column by column and every column earns its place: whitespace alone promotes the Italian sentence, punctuation alone promotes the English one, and diacritic folding alone promotes the French, Spanish and Portuguese ones. A checker that hard-codes any one of these choices is answering a different question from the one its user asked, and answering it silently.
Four sentences, four different stopping points
Start with the English one, because it is the sentence everybody quotes. "A man, a plan, a canal: Panama" is not a palindrome as typed — the capital A at one end faces a lowercase a at the other. Fold the case and it still is not one: the spaces do not fall symmetrically. Remove the whitespace and it still is not one, because a comma and a colon remain. Remove the punctuation as well and it finally is: amanaplanacanalpanama, twenty-one characters, symmetric. Three policies had to be switched on before the famous answer appeared.
The German "Ein Esel lese nie" stops one step earlier than everything else in the table. Fold the case and it is already a palindrome: einesellesenie reads the same both ways, and so does the sentence with its spaces left in place, because its word lengths — three, four, four, three — are symmetric by construction. A checker that always strips whitespace gets the right answer here, but for the wrong reason, and it will never be able to tell you that this sentence is a palindrome in the stronger sense that its spaces are too. The other German example, "O, Genie, der Herr ehre dein Ego!", needs the punctuation rule and nothing more.
The three Romance sentences all need the fourth rule and fail every earlier one. "Ésope reste ici et se repose" with punctuation stripped but accents kept gives ésoperesteicietserepose, whose reverse is esoperesteicietsereposé — the accent has moved from the first character to the last, and the strings differ. Fold the diacritic and both become esoperesteicietserepose. The same happens with dábale…, where the reverse ends in á, and with socorram-me…, where the ô lands in the wrong place. Note what this means in practice: a checker built for English will report three perfectly good European palindromes as failures, and will do it without any error message.
Reversing a string is not one operation either
Every palindrome checker compares a text with its reverse, and the reverse is where the second half of the problem lives. The one-line reversal everybody writes — s.split("").reverse().join("") — reverses UTF-16 code units. A companion article in this series covers what a character is; the consequence here is that any character outside the Basic Multilingual Plane is stored as two code units, and reversing them individually swaps the pair. Reverse "ça va 😀" that way and the result contains a low surrogate followed by a high surrogate: eight code points instead of seven, and isWellFormed() returns false. On screen it is a replacement box.
The usual fix is to reverse code points instead: [...s].reverse().join(""). It repairs the emoji — "ça va 😀" becomes "😀 av aç", still seven code points — and it is still not enough. Take "José" in decomposed form, where the é is an e followed by a combining acute accent: five code points, four graphemes. Reversing the code points puts the combining accent first, where it has no letter to attach to, and leaves a bare e: the result is five graphemes where there were four, and the accent renders on nothing. The German case is more vivid still. Decompose "Grüße" and reverse its code points and you get eß̈urG: the umlaut has jumped off the u and landed on the ß.
Emoji built from several code points fail the same way. The family 👨👩👧 is three people joined by zero-width joiners: sixteen code units, thirteen code points, one grapheme. Reversing its code points produces a family that renders as 👧👩👨 — daughter, mother, father — which is a different emoji, not a reversed one, and reversing its code units produces something that renders as fragments. Only reversing by grapheme cluster leaves it untouched.
Reverse by grapheme cluster
A grapheme cluster is Unicode's name for what a reader calls a character: a base letter plus whatever combines onto it, or a whole emoji sequence. JavaScript exposes the segmentation rules directly through Intl.Segmenter, and a correct reversal is three lines: build a segmenter with granularity "grapheme", spread its output, reverse, join. On every example above it does the right thing — the emoji survives, the family stays a family, and the decomposed Grüße comes back as eßürG, with the umlaut still on its u.
There is a normalisation decision hiding underneath, and the French word été shows it in one line. In composed form it is three code points and reversing them gives été back: a palindrome. In decomposed form it is five code points, reversing them gives a string with the accents in the wrong places, and the same visible word is reported as not a palindrome. Reversing by grapheme cluster gives the right answer in both forms — but if you compare the two forms to each other they are still unequal, which is why a checker should normalise to NFC before it does anything else. The article on accents in this series covers the NFC and NFD forms in full.
A number palindrome is a different problem
Numbers look like the easy case and are not. A number palindrome is a property of a value in a base, and a text palindrome is a property of a rendering. 12321 is a palindrome in base ten. Rendered for a reader it stops being one in every locale in this article: 12,321 reversed is 123,21 in English, 12.321 reversed is 123.21 in German, Spanish and Italian, and 12 321 reversed is 123 21 in French and Portuguese — where the separator is a narrow no-break space U+202F in French and a plain non-breaking space U+00A0 in Portuguese, two invisible characters that a naive comparison will treat as different anyway.
Three more differences follow from the same distinction. A sign is a character but not a digit: -121 as text reverses to 121- and fails, while as a value the question of whether a negative number can be a palindrome is a convention you have to pick. A decimal point is likewise a character: 0.1 reverses to 1.0, two different strings that denote different values. And leading zeros exist in text and not in numbers: 010 is a palindrome as text, and as a number it is 10, which is not. If your input is a number, strip the formatting and work on the digits; if your input is text that happens to look numeric, say so and treat it as text.
What to build, in order
The recipe that survives all six languages is short. Normalise to NFC first, so that the same visible text always has the same representation. Then apply the policies you have decided on and can name: lowercase, drop whitespace, drop everything that is not a letter or a digit, fold diacritics by decomposing and removing the marks. Then reverse by grapheme cluster and compare. Four steps, and every one of them is a choice you can put in the interface rather than a default you hide in the code.
One last observation about the reversal, because it is counter-intuitive and it hides bugs. Reversing twice always returns the original string, whichever unit you reverse by — code units, code points or graphemes are all involutions on the sequence they operate on. So a round-trip test tells you nothing about whether your reversal is correct. The only test that catches the broken cases is looking at the intermediate result, and the fastest way to look at it is to count graphemes before and after: for a decomposed José they are four and five.
| Sentence | Case folded only | + whitespace ignored | + punctuation ignored | + diacritics folded |
|---|---|---|---|---|
| A man, a plan, a canal: Panama | No | No | Yes | Yes |
| I topi non avevano nipoti | No | Yes | Yes | Yes |
| Ein Esel lese nie | Yes | Yes | Yes | Yes |
| Ésope reste ici et se repose | No | No | No | Yes |
| Dábale arroz a la zorra el abad | No | No | No | Yes |
| Socorram-me, subi no ônibus em Marrocos | No | No | No | Yes |
Frequently asked questions
- Is "A man, a plan, a canal: Panama" a palindrome or not?
- Under the usual policy, yes; as typed, no. Fold the case, drop the spaces and drop the punctuation and it becomes amanaplanacanalpanama, which is symmetric. Keep any one of those three and it is not. That is not a trick question — it is what the phrase "ignoring case, spaces and punctuation" in every textbook definition is doing, and a tool that lets you switch the three rules independently is telling you the truth rather than hiding it.
- Why did reversing my text produce a broken square?
- Because the reversal worked on UTF-16 code units and your text contained a character outside the Basic Multilingual Plane — an emoji, a rare CJK ideograph, a mathematical symbol. Those are stored as a surrogate pair, and reversing the two halves produces a sequence no font can render. Check it with isWellFormed(), which returns false on such a string, and switch the reversal to grapheme clusters with Intl.Segmenter.
- Should a palindrome checker ignore accents?
- It should offer the choice and default to folding them, because the tradition in French, Spanish and Portuguese wordplay treats é and e as the same letter. Without folding, three of the six sentences in the table above stop being palindromes: Ésope reste ici et se repose, Dábale arroz a la zorra el abad and Socorram-me, subi no ônibus em Marrocos all fail on their first and last characters. Fold by decomposing to NFD, deleting the combining marks, and recomposing — the accents article in this series covers why that is not the same as replacing letters one by one.
- Is 12321 a palindrome?
- As a number in base ten, yes. As displayed text, no — in every locale in this article. Formatted for a reader it becomes 12,321 in English and 12.321 or 12 321 elsewhere, and reversing those strings gives 123,21, 123.21 and 123 21. If you are checking numbers, work on the digits and decide explicitly what to do with a sign, a decimal separator and leading zeros: -121 reverses to 121-, 0.1 reverses to 1.0, and 010 is a palindrome as text while the number it denotes is 10.
- Does reversing a string twice always give the original back?
- Yes, and that is exactly why a round-trip test is worthless here. Reversing by code unit, by code point or by grapheme cluster are all involutions: apply any of them twice and you get the input back, even when the single reversal produced a broken string in between. Test the intermediate result instead — compare the grapheme count before and after, or call isWellFormed() on it.
- Can a single word be a palindrome without any of these rules?
- Yes, and those are the only cases where the question has one answer. All-lowercase ASCII words such as racecar, radar, kayak, the French ressasser, the Spanish reconocer, the Italian onorarono and the Portuguese reviver are palindromes with no policy at all — verified character by character. Capitalise them and you need the case rule: the German Reliefpfeiler, Rentner and Lagerregal are palindromes only once the initial capital is folded, which is one policy and not four.
Articles you may find interesting
All guides →Related tools
Sources
- Unicode Consortium — Unicode Standard Annex #29: Unicode Text Segmentation — grapheme cluster boundaries
- Unicode Consortium — Unicode Standard Annex #15: Unicode Normalization Forms (NFC, NFD, NFKC, NFKD)
- Unicode Consortium — Unicode Technical Standard #51: Unicode Emoji — emoji ZWJ sequences
- Ecma International — ECMAScript Internationalization API Specification (ECMA-402) — Intl.Segmenter
- MDN Web Docs — String.prototype.normalize() and Intl.Segmenter
Spotted a mistake in this article?