ruby 74 lines · 3 tabs

Normalize Tags at Write Time

Shared by codesnips Jan 2026
3 tabs
class CreateTags < ActiveRecord::Migration[7.0]
  def change
    create_table :tags do |t|
      t.string :name, null: false
      t.integer :taggings_count, null: false, default: 0
      t.timestamps
    end

    execute <<~SQL
      CREATE UNIQUE INDEX index_tags_on_lower_name
      ON tags (LOWER(name));
    SQL

    create_table :taggings do |t|
      t.references :post, null: false, foreign_key: true
      t.references :tag, null: false, foreign_key: true
      t.timestamps
    end

    add_index :taggings, [:post_id, :tag_id], unique: true
  end
end
3 files · ruby Explain with highlit

This snippet shows a common Rails pattern for keeping a free-form tag input clean by normalizing tags at write time rather than at read time. The core idea is that user-supplied tag strings are messy — mixed case, stray whitespace, duplicates, empty tokens — and normalizing them once on save keeps the stored data canonical so that queries, uniqueness constraints, and counts all behave predictably.

The create_tags migration establishes the storage. A tags table holds a canonical name with a case-insensitive unique index built via LOWER(name), which guarantees that Ruby and ruby can never both exist. A taggings join table wires posts to tags with a compound unique index so the same tag cannot be attached twice to one record. Enforcing invariants in the database, not just in Ruby, is what makes the normalization trustworthy under concurrency.

In Tag model, the class method Tag.canonicalize centralizes the string cleanup: it strips, collapses internal whitespace, and downcases. Tag.for_name uses this together with find_or_create_by so lookups and inserts always go through the same canonical form. before_validation calling canonicalize! ensures even direct writes are normalized before validation runs.

Post model exposes the write-time behavior developers actually touch. The virtual attribute tag_list= accepts a comma-separated string, splits it, canonicalizes each token, and rejects blanks with reject(&:blank?). The uniq call deduplicates after normalization, so Rails, rails , RAILS collapses to a single tag. The before_save hook sync_tags resolves each name to a Tag via for_name and assigns the association, letting Rails manage the join rows.

A key trade-off is that write-time normalization pushes cost onto saves and makes the raw input unrecoverable — the original casing is discarded by design. The pitfall to watch is a race where two requests create the same tag simultaneously; the LOWER(name) unique index turns that into a catchable RecordNotUnique rather than duplicate rows. This approach is worth reaching for whenever tags are queried far more often than they are written, since it moves the messy work out of the hot read path.


Related snips

Share this code

Here's the card — post it anywhere.

Normalize Tags at Write Time — share card
Link copied