The Rails Deprecations You Missed This Summer

The Rails Deprecations You Missed This Summer

Ruby on Rails keeps changing between releases. Five This Week in Rails opens a new window issues rolled in eight Active Record deprecations and behavior changes on Rails main over the last few weeks. Most are small renames. One is a real bug fix that stops a write from leaking outside its association. None of it has shipped in a tagged release yet, main is currently versioned 8.2.0.alpha. Rails has shipped a new minor version roughly every year (8.0 in November 2024, 8.1 in October 2025), so 8.2 landing sometime around the end of 2026 is a reasonable bet, well ahead of binds, which has a committed removal date of 8.3. uniq! is the one to not expect soon: its removal is tied to 9.0, and Rails hasn’t announced a timeline for that one yet. Nothing in your Gemfile breaks today, but you’ll want to know about it before it does. If you’ve been tracking this kind of thing, you might remember we covered deprecated associations in Rails 8.1 opens a new window back in July. This is the next batch.

I’ll get into what’s changing in each of the eight, why the Rails team made the change, and what to update in your own code once it ships. If you want the general playbook for handling deprecation warnings during an upgrade, we have a guide for that too opens a new window .

Attribute writes: write_attribute(:id, ...) is deprecated

If you’re using a custom primary key, write_attribute(:id, value) used to translate :id into your actual primary key column behind the scenes:

class Order < ApplicationRecord
  self.primary_key = "legacy_id"
end

order = Order.new
order.write_attribute(:id, 42)
order.legacy_id # => 42

That’s going away. Pull request #58347 opens a new window deprecates the translation. Eventually write_attribute(:id, value) will write straight to the id column instead of your custom primary key. It’s a follow-up to an earlier change to read_attribute(:id) opens a new window , which stopped returning the custom primary key value a few versions back. The Rails team calls the old write-side behavior “most likely an oversight”, since it’s never gotten the same treatment as the read side.

The PR doesn’t spell out an official migration path, but one way to keep the old behavior is to write to your actual primary key attribute by name instead of through :id:

order.write_attribute(:legacy_id, 42)

Treat that as a starting point, not an official fix, and check whether order.id = 42 (Active Record’s own primary key alias) does the job for your case too. This only affects apps with a custom or composite primary key. If your models use Rails’ default id column, there’s nothing to change.

SQL internals cleanup: positional #insert args, binds, and the create alias

Four separate pull requests share the same focus: pieces of Active Record’s low-level connection API that come before Arel’s current way of handling bind parameters (the ? placeholders in a query that carry their values separately, like WHERE id = ? paired with 1) are getting deprecated.

PR #58297 opens a new window deprecates three positional arguments to #insert: pk (primary key), id_value, and sequence_name. Each gets its own warning and its own replacement:

# before: pk selects which column #insert returns
connection.insert(sql, name, "id")

# after: use the returning: keyword instead
connection.insert(sql, name, returning: "id")

id_value used to let #insert echo back a value the caller already had, for cases where the database couldn’t compute the last inserted ID. If you’re passing it in, you already have the value, you don’t need it returned. sequence_name only mattered for a PostgreSQL currval() fallback tied to a config path that’s already deprecated on its own.

The binds argument (the separate array holding those placeholder values) is going away in two places. PR #58310 opens a new window deprecates passing binds to to_sql, and PR #58323 opens a new window deprecates it as a positional argument to insert, update, and delete. Both come down to the same root cause: since Rails 5.2, Arel has tracked bind values as part of the query itself, so passing binds in separately hasn’t done anything for years, to_sql never even looked at the value you gave it. The fix is to build the bind values into the SQL itself with Arel.sql:

# before
connection.update("UPDATE topics SET title = ? WHERE id = 1", [title])

# after
connection.update(Arel.sql("UPDATE topics SET title = ? WHERE id = 1", title))

Arel.sql can now wrap a SQL string and its bind values together, the same pattern Model.where already uses internally. The old positional binds argument is planned to be removed in Rails 8.3, so you have more time to update this one than the others.

Last in this group, PR #58426 opens a new window deprecates the create alias for insert on connection adapters. insert, update, and delete map to their SQL verbs, but create reads like DDL (create_table, create_database) when it’s actually running an INSERT. If you’re calling connection.create(...) directly, switch to connection.insert(...). Same behavior, clearer name.

Relation changes: uniq! deprecated, and update/update! now respect scope

Two changes here, one minor cleanup and one real behavior fix, both about making a Relation’s behavior more consistent. This is the same family of change as the Rails 6.1 merge deprecation opens a new window we ran into a while back.

PR #58525 opens a new window deprecates Relation#uniq!. Rails added it back to help with the move to automatic deduplication of multi-value query methods (SELECT DISTINCT and friends), a Rails 7.0 feature. Deduplication happens automatically now, so the method doesn’t do anything useful anymore. Rails plans to remove it in 9.0. If you’re on Rails 7.0 or newer and still have uniq! calls lying around, they’re safe to delete today.

The bigger one is PR #58320 opens a new window , which changes how update and update! behave when you call them with an ID on a scoped relation:

post.comments.update!(comment_id, body: "edited")

Before this change, that call delegated to the model class method (in this case Comment.update!), which resolved comment_id against the whole comments table, not just the ones belonging to post. If comment_id pointed at a comment on a different post, the update would go through anyway. The Rails team’s commit message says it directly: an update through an association could silently write to a record that belonged to a different post, even though the association was supposed to scope it.

After this change, update and update! move to the Relation class and respect its scope the same way update_all, delete, and destroy already do. If the ID isn’t in the relation’s scope, Rails raises ActiveRecord::RecordNotFound instead of writing to a record you didn’t mean to touch.

The fix cuts both ways. The Rails team calls the second half “the same bug seen from the other side”: Model.unscoped.update(id, attributes) now actually stops applying your model’s default scope too. Before, update/update! ignored whatever scope you called it through, whether that scope was meant to narrow your options (an association) or remove them (unscoped). Now it honors the relation’s real scope either way, so it’s also worth testing any unscoped.update call that was relying on a default scope to still filter things out.

This is the one worth actually testing for. Does your app call update/update! with an ID through an association anywhere, where that ID could realistically belong to a different parent record? That call will start raising once this ships. It’s the right behavior, but it’s a behavior change, not just a rename.

Schema config consolidation: schema_ignored_tables

If you’ve ever needed to keep a table out of schema.rb and out of the schema cache, you’ve probably set two separate options:

config.active_record.schema_cache_ignored_tables = ["audit_logs"]
ActiveRecord::SchemaDumper.ignore_tables = ["audit_logs"]

PR #58554 opens a new window merges both into one:

config.active_record.schema_ignored_tables = ["audit_logs"]

The reasoning is simple: the two old options were sort of doing the same job, and the PR description asks the obvious question, why would you want to ignore a table from schema caching but not from the dumper, or the other way around? Both old configs still work for now, they delegate to the new one and print a deprecation warning, but if you set both, only the last one you assign wins.

One thing to watch: table matching now happens against the actual database table name, not the logical name with your table_name_prefix/table_name_suffix removed. If your app uses either of those and you’re ignoring a table by its logical name, double check the entry still matches once you switch to schema_ignored_tables. If schema config in general feels unfamiliar, we’ve covered the other schema changes that landed in Rails 7 opens a new window too.

Conclusion

All eight of these are sitting on Rails main right now, not in a tagged release, so nothing in your app breaks today. write_attribute(:id, ...), the #insert/to_sql/create cleanup, uniq!, and the schema_ignored_tables merge are all low-risk renames you can get ahead of whenever you have a few minutes. The one to actually go test for is the update/update! scope change. If any of your code writes to a record by ID through an association, check now whether that ID could ever belong to something outside the association’s scope, before Rails starts raising on it for you. Once you’ve fixed these, here’s how to keep the fixed ones from creeping back into your codebase opens a new window .

Has your team been struggling with an upgrade? We can help. opens a new window

Get the book