# Rails titleize Breaks Names, Hyphens, and Apostrophes

> titleize splits McAllen, drops hyphens, skips capitals after apostrophes, and chops ALL-CAPS words around acronyms. Here's a safer way to recase text.

- Author: Curtis Miller
- Published: 2026-10-09
- Categories: rails, ruby, gotchas
- Canonical URL: https://millarian.com/posts/rails-titleize-breaks-names/

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:

```ruby
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`](https://api.rubyonrails.org/classes/ActiveModel/Attributes/Normalization/ClassMethods.html#method-i-normalizes) declaration, available since Rails 7.1, but a setter or a `before_validation` callback does the same thing):

```ruby
class Customer < ApplicationRecord
  normalizes :last_name, :street, :city, with: ->(value) { value.strip.titleize }
end
```

It 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](https://api.rubyonrails.org/classes/String.html#method-i-titleize) 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.

```ruby
# 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 `21ST` becomes `21st` instead of `21St`.
- The second branch handles a one-letter prefix before an apostrophe (`O'Fallon`, `D'Angelo`, `L'Anse`) without touching `Martha's` or `Don't`.
- Exceptions match whole words, so `NE` can't fire inside `NEW`.

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:

```ruby
class Customer < ApplicationRecord
  normalizes :last_name, :street, :city, with: ->(value) { TextCase.tidy_case(value) }
end
```

A table-driven spec doubles as documentation:

```ruby
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
end
```

## Can't I just register the name as an inflection?

For some names, yes. The [`acronym` docs](https://api.rubyonrails.org/classes/ActiveSupport/Inflector/Inflections.html#method-i-acronym) 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](https://millarian.com/posts/quick-tip-rails-singularization-of-ess/) back in 2009) and `titleize` honors it:

```ruby
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-Salem` or `O'Fallon` changes nothing: they still come out `Winston Salem` and `O'fallon`.
- **It doesn't repair what's already stored.** `Mc Allen` stays `Mc Allen`.
- **It's app-wide.** After registering `DeKalb`, `"dekalb_report".camelize` returns `"DeKalbReport"`.
- **Short entries backfire on ALL-CAPS text.** Register `NE` and `SW` for street directions and `NEW YORK AVE` becomes `NE W York Ave`, `SENECA ST` becomes `Se NE Ca St`, and `SWAN LN` becomes `SW An Ln`. Mixed-case `New York Ave` is 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.
