Rails: Restrict deletion of a parent record using scoped associations

Updated . Posted . Visible to the public.

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.

class Account < ApplicationRecord
  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

  has_many :subscriptions, dependent: :destroy
end

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.

account = Account.create!(name: 'Globex')

account.subscriptions.create!(status: :active)
account.subscriptions.create!(status: :canceled, canceled_at: 2.days.ago)
account.subscriptions.create!(status: :expired)

With an active subscription present, the first restrict check fires and aborts before the others run:

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:

account.active_subscriptions.destroy_all
account.recently_canceled_subscriptions.destroy_all

account.destroy!
# => deletes the account and its remaining (expired) subscription

Declaration order is the whole trick

Declaring the unscoped association first breaks it:

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:

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.

class Organization < ApplicationRecord
  has_many :accounts, dependent: :destroy
end
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 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
Last edit
Felix Eschey
License
Source code in this card is licensed under the MIT License.
Posted by Felix Eschey to makandra dev (2026-05-08 13:19)