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
- 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.
- 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.
- 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.
- 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
- Never destroy records loaded by eager_load or includes.references — re-fetch by primary key (Model.find(id)) at the write site.
- Check record.readonly? in service objects or before_actions guarding destroy endpoints on acts_as_paranoid models.
- Prefer preload over includes+references when you do not need WHERE conditions on the joined table, so display queries return writable records.
- In specs, assert refute record.readonly? before exercising soft-delete paths.
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.