Rails: Restrict deletion of a parent record using scoped associations

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.

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 :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
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.

Why before_destroy instead of dependent: :destroy on the unscoped association

You might be tempted to write:

has_many :subscriptions, dependent: :destroy   # WRONG in this pattern

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.

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.

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 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.
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-05-08 13:19)