rubysherpas/paranoia · error · ActiveRecord::ReadOnlyRecord

#{self.class} is marked as readonly

Error message

#{self.class} is marked as readonly

What it means

Paranoia redefines destroy as a soft delete: paranoia_delete writes the deleted_at column via update_columns(paranoia_destroy_attributes). Because that is a real UPDATE, the method first checks readonly? and raises ActiveRecord::ReadOnlyRecord with "#{self.class} is marked as readonly" — the same failure Rails itself raises when saving readonly records — so the write never starts. A record is readonly when it was marked with readonly! or was materialized from a multi-table query (eager_load, or includes plus references) whose rows ActiveRecord protects from being saved.

Source

Thrown at lib/paranoia.rb:110

    paranoia_destroy ||
      raise(ActiveRecord::RecordNotDestroyed.new("Failed to destroy the record", self))
  end

  def trigger_transactional_callbacks?
    super || @_trigger_destroy_callback && paranoia_destroyed? ||
      @_trigger_restore_callback && !paranoia_destroyed?
  end

  def transaction_include_any_action?(actions)
    super || actions.any? do |action|
      if action == :restore
        paranoia_after_restore_commit && @_trigger_restore_callback
      end
    end
  end

  def paranoia_delete
    raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly?
    if persisted?
      # if a transaction exists, add the record so that after_commit
      # callbacks can be run
      add_to_transaction
      update_columns(paranoia_destroy_attributes)
    elsif !frozen?
      assign_attributes(paranoia_destroy_attributes)
    end
    self
  end
  alias_method :delete, :paranoia_delete

  def restore!(opts = {})
    self.class.transaction do
      run_callbacks(:restore) do
        recovery_window_range = get_recovery_window_range(opts)
        # Fixes a bug where the build would error because attributes were frozen.
        # This only happened on Rails versions earlier than 4.1.

View on GitHub (pinned to a950fe4981)

Solutions

  1. Re-fetch a writable instance from the model's own table at the delete site: record = record.class.find(record.id); record.destroy — or call record.reload, which refreshes attributes from the base table and clears the readonly flag.
  2. If the object came from includes(:assoc).references(:assoc) or eager_load, load it without the LEFT JOIN for the delete path (plain Model.find / Model.where, or preload instead of references) so the instance is writable.
  3. If the readonly flag was set intentionally but this record must be soft-deleted, clear it first: record.readonly!(false) (Rails 5.2+) then record.destroy.
  4. Audit the code path for explicit readonly! / .readonly calls and remove them where soft delete is the intended behavior.

Example fix

# before — record came from a LEFT OUTER JOIN query, readonly? == true
post = Post.includes(:author).references(:author).where(authors: { name: 'Jo' }).first
post.destroy  # ActiveRecord::ReadOnlyRecord: Post is marked as readonly

# after — reload from posts table first, then soft-delete
post = Post.includes(:author).references(:author).where(authors: { name: 'Jo' }).first
post.reload
post.destroy  # paranoia_delete runs, sets deleted_at
Defensive patterns

Strategy: validation

Validate before calling

record = Post.eager_load(:author).find(params[:id])
record = record.class.find(record.id) if record.readonly?  # writable copy before any destroy
record.destroy

Type guard

def soft_deletable?(record)
  record.is_a?(ActiveRecord::Base) && record.persisted? && !record.readonly? && !record.frozen?
end

Try / catch

begin
  record.destroy
rescue ActiveRecord::ReadOnlyRecord
  raise if record.frozen? || !record.persisted?
  record = record.class.find(record.id)  # fresh instance from the base table
  record.destroy                          # retry once, then let errors propagate
end

Prevention

When it happens

Trigger: Calling record.destroy, record.paranoia_destroy, record.paranoia_destroy!, or record.paranoia_delete! (all funnel into paranoia_delete at lib/paranoia.rb:110) on a record where readonly? is true: a record loaded through Post.eager_load(:author).first or Post.includes(:author).references(:author).where(...).first (LEFT OUTER JOIN rows are readonly), or a record explicitly marked with record.readonly! / a .readonly scope. The readonly check runs at the top of paranoia_delete, before the persisted?/frozen? branches, so it fires regardless of record state.

Common situations: Adding acts_as_paranoid to a model whose destroy paths receive objects loaded by admin/reporting screens that eager_load or includes+references associations for display; deliberately guarding records with readonly! (audit code, demo seeds) and later soft-deleting them; test fixtures or factories marked readonly; Rails version upgrades changing which query shapes mark records readonly.

Related errors


AI-assisted analysis of rubysherpas/paranoia@a950fe4981 (2026-08-23). Data as JSON: /api/errors/7be9b1f0a12508fd. Report an issue: GitHub.