Rails titleize mangles names, cities, and addresses. It splits words at inner capitals, drops hyphens, lowercases the letter after an apostrophe, and breaks ordinals apart. It's still the first thing people reach for when a form or an import hands them ALL-CAPS text, because the simple case looks right:
store(dev)> "PHOENIX".titleize
=> "Phoenix"
store(dev)> "McAllen".titleize
=> "Mc Allen"
store(dev)> "Winston-Salem".titleize
=> "Winston Salem"
store(dev)> "O'Fallon".titleize
=> "O'fallon"
store(dev)> "500 W 21ST ST".titleize
=> "500 W 21 St St"The bug is using an inflector as a name formatter. The usual code is a one-liner (this one uses the normalizes declaration, available since Rails 7.1, but a setter or a before_validation callback does the same thing):
class Customer < ApplicationRecord
normalizes :last_name, :street, :city, with: ->(value) { value.strip.titleize }
endIt runs on write, so the mangled value is what gets saved. The casing the user typed is gone for good.
Why titleize removes hyphens and splits names
titleize is humanize(underscore(word)) plus a regex that capitalizes word starts. The String#titleize docs say it "replaces some characters in the string to create a nicer looking title," and the examples are identifiers like TheManWithoutAPast. Each step makes sense for identifiers and is wrong for people. These outputs are from Rails 8.1.4 (the latest release as of October 2026) on Ruby 4.0.2:
| Input | titleize returns | Why |
|---|---|---|
PHOENIX | Phoenix | Nothing to split, so it looks fine |
McAllen | Mc Allen | underscore splits CamelCase |
Winston-Salem | Winston Salem | underscore turns hyphens into underscores, humanize turns those into spaces |
O'Fallon | O'fallon | The final regex skips a letter after a word character and an apostrophe |
MARY-KATE O'NEIL | Mary Kate O'neil | Both of the above |
500 W 21ST ST | 500 W 21 St St | underscore splits a digit from a capital letter |
BOISE-ID | Boise | underscore makes it boise_id, and humanize drops a trailing _id |
The apostrophe rule leaves contractions like Don't alone, but it can't tell one from a name prefix like O'. The hyphen loss is documented in Rails itself: titleize('x-men: the last stand') returns "X Men: The Last Stand".
How acronym inflections split ALL-CAPS words
It gets worse once your app registers acronyms. A line like inflect.acronym "API" in config/initializers/inflections.rb is how api_controller becomes APIController. The rule is global, so titleize also applies it inside any ALL-CAPS word that contains those letters:
| Registered acronym | Input | titleize returns |
|---|---|---|
AI | SPAIN | Sp AI N |
API | GRAND RAPIDS | Grand R API Ds |
ID | DAVID | Dav |
US | COLUMBUS | Columb US |
Each row registers only that acronym. underscore puts separators around the match and humanize swaps in the registered spelling. ID also trips the trailing _id rule, so David loses two letters. The splitting only happens in upper case, so mixed-case David and Columbus come through fine. ALL-CAPS is the input people feed to titleize, so it's the input that breaks.
Fixing ALL-CAPS names without titleize
A single-case string carries no casing information: nobody can tell whether MCALLEN meant McAllen. A mixed-case string does, because someone typed that capital A on purpose. So the helper below recases only text that's all upper or all lower, capitalizes word starts itself instead of calling the inflector, and applies a short list of whole-word exceptions. Seed that list with the casing your data actually has.
# app/lib/text_case.rb
module TextCase
CASING_EXCEPTIONS = %w[McAllen DeKalb NE NW SE SW].index_by(&:downcase)
module_function
# Recase text only when it's all one case, so casing a person chose is never touched.
def tidy_case(text)
text = text.to_s.squish
return text unless text == text.upcase || text == text.downcase
text.downcase
.gsub(/(?<![\p{L}\p{M}\p{N}'’])\p{L}|(?<=\b\p{L}['’])\p{L}/, &:upcase)
.gsub(/[\p{L}\p{M}]+/) { |word| CASING_EXCEPTIONS.fetch(word.downcase, word) }
end
end- The first regex branch capitalizes a letter that starts a word: one not preceded by a letter, a combining mark, a digit, or an apostrophe. The digit check is why
21STbecomes21stinstead of21St. - The second branch handles a one-letter prefix before an apostrophe (
O'Fallon,D'Angelo,L'Anse) without touchingMartha'sorDon't. - Exceptions match whole words, so
NEcan't fire insideNEW.
It never calls the inflector, so a registered AI acronym leaves SPAIN as Spain. Whitespace is squished either way, and mixed-case input is otherwise returned untouched.
Wire it in through normalizes. That declaration can apply the block more than once, so the block has to be idempotent, and this one is:
class Customer < ApplicationRecord
normalizes :last_name, :street, :city, with: ->(value) { TextCase.tidy_case(value) }
endA table-driven spec doubles as documentation:
RSpec.describe TextCase do
{
"PHOENIX" => "Phoenix",
"WINSTON-SALEM" => "Winston-Salem",
"MARY-KATE O'NEIL" => "Mary-Kate O'Neil",
"D'ANGELO" => "D'Angelo",
"MARTHA'S VINEYARD" => "Martha's Vineyard",
"500 W 21ST ST" => "500 W 21st St",
"1234 NE 5TH ST" => "1234 NE 5th St",
"NEW YORK AVE" => "New York Ave",
"MCALLEN" => "McAllen",
" st. louis " => "St. Louis",
"McAllen" => "McAllen",
"Coeur d'Alene" => "Coeur d'Alene",
"Ne Portland Blvd" => "Ne Portland Blvd",
nil => ""
}.each do |input, expected|
it "turns #{input.inspect} into #{expected.inspect}" do
expect(described_class.tidy_case(input)).to eq(expected)
end
end
endCan't I just register the name as an inflection?
For some names, yes. The acronym docs include acronym 'McDonald' for words that need non-standard capitalization. Register one in config/initializers/inflections.rb (the file where I added a custom singularization rule back in 2009) and titleize honors it:
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "McAllen"
end
"McAllen".titleize # => "McAllen"
"MCALLEN".titleize # => "McAllen"
"mcallen".titleize # => "McAllen"McDowell and DeKalb work the same way. The limits are real:
- Hyphens and apostrophes stay broken. Registering
Winston-SalemorO'Fallonchanges nothing: they still come outWinston SalemandO'fallon. - It doesn't repair what's already stored.
Mc AllenstaysMc Allen. - It's app-wide. After registering
DeKalb,"dekalb_report".camelizereturns"DeKalbReport". - Short entries backfire on ALL-CAPS text. Register
NEandSWfor street directions andNEW YORK AVEbecomesNE W York Ave,SENECA STbecomesSe NE Ca St, andSWAN LNbecomesSW An Ln. Mixed-caseNew York Aveis fine.
That last one is why the helper's exceptions match whole words. Keep the list in your own helper, not in global inflections.
Takeaway
The helper has limits. The list only knows what you put in it, so COEUR D'ALENE comes out Coeur D'Alene although the official name has a lowercase d. Letters after a digit stay lowercase so ordinals work, which turns SUITE 1A into Suite 1a. Its promise is narrower than what people expect from titleize: it never damages casing a person typed, and it makes single-case text readable.
Normalizing on write destroys the original, so the rule has to be one that can't hurt good input. Only recase text that's all one case, capitalize word starts yourself instead of calling titleize, and leave mixed-case input alone, because a person chose that casing. If you can't write a rule you trust, store what the user typed and format it on display, where a bad rule costs a render instead of data.