Solving: accepts_nested_attributes_for Breaks Scoped Uniqueness Validation
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:
- The parent (
Country) doesn’t have an ID yet - The
country_idon eachCityisnil - The uniqueness validator checks: “Is there another city with
language: 'en-US'ANDcountry_id: nil?” - The database query finds nothing (there’s no committed record yet)
- 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
Option 1: Database constraint (recommended)
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
- Always add database-level unique constraints for uniqueness validations, especially scoped ones
- Test with nested attributes: Write tests that attempt to create duplicate nested records
- Consider using
validates_withfor 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