Solving: accepts_nested_attributes_for Breaks Scoped Uniqueness Validation

railsactiverecordtroubleshootingvalidation

If you’ve used accepts_nested_attributes_for with a uniqueness validation scoped to the parent, you may have encountered a frustrating bug: duplicate records slip through validation. This is a long-standing Rails issue that affects many developers.

The Problem

Consider this common setup:

class Country < ActiveRecord::Base
  has_many :cities, dependent: :destroy
  accepts_nested_attributes_for :cities
end

class City < ActiveRecord::Base
  belongs_to :country
  validates :language, uniqueness: { scope: :country_id }
end

Now try to create a country with duplicate cities:

country = Country.new(
  name: "USA",
  cities: [
    City.new(language: "en-US", name: "New York"),
    City.new(language: "en-US", name: "Los Angeles")  # Same language!
  ]
)

country.save!  # This succeeds when it shouldn't!

Both cities are saved, despite having the same language within the same country_id. The uniqueness validation didn’t catch the duplicate.

Why This Happens

When creating nested records through accepts_nested_attributes_for, Rails validates children before the parent record is persisted. At validation time:

  1. The parent (Country) doesn’t have an ID yet
  2. The country_id on each City is nil
  3. The uniqueness validator checks: “Is there another city with language: 'en-US' AND country_id: nil?”
  4. The database query finds nothing (there’s no committed record yet)
  5. Validation passes for both cities

The validation runs in-memory, but the uniqueness check queries the database where the records don’t exist yet.

Solutions

Add a unique index at the database level:

class AddUniquenessConstraintToCities < ActiveRecord::Migration[7.0]
  def change
    add_index :cities, [:country_id, :language], unique: true
  end
end

This catches duplicates at insert time and raises ActiveRecord::RecordNotUnique. You’ll want to handle this exception:

class CountriesController < ApplicationController
  def create
    @country = Country.new(country_params)
    @country.save!
  rescue ActiveRecord::RecordNotUnique
    @country.errors.add(:cities, "contain duplicate languages")
    render :new, status: :unprocessable_entity
  end
end

Option 2: Custom validation

Validate uniqueness within the in-memory collection:

class Country < ActiveRecord::Base
  has_many :cities, dependent: :destroy
  accepts_nested_attributes_for :cities

  validate :cities_have_unique_languages

  private

  def cities_have_unique_languages
    languages = cities.reject(&:marked_for_destruction?).map(&:language)

    if languages.length != languages.uniq.length
      errors.add(:cities, "must have unique languages")
    end
  end
end

Option 3: Validate on the association

Use validates_associated with a custom validator:

class City < ActiveRecord::Base
  belongs_to :country
  validates :language, uniqueness: { scope: :country_id }, on: :update

  validate :unique_language_in_siblings, on: :create

  private

  def unique_language_in_siblings
    return unless country

    siblings = country.cities.reject { |c| c.equal?(self) }
    if siblings.any? { |c| c.language == language }
      errors.add(:language, "has already been taken")
    end
  end
end

Option 4: Hacky but works

A workaround from the GitHub issue discussion uses the if proc to assign the foreign key early:

class City < ActiveRecord::Base
  belongs_to :country

  validates :language,
    uniqueness: { scope: :country_id },
    if: -> {
      self.country_id = country&.id if country.present? && country_id.nil?
      true
    }
end

This forces the country_id assignment before validation runs. It’s not pretty, but it works.

Prevention

  1. Always add database-level unique constraints for uniqueness validations, especially scoped ones
  2. Test with nested attributes: Write tests that attempt to create duplicate nested records
  3. Consider using validates_with for complex cross-record validations

References

  • GitHub Issue #20676 - Open since 2015, 76+ comments
  • This affects Rails 4.x through 7.x and likely 8.x as well