Identità falsa > Articoli > `ucfirst(strtolower($name))` Breaks Names, and You Can Measure It

Questo articolo non è ancora stato tradotto in Italiano: stai leggendo l'originale in English. Disponibile anche in:Deutsch, English, Українська

ucfirst(strtolower($name)) Breaks Names, and You Can Measure It

Every name pipeline eventually meets a source that shouts. National registries store names in upper case — MCKENZIE, O'BRIEN, NGUYEN VAN NAM — because the systems they grew out of had no lower case. Somewhere between that file and your user interface, somebody has to decide where the capitals go.

The one-liner everybody reaches for is:

$name = ucfirst(strtolower($name));       // PHP
name = name.title()                       // Python — same class of bug, different shape
name[0].toUpperCase() + name.slice(1).toLowerCase()   // JS

It produces Mckenzie, O'brien, Van Der Berg and Tôn-nữ. All four are wrong. Not "unusual" — wrong, in the sense that no register, style guide or bearer writes them that way.

We know because we shipped two of them. This page is about how to detect the artefact in data you already have, why the obvious fix is also wrong, and where the honest limit of the whole exercise is.

The signature: a boundary that fires zero times

The tell is not that a name looks odd. Names look odd. The tell is a boundary character that never once produces a capital in an entire file.

Here is our French given-name corpus, built from the national birth-name register. We counted every hyphen and every apostrophe inside a name and asked what kind of letter follows it:

FileAfter -: uppercaseAfter -: lowercaseAfter ': uppercaseAfter ': lowercase
fr_FR firstname_male1,3590032
fr_FR firstname_female1,5250022

2,884 hyphens, every single one followed by a capital: Jean-Pierre, Marie-Claire, Anne-Sophie. 54 apostrophes, not one followed by a capital.

That is not a linguistic fact about French. That is a machine signature. Whatever re-cased this file treated - as a word boundary and ' as an ordinary letter. A human transcriber produces a mix; a rule produces a column of zeros.

What the apostrophe entries actually contain:

M'hamed  M'barek  M'baye  N'guessan  N'diaye  N'deye  N'faly  R'kia      ← correct as-is
O'bryan  O'neal   O'brian  O'neil   O'neill   Jo'anna  Carol'ann  Lou'ann ← wrong

And that is why the defect survives review. The majority of those names — Arabic M'hamed, West African N'diaye, N'guessan — are correctly lowercase after the apostrophe. The broken ones hide among them. Anyone eyeballing the file sees a plausible mix of Maghrebi and Sahelian names and moves on.

Contrast a file that was not mechanically re-cased. Across all 64 locales we ship, here is what follows an apostrophe:

LocaleUppercase after 'Lowercase after 'Reading
en_US550O'Brien, O'Neill, D'Angelo
en_GB550
en_IE440O'Brien, O'Sullivan, O'Donnell
en_AU250
en_NZ224O'Brien and Su'a, Papali'i, Fa'alogo
it_IT120D'Angelo, Dell'Aquila
fr_FR154⚠ inverted

en_NZ is the interesting row and the one that ends the argument for a mechanical rule. It contains both conventions, both correct: Irish O'Brien capitalises after the apostrophe, and Samoan Su'a, Fa'alogo, Fepulea'i, Papali'i do not, because there the apostrophe is a glottal stop consonant (the ʻokina), not a word boundary. One file, one country, two opposite rules, no way to tell from the string.

The two we got wrong

en_IE: MckeeverMcKeever. Straightforward, found and fixed.

vi_VN: Tôn-nữTôn Nữ and Tôn-thấtTôn Thất. Fixed on 21 July 2026, and worth explaining because the argument that it is a bug — rather than a legitimate spelling variant — is structural rather than aesthetic.

Vietnamese compound surnames have had exactly two written norms:

NormWritten asSecond syllable
Modern (post-1950s)Tôn Thất, Tôn Nữcapital
Older hyphenated (pre-)Tôn-Thất, Việt-Namcapital

The second syllable of a Vietnamese proper name is capitalised in both. There has never been a norm in which it is lowercase. Tôn-nữ is therefore not an archaism, not a regional variant, and not a source spelling — it is precisely what ucfirst(strtolower("TÔN-NỮ")) emits. Two known norms, and the string matches neither: that is a stronger argument than "it looks wrong to me".

We chose the space form over Tôn-Nữ because the rest of the Vietnamese corpus already uses spaces for multi-syllable units — 49 space-separated given names in the male file, 79 in the female file, and zero hyphens anywhere in Vietnamese names. Current state: Tôn Nữ (rank 122, weight 17) and Tôn Thất (rank 147, weight 12) out of 298 surnames, no hyphens remaining.

We then swept all 64 locales for the same pattern — hyphen followed by a lowercase letter — and found three survivors:

EntryLocaleVerdict
Perangin-anginid_IDCorrect. Batak reduplication; the second element is not a separate word.
Karo-karoid_IDCorrect. Same.
Jo-anneen_NZGenuinely attested alongside Jo-Anne.

Three hits, and the automated rule would have "fixed" all three into errors.

The bigger one we have not fixed: 158 given names

Surnames in our English locales are clean. Given names are not, and the numbers say exactly where the boundary of the cleanup ran:

LocaleMc + capitalMc + lowercaseO' + capitalO' + lowercase
en_IE640430
en_GB520370
en_US570470
en_AU730250
en_NZ990220

Zero across the board — in the surname files. In the en_US given-name files there are 158 entries beginning Mc + lowercase, carrying 148,225 of 282,696,333 weight (0.052%): Mckenzie (55,094), Mckenna (36,825), Mckayla (11,415), Mckinley (9,468).

The proof that this is a pipeline artefact and not two different naming conventions is that the same strings appear in both files, with different capitalisation, in the same corpus:

As a given nameWeightAs a surnameWeight
Mckenzie1,795McKenzie58,046
Mccoy2,330McCoy107,288
Mcdonald466McDonald172,700
Mcdaniel197McDaniel84,533
Macdonald288MacDonald45,126
Dejesus141DeJesus46,493
Mckeever5McKeever7,036

Twenty such clashes. Same source population, same organisation, two different case decisions — because one file went through a curated exception list and the other went through ucfirst.

The source is upper case, and it has already thrown things away

Worth knowing before you write your re-caser: the registry you are re-casing may have destroyed more than the case.

Both US Census 2020 name products store names in upper case, and we counted what is in them:

MeasurementGiven namesSurnames
Name rows in the file53,618156,622
Rows containing an apostrophe00
O'BRIEN00
OBRIEN11
O'NEILL / ONEILL0 / 10 / 1
D'AMICO / DAMICO0 / 10 / 1

The Census Bureau does not merely upper-case names; it strips the apostrophe entirely. O'Brien is published as OBRIEN. So the 115,547-weight O'Brien in our corpus is not source data — it is a reconstruction, made by someone who knew that OBRIEN is O'Brien and not Obrien. That reconstruction is knowledge, not transformation, and no function signature can encode it.

This is the same shape as accent stripping in Spanish registries: the register normalises for its own comparability, and the normalisation is irreversible without outside knowledge.

Why ucfirst and ucwords cannot be fixed

The temptation, after seeing all this, is to write a better one: split on -, ', , capitalise each piece. That produces M'Hamed, N'Diaye, Su'A, Papali'I, Van Der Berg, De La Cruz, Al-Ahmad. You have swapped one uniform error for another.

There are four distinct problems stacked on top of each other, and only the first is about word boundaries.

1. The boundary set is not [ -]. At minimum you need -, ', (U+2019, which real data contains alongside U+0027), space, and . in initials. ucfirst handles none of them. ucwords handles space, and in PHP takes an explicit delimiter list precisely because there is no correct default.

2. Not every boundary character is a boundary. Established by the data above: the apostrophe is a boundary in O'Brien and a consonant in Su'a; the hyphen is a boundary in Jean-Pierre and internal in Karo-karo. The same character, in the same file, means two things.

3. Some elements must stay lowercase, and which ones is language-specific. Dutch van, de, van der, ten are lowercase after a given name and capitalised without one — a positional rule, not a fixed spelling. Belgian Dutch freezes whatever the civil registry recorded, so De Smet is De Smet everywhere. Arabic bin, bint, al-; Portuguese dos, da; French de, du; German von, zu — each has its own convention, and applying the Dutch one to a Belgian name or the German one to a Portuguese one produces a real error in both directions.

4. strtolower itself is wrong for some scripts. strtolower in PHP is byte-based and mangles UTF-8 (mb_strtolower is the minimum). Turkish has dotted and dotless i: İSTANBUL lower-cases to istanbul only under a Turkish locale, and to i̇stanbul otherwise. Greek final sigma changes shape by position. And in Georgian — which we ship — Mkhedruli has no case at all; a naive scan for "lowercase-initial names" flags all 1,036 Georgian entries as suspicious, and every one of them is fine.

The exception list is mandatory, and it is language-specific

You cannot avoid a list. Mc, Mac, O', Fitz, van, de, von, bin, al-, Ó, , Le, D'. Fine — except the list does not partition the data cleanly either, and our corpus contains the counter-examples:

EntryLocaleWhat a Mac/Mc rule would doReality
Mchunuen_ZAMcHunuZulu surname. Mc is not a prefix at all.
Machabaen_ZAMacHabaAlso Zulu, also not a prefix.
Maciasen_USMacIasSpanish Macías. Not a prefix.
Macken_USMacKA surname in its own right (69,776).
Mackenen_IEMacKenIrish, correctly Macken.
Mackeyen_IEMacKeyIrish, correctly Mackey.
Maconen_USMacOnNot a prefix.

So the list has to be a list of names, not of prefixes. Which means it has to be built from a source that records real spellings, which is the thing you did not have when you started.

And then there is Macdonald

The genuinely unresolvable case, and we would rather say it plainly than leave you thinking a good enough list exists.

LocaleSpellingBearers
en_USMcDonald172,700
en_USMacDonald45,126
en_GBMcDonald31,974
en_GBMacdonald21,737
en_CAMacDonald19
en_AUMacdonald4

Macdonald and MacDonald are both real surnames, held by different families, and they are not variants of one another any more than Smith and Smyth are. In our British corpus the medial-lowercase form carries 21,737 bearers; in our American corpus the medial-capital form carries 45,126. Neither is a typo of the other. There is no rule — not a locale rule, not a frequency rule, not a Scottish-versus-Irish rule — that decides which one a given person is.

The same holds for Mackenzie / MacKenzie / McKenzie, and for Vandenberghe versus Van den Berghe in Belgian data, where the fused and split forms are separate legal surnames belonging to separate families.

Any normalisation that maps these onto each other destroys real people. That is the ceiling on this entire problem: not "hard to automate", but no correct answer exists as a function of the string.

What actually works

1. Do not re-case at all if you can avoid it. If the source ships mixed case, keep it byte for byte. Every transformation is a chance to lose something and no transformation adds information.

2. If you must re-case an upper-case source, treat it as reconstruction, not formatting. Reconstruction needs evidence: a second source in mixed case, a curated list, or a human. Budget for it accordingly, and record which rows were reconstructed.

3. Audit with the boundary test, not by reading. For every boundary character, count how many times it is followed by an upper-case letter and how many by a lower-case one, per file. A column of pure zeros in either direction is a machine, not a language. This test found all three of our defects and cost a dozen lines of code.

4. Never re-case on output. If your template calls a capitalisation helper when rendering a name, you have moved the bug to the point where it is least visible and hardest to test. Normalise once, on ingest, deliberately, with the result stored.

5. Validate on renderability, not on shape. ^[A-Z] as a name validation rule rejects dela Cruz — the most common surname in the Philippines — and da Silva, both of which sit in our corpus as correctly lowercase-initial entries, and it rejects every Georgian name ever written.

6. Separate display capitalisation from stored capitalisation. A surname rendered on its own — in a sorted index, a table column, a salutation — can legitimately need different capitalisation than the same surname after a given name. That is a rendering function with a locale argument, not a property of the stored string.

Known limitations

  • We have not fixed the 158 Mc* given names in en_US. They are 0.052% of the given-name weight and correcting them means deciding, name by name, whether Mckenzie as a given name is a mis-cased McKenzie or a distinct modern spelling that parents actually register — and for given names, unlike surnames, the second reading is genuinely plausible.
  • Jo-anne in en_NZ is unresolved. Both Jo-anne and Jo-Anne are attested. We left the source spelling.
  • The fr_FR apostrophe entries are unfixed. We can identify O'bryan, O'neal and O'brian as artefacts with confidence; we cannot mechanically separate them from the 40-odd correctly lowercase Maghrebi and West African names in the same file, and a per-name decision has not been made.
  • We measured our own corpus, not the world. The -/' counts, the prefix inventories and the given-name/surname clashes are all measurements of the files we ship, taken on 21 July 2026. They are evidence about how this class of bug behaves, not a census of it.

Data as of 2026-07-21

64 locales scanned. vi_VN surnames: 298 entries, 99,441 total weight, 0 hyphens, Tôn Nữ and Tôn Thất in the corrected form; male and female files byte-identical. en_US: 2,064 surnames and 53,618 source given-name rows. US Census 2020 files counted directly. All figures recomputed for this page rather than quoted from earlier notes.

Sources

← Articoli