Why a password_digest Column Breaks Devise's inspect
Devise's password_digest(password) shadows a leftover has_secure_password column. Staging looked fine only because pry swallowed the exception
If your Rails app uses Devise and printing a user in the console gives you An error occurred when inspecting the object, or user.as_json dies with ArgumentError, this is for you. By the end you can find the culprit column with one snippet, and fix it without starting to leak your API token.
Short version: if the table has a password_digest column, Devise's own method of the same name, password_digest(password), shadows the column reader. Devise's inspect reads every attribute with send, calls it with no arguments, and blows up.
It only blew up in the production console
On a work project (Rails 7.1, Devise 4.9) I looked up one user in the production console and got this.
irb(main):001> user = User.find_by(email: 'someone@example.com')
An error occurred when inspecting the object: #<ArgumentError: wrong number of arguments (given 0, expected 1)>
Result of Kernel#inspect: #<User:0x00007f528b334d40 @new_record=false, @attributes=...
irb(main):002> user.email
=> "someone@example.com"
The data is fine. Only the display died. Do the same thing on staging and there's no error.
[1] pry(main)> user = User.find_by(email: 'admin@example.com')
=> #<User:0x6978>
So I started by digging for something odd in the production data. Wrong starting point. It wasn't an environment difference. It was a REPL difference.
Staging looked fine because pry swallowed it
The clue was #<User:0x6978> itself. A working ActiveRecord inspect shows attributes, like #<User id: 17, email: ...>. Ruby's default inspect shows a 16-digit address. Four digits is neither.
pry's ColorPrinter#pp rescues exceptions raised by inspect and prints a fallback built from the class name and object.__id__.to_s(16). Object ids are small sequential numbers, so it's short. irb has no such rescue, so it prints "An error occurred when inspecting the object" as is. In this blog's repo (pry 0.15.2), passing the same kind of model to Pry::ColorPrinter.pp printed just #<ReproUser:0x7544> and no exception got out.
Staging used pry because of RAILS_ENV. pry-rails sits in the Gemfile's group :development, :test, and Rails only requires the groups for the current environment through Bundler.require(*Rails.groups). The staging container was running with RAILS_ENV=development, so pry took over the console. Production was production, so it got irb. The gem was installed in both images. The difference was require, not install.
So when someone says "it works on staging", line up the REPL first. Reproduce with bin/rails runner, not the console. With no REPL in the way, staging blew up exactly like production.
The cause: Devise already uses that name
At the bottom of the backtrace from runner was Devise's inspect. Devise redefines inspect on top of serializable_hash so passwords don't leak into exception messages. ActiveModel's serializable_hash calls send for every attribute name (alias :read_attribute_for_serialization :send).
And Devise's DatabaseAuthenticatable already has a method that hashes passwords.
# devise-4.9.4/lib/devise/models/database_authenticatable.rb:197
def password_digest(password)
Devise::Encryptor.digest(self.class, password)
end
It takes one argument. send(:password_digest) passes zero. Hence wrong number of arguments (given 0, expected 1).
The column reader does exist. AR defines a password_digest reader in User::GeneratedAttributeMethods. But that module is included the moment the class is created (the inherited hook), and devise ... in the class body is included after it. Later includes sit higher in the ancestor chain, so when names collide, the column loses. User.instance_method(:password_digest).owner returns Devise::Models::DatabaseAuthenticatable.
I also checked why AR never warned. dangerous_attribute_method?, which decides whether a column name is dangerous, only looks at methods defined by ActiveRecord::Base itself. A column named save raises DangerousAttributeError right away. password_digest returns false. Collisions with gems or concerns get shadowed silently.
Why the column was there at all
password_digest is the fixed column name Rails' standard has_secure_password uses. Devise does the same job with encrypted_password. So there were two password hash columns.
Digging through git, add_column :users, :password_digest and a #has_secure_password comment landed on the same day. It was commented out from the start and never turned on. Someone weighed the two, picked Devise, and by then the migration had already run, so only the column stayed. In production, 0 of the 42,212 users rows had a value in it. An empty column had been killing inspect for over two years.
It's not just inspect. as_json, to_json and serializable_hash die too. Single attributes like user.email and queries work fine. It survived only because nothing rendered a User as JSON. One render json: user and you get a 500.
The fix: ignored_columns alone leaks in two places
The first move was self.ignored_columns += %w[password_digest]. The attribute is gone, so nothing calls send on it. Then a self-review found two holes.
One. ignored_columns only applies when AR builds attributes from the schema. If a SELECT brings the column back, like select('users.*'), the attribute is revived and it blows up again. The service code really had a query like that.
Two. The moment it's fixed, things you couldn't see before start showing. Devise only hides its UNSAFE_ATTRIBUTES_FOR_SERIALIZATION list (encrypted_password, reset_password_token and so on), and authentication_token, which that project used for API auth, isn't on it. Re-running it in this blog's repo, inspect masked it as [FILTERED] thanks to :token in filter_parameters, but as_json and to_json returned the token in plain text. And if ActiveRecord loads before your initializers, filter_attributes stays an empty array and inspect is plain text too. This blog's repo is like that.
So the final fix drops both in serializable_hash.
class User < ApplicationRecord
self.ignored_columns += %w[password_digest]
EXCLUDED_FROM_SERIALIZATION = %i[password_digest authentication_token].freeze
def serializable_hash(options = nil)
options = options.try(:dup) || {}
options[:except] = Array(options[:except]).dup
options[:except].concat(EXCLUDED_FROM_SERIALIZATION)
super(options)
end
end
except removes names from the list before send is ever called, so it's safe even when select('users.*') revives the column. Devise's serializable_hash is still reached through super, so Devise's own hiding keeps working. Here's the reproduction on Rails 8.0.4 and Devise 4.9.4.
before inspect, as_json, to_json ArgumentError
ignored_columns only inspect OK, authentication_token in as_json
+ select('users.*') inspect ArgumentError (again)
with except inspect OK, as_json keys = [id, email]
One catch: only and except are an elsif, so pinpointing it with as_json(only: [:password_digest]) still blows up. Not that you'd ever call it that way.
Drop the column in the next release
It's tempting to put remove_column in the same PR, but on a rolling deploy that's an outage, because old code still writes the dropped column in its INSERTs. Split it: ship ignored_columns → confirm every old container is gone → remove_column → delete the ignored_columns line. The why is in Why remove_column Breaks INSERTs During a Rolling Deploy.
Does my app have one? Scan them all at once
This lists only columns whose reader is shadowed by another module, where that method requires arguments. Run it with bin/rails runner.
Rails.application.eager_load!
ActiveRecord::Base.descendants
.reject { |m| m.abstract_class? || m.name.nil? || !m.table_exists? }
.each do |klass|
klass.define_attribute_methods rescue next
klass.column_names.each do |c|
m = (klass.instance_method(c) rescue next)
next if m.owner.to_s.include?('GeneratedAttributeMethods')
next if m.arity == 0 || m.arity == -1 # harmless if callable with no args
puts "#{klass.name}##{c} -> #{m.owner} (arity #{m.arity})"
end
end
On the work project (80 models) it printed exactly one line: User#password_digest -> Devise::Models::DatabaseAuthenticatable (arity 1). On this blog's repo it prints nothing. id is shadowed by AR's PrimaryKey module in almost every model, but it's arity 0 and harmless, so it's filtered out.
Half-finished migrations like this one, left over from weighing has_secure_password against Devise, are more common than you'd think. Roll back migrations you won't use. The column stays, the intent doesn't. Two years later, the only clue to the cause was a single #has_secure_password comment.
If your older Rails app has piled up columns nobody can explain, or you want a second pair of eyes on where tokens leak while you clean up auth, feel free to ask negabaro.
pry vs irb: when inspect raises
| pry (pry-rails) | irb | |
|---|---|---|
| What you see | #<User:0x6978> | An error occurred when inspecting the object |
| The exception | Rescued by ColorPrinter, hidden | Printed as is |
| When you get it | Its Gemfile group matches the current RAILS_ENV | pry-rails is not required |
has_secure_password vs Devise: same job, different names
| has_secure_password | Devise database_authenticatable | |
|---|---|---|
| Hash column | password_digest | encrypted_password |
The name password_digest | A column (no args) | A method that builds the hash (1 arg) |
| Pick it when | A small app that only needs login. Rails 8's auth generator goes this way | You need password reset, lockout and OAuth in one go |
| Both at once | The Devise method shadows the column and inspect / as_json die. Pick one and drop the other's column | |
Key Points
If "it works on staging", check the REPL first. pry swallows inspect errors. Reproduce with bin/rails runner
Use User.instance_method(:password_digest).owner to see who shadows the column reader
Make AR forget the column with ignored_columns, and drop it plus secret tokens with except in serializable_hash
Run remove_column in the release after ignored_columns has fully rolled out
Use the snippet to find, across all models, columns shadowed by methods that need arguments