Updated: Rails: Restrict deletion of a parent record using scoped associations

Posted . Visible to the public. Auto-destruct in 60 days

Added an example and removed the redundant before_destroy.

Changes

  • -When deletion of a parent record should be blocked by one or more conditions on the same child table, declare a `has_many` association per condition with its own scope and `dependent: :restrict_with_error`.
  • +When deletion of a parent record should be blocked by one or more conditions on the same child table, declare a `has_many` association per condition with its own scope and `dependent: :restrict_with_error`, then declare the plain `dependent: :destroy` association last.
  • ## The pattern
  • The example below uses two scoped associations to keep things readable, but the same approach works with one, three, or any number of independent restrictions.
  • ```ruby
  • class Account < ApplicationRecord
  • - has_many :subscriptions
  • -
  • has_many :active_subscriptions,
  • -> { where(status: :active) },
  • class_name: 'Subscription',
  • dependent: :restrict_with_error
  • has_many :recently_canceled_subscriptions,
  • -> { where(status: :canceled, canceled_at: 30.days.ago..) },
  • class_name: 'Subscription',
  • dependent: :restrict_with_error
  • -before_destroy do
  • -subscriptions.destroy_all
  • - end
  • +has_many :subscriptions, dependent: :destroy
  • end
  • ```
  • -When `account.destroy` runs, Rails fires every `restrict_with_error` check first. If any scope returns at least one row, an error is added to the parent and destruction is aborted. If all scopes are empty, the `before_destroy` callback removes the remaining (unrestricted) child rows.
  • +`dependent: :restrict_with_error` and `dependent: :destroy` both install `before_destroy` callbacks, and Rails fires them in declaration order. The restricting associations are declared first, so their checks run while the table is still intact. Only once every check passes does `dependent: :destroy` clear the rest.
  • +
  • +```ruby
  • +account = Account.create!(name: 'Globex')
  • -## Why `before_destroy` instead of `dependent: :destroy` on the unscoped association
  • +account.subscriptions.create!(status: :active)
  • +account.subscriptions.create!(status: :canceled, canceled_at: 2.days.ago)
  • +account.subscriptions.create!(status: :expired)
  • +```
  • -You might be tempted to write:
  • +With an active subscription present, the first restrict check fires and aborts before the others run:
  • ```ruby
  • -has_many :subscriptions, dependent: :destroy # WRONG in this pattern
  • +account.destroy!
  • +# => raises ActiveRecord::RecordNotDestroyed: Failed to destroy the record
  • +
  • +account.errors[:base]
  • +# => ["Cannot delete record because dependent active subscriptions exist"]
  • +```
  • +
  • +Once nothing restricts the deletion, `dependent: :destroy` removes whatever is left:
  • +
  • +```ruby
  • +account.active_subscriptions.destroy_all
  • +account.recently_canceled_subscriptions.destroy_all
  • +
  • +account.destroy!
  • +# => deletes the account and its remaining (expired) subscription
  • ```
  • -But `dependent: :destroy` and `dependent: :restrict_with_error` both install `before_destroy` callbacks, and Rails fires them in declaration order. If `:destroy` runs first, it wipes out the very rows the `:restrict_with_error` scopes were supposed to find. The restriction silently passes, and the parent is deleted.
  • +## Declaration order is the whole trick
  • -Putting cleanup into an explicit `before_destroy` block at the end of the class guarantees that the restrict checks run first against an unmodified table, and cleanup only happens once they have all passed.
  • +Declaring the unscoped association first breaks it:
  • +
  • +```ruby
  • +has_many :subscriptions, dependent: :destroy # declared first: WRONG
  • +```
  • +
  • +Its callback fires before the restrict checks and deletes the very rows they were meant to find. Every check then runs against an empty table, passes, and the parent is deleted. Keep the restricting associations above the `dependent: :destroy`.
  • +
  • +## One catch: restrictions through a join
  • +
  • +This only works when the restricting association can be declared first. A `has_many :through` cannot: Rails raises `ActiveRecord::HasManyThroughOrderError` unless the through association is declared after the association it rides on. That association carries the `dependent: :destroy`, so it clears the join rows before the restrict check sees them. Restrict over the join table's own foreign key instead of going `through:` it:
  • +
  • +```ruby
  • +has_many :unbilled_line_items,
  • + -> { joins(:charge).where(charges: { billed: false }) },
  • + class_name: 'LineItem',
  • + inverse_of: :account,
  • + dependent: :restrict_with_error
  • +
  • +has_many :line_items, dependent: :destroy
  • +```
  • ## Propagating the restriction up to a grandparent
  • If a grandparent (e.g. `Organization`) owns the parent through `dependent: :destroy`, the restriction propagates automatically. When the grandparent is destroyed, Rails calls `destroy` on each child. The child's `restrict_with_error` adds an error and aborts. The aborted child rolls back the surrounding transaction, and the grandparent is preserved.
  • ```ruby
  • class Organization < ApplicationRecord
  • has_many :accounts, dependent: :destroy
  • end
  • ```
  • ```ruby
  • organization = organizations(:big_co)
  • account = organization.accounts.create!(...)
  • account.subscriptions.create!(status: :active)
  • organization.destroy
  • assert Organization.exists?(organization.id)
  • assert_includes organization.accounts.first.errors[:base], 'Cannot delete record because dependent active subscriptions exist'
  • ```
  • The grandparent does not need its own `restrict_with_error` declaration. Forwarding through `dependent: :destroy` is sufficient, because the child's abort halts the whole destroy chain.
  • Two things to watch out for:
  • 1. The grandparent must use `dependent: :destroy`, not `dependent: :delete_all`. The latter issues a single `DELETE` and skips child callbacks entirely, so the restriction never runs.
  • -2. If the grandparent has a `before_destroy` of its own that wipes child rows ahead of the dependent chain (the same anti-pattern as before), the restriction is bypassed at the grandparent level too.
  • +2. If the grandparent runs a `before_destroy` of its own that clears child rows ahead of the dependent chain, the restriction is bypassed at the grandparent level too.
Profile picture of Felix Eschey
Felix Eschey
License
Source code in this card is licensed under the MIT License.
Posted by Felix Eschey to makandra dev (2026-06-09 07:18)