Summary
ActiveRecord::Enum#_enum (Rails 7.1+) registers a pending attribute modification that raises Undeclared attribute type for enum '<name>' in <Model> the first time columns_hash is
consulted if the column isn’t yet known to the schema cache. Once the class singleton caches that failed apply_pending_attribute_modifications result, every subsequent request
re-raises until the cache is invalidated.
I’d like to propose a recovery path: when attribute_types[name] resolves to ActiveModel::Type.default_value, infer the underlying type from the enum’s values mapping (all-Integer →
:integer, all-String → :string). Mixed or empty mappings continue to raise the existing error, preserving behavior for genuinely under-specified enums.
Wanted to surface this here before opening a PR, since the strictness in enum was added deliberately (#49717) and walking back any of it deserves a design conversation.
Why this matters in practice
Three open issues document the same production failure mode:
- #54820 — Postgres-backed enum raises in production only (open, no fix)
- #45668 — enum misbehaves with outdated schema cache
- #52607 — false positive under parallelized tests (PR #52703 fixed the test path; production path remains)
Plus a real production incident on Mastodon during their 4.4.8 → 4.5.0 upgrade: mastodon/mastodon#36857. And the
enum_errors_away gem exists specifically to monkey-patch the same recovery pattern; it has real adoption, which is evidence the
failure is hitting people in the wild and the workaround is well-understood.
The common trigger is the first post-boot columns_hash lookup racing against a transient or stale schema state — PgBouncer session-state leak, deploy-time migration race, Heroku
rolling deploys with stale-from-S3 schema caches. Once columns_hash returns a stale result, the class is sticky-poisoned: every authenticated request that touches the model re-enters
the same path and re-raises.
I hit this last week as a 2-minute production outage on a Rails 8.1 app. One dyno’s first Student.columns_hash lookup at deploy time landed against a stale cache; the resulting empty
result cached on the class singleton; ~227 RuntimeError: Undeclared attribute type for enum 'claim_state' in Student errors over a 2-minute window, cascading into 426 HTTP 500s, until
the connection pool reaper cycled out the stale connection. Declarative attribute :name, :type above each enum neutralized it for our four enum sites, but the underlying race is
still there for anyone who hasn’t done that workaround.
Sketch of the change
In ActiveRecord::Enum#_enum, where it currently raises:
if subtype == ActiveModel::Type.default_value
inferred = infer_enum_subtype(enum_values)
if inferred
ActiveRecord::Base.logger&.warn("Enum '#{name}' on #{self.name}: inferred :#{inferred.first} from values...")
subtype = inferred.last
else
raise "Undeclared attribute type for enum '#{name}' in #{self.name}..."
end
end
Where infer_enum_subtype inspects enum_values.values:
- values.all?(Integer) → [:integer, Type::Integer.new]
- values.all?(String) → [:string, Type::String.new]
- otherwise → nil (preserve current raise)
A working patch is sitting on a branch locally; I’m happy to open it as a PR once the design is settled. The diff is ~45 LOC in enum.rb, 3 new tests, and a CHANGELOG entry.
Some design questions..
- Recovery vs. preserved strictness with a better error? An alternative would be: keep raising, but include the inferred type in the error message (“Did you mean attribute :foo, :integer?”). Doesn’t fix the production race (still a hard crash on the first request), but stays closer to the explicit-is-better-than-implicit spirit of #49717.
- Warning channel? ActiveRecord::Base.logger&.warn seems right (the patch I prototyped used ActiveRecord.deprecator.warn, but nothing is actually being deprecated, so logger feels more honest). Open to whichever the maintainers prefer.
- Mixed / non-Integer-non-String values? I left those raising as today, on the theory that those are rare enough that explicit declaration is the right ask. Open to broader inference (e.g. all-Boolean → :boolean) if there’s appetite.
- PG-level enum types? CREATE TYPE foo AS ENUM (…)-backed columns are out of scope for this patch — the values mapping doesn’t help there. Existing behavior preserved.
Would love a quick reaction from anyone who’s touched this code recently before I send the PR. Happy to adjust the design based on what makes sense to the team.