activerecord-returning
update_all tells you how many rows changed. This gem tells you which ones.

User.where(role: :admin).update_all(role: :member)
# => 2 ...which two?
User.where(role: :admin).update_all_returning({ role: :member }, returning: %i[id email])
# => #<ActiveRecord::Result [[1, "ada@example.com"], [2, "grace@example.com"]]>
One statement. No pluck first, no FOR UPDATE, no window where another process can change the set
underneath you.
TL;DR
gem "activerecord-returning"
# every form of update_all, plus returning:
User.where(role: :admin).update_all_returning(role: :member) # => primary keys
User.where(role: :admin).update_all_returning({ role: :member }, returning: :email)
User.where(role: :admin).update_all_returning({ role: :member }, returning: %i[id email])
User.where(role: :admin).update_all_returning({ role: :member }, returning: :_all) # RETURNING *
User.update_all_returning(role: :member) # on the model, too
Session.where(expires_at: ..1.week.ago).delete_all_returning(returning: :user_id)
Always returns an ActiveRecord::Result. PostgreSQL and SQLite 3.35+. Rails 7.0–8.1. Nothing is
overridden — update_all and delete_all behave exactly as before.
Why
Every bulk change ends with the same question: and then what? Enqueue jobs for the rows you touched,
write an audit entry, send the mail, invalidate the cache. update_all hands back an Integer, so people
reach for one of these:
# Racy. Another process can change the set between the two statements.
ids = Session.where(expires_at: ..1.week.ago).pluck(:id)
Session.where(id: ids).delete_all
# Correct, but three statements, a transaction and a lock you have to remember.
Session.transaction do
ids = Session.where(expires_at: ..1.week.ago).lock.pluck(:id)
Session.where(id: ids).delete_all
ids
end
Databases have solved this for years with RETURNING. Rails exposes it on insert_all/upsert_all via a
returning: kwarg — but not on update_all/delete_all. Proposed upstream more than once, still not
merged as of Rails 8.1. This gem adds the two methods, using only public Active Record API.
Examples
Expire sessions, notify their owners
expired = Session.where(expires_at: ..Time.current).delete_all_returning(returning: :user_id)
expired.rows.flatten.uniq.each { |user_id| SessionExpiredMailer.notify(user_id).deliver_later }
Claim work without a race
claimed = Job.pending.order(:created_at).limit(1)
.update_all_returning({ status: :claimed, worker: worker_id }, returning: :_all)
job = Job.instantiate(claimed.to_a.first) if claimed.length.positive?
Two workers running that at once cannot claim the same row: the UPDATE picks it, and only one wins.
Audit a bulk change
changed = Invoice.where(status: :draft).update_all_returning(
{ status: :issued, issued_at: Time.current },
returning: %i[id number]
)
AuditLog.insert_all(changed.to_a.map { |row| { subject: "Invoice##{row["id"]}", action: "issued" } })
Cancel and report in one pass
result = Subscription.where(trial_ends_at: ..Date.current)
.update_all_returning({ state: "expired" }, returning: %i[id user_id plan])
Rails.logger.info("expired #{result.length} trials: #{result.rows.inspect}")
Get models back
users = User.where(role: :admin)
.update_all_returning({ role: :member }, returning: :_all)
.map { |attributes| User.instantiate(attributes) }
users.first.email # => "ada@example.com"
users.first.new_record? # => false
Timestamps, if you want them — like update_all, nothing is touched for you:
User.where(role: :admin).update_all_returning(role: :member, updated_at: Time.current)
returning:
| Value | Clause |
|---|---|
omitted / nil |
the primary key (all of them, for a composite primary key) |
:email |
RETURNING "email" |
%i[id email] |
RETURNING "id", "email" |
:_all (bare or as [:_all]) |
RETURNING * |
Arel.sql("id, now() AS at") |
that SQL, verbatim |
Rejected on purpose, each with a message saying what to do instead: a bare String (pass symbols, or wrap
SQL in Arel.sql), an empty list, returning: false (use plain update_all), :all (use :_all), and
:_all mixed with other columns (RETURNING * already includes them). A column literally named all or
_all is reachable with Arel.sql.
updates takes every shape update_all accepts:
users.update_all_returning(role: :member) # Hash
users.update_all_returning("role = 0") # String
users.update_all_returning(["email = ?", "x@example.com"]) # Array
users.update_all_returning(role: :member, returning: :email) # braceless — returning: is pulled out
Hash values go through the attribute's type, so enums, booleans, JSON and alias_attribute cast exactly as
update_all does.
The return value
Always an ActiveRecord::Result — the same class insert_all returns, no wrapper, no subclass.
result.columns # => ["id", "email"]
result.rows # => [[1, "ada@example.com"], [2, "grace@example.com"]]
result.to_a # => [{"id" => 1, "email" => "ada@example.com"}, ...]
result.length # => 2
result.each { |row| … }
Casting is the adapter's, not your model's: PostgreSQL types by OID and gives you a Time for a
timestamp; SQLite hands back the raw String. For model types:
result.cast_values(Session.attribute_types) # => [[7, 1, 2026-08-14 09:00:00 UTC], ...]
Database support
| Database | Supported |
|---|---|
| PostgreSQL | yes |
| SQLite 3.35+ | yes |
| MySQL | no — raises UnsupportedAdapter |
| MariaDB | no — raises UnsupportedAdapter |
Read from supports_update_returning? where it exists, supports_insert_returning? otherwise. Two
deliberate exceptions:
- MariaDB answers
supports_insert_returning?withtrue(it hasINSERT ... RETURNINGsince 10.5) while having noUPDATE ... RETURNINGat all — so the MySQL family is excluded explicitly, and CI runs a MariaDB lane to keep it that way. - Rails 7.0's SQLite3 adapter predates those capability methods, so the SQLite version is checked directly.
MySQL has no RETURNING on any statement. There is nothing to generate, so it raises rather than guessing:
User.where(role: :admin).update_all_returning(role: :member)
# ActiveRecord::Returning::UnsupportedAdapter:
# the Trilogy adapter does not support RETURNING on UPDATE/DELETE
On MySQL, do it in two statements with a lock, inside a transaction:
User.transaction do
ids = User.where(role: :admin).lock.pluck(:id)
User.where(id: ids).update_all(role: :member)
User.where(id: ids)
end
The gem won't do that for you: it's only correct inside a transaction with a lock, and hiding that behind a method that looks atomic hands a race to everyone who forgets.
How it works
Your relation is reduced to a primary-key SELECT and used as a subquery:
UPDATE "users" SET "role" = 0, "lock_version" = COALESCE("lock_version", 0) + 1
WHERE "users"."id" IN (
SELECT "users"."id" FROM "users" WHERE "users"."role" = 1 ORDER BY "users"."email" ASC LIMIT 2
)
RETURNING "id", "email"
Active Record builds that inner SELECT, so everything you already know keeps working:
User.joins(:posts).where(posts: { published: true }).update_all_returning(role: :member)
User.order(:created_at).limit(100).update_all_returning({ role: :member }, returning: :id)
User.where(role: :admin).lock.update_all_returning(role: :member) # FOR UPDATE in the subquery
user.posts.update_all_returning({ published: true }, returning: :id)
Memo.update_all_returning(title: "edited") # STI: this subclass only
Note.where(shop_id: 1).delete_all_returning # WHERE ("shop_id", "note_id") IN (SELECT ...)
Default scopes, merge, none, distinct and composite primary keys are covered too. No
Arel::UpdateManager, no _substitute_values, no build_arel — nothing private, which is why one code
path spans Rails 7.0 to 8.1.
Optimistic locking works like update_all: the locking column is incremented unless you set it
yourself.
Caveats
Callbacks, validations and timestamps are skipped, exactly as with update_all/delete_all. Nothing
is instantiated.
Isolation. The subquery runs inside the same statement, so there is no separate read. But under READ COMMITTED (PostgreSQL's default) a row whose value changed after the statement's snapshot can still be
picked up. Where that matters, lock explicitly or raise the isolation level.
Rejected relations, each with a message pointing at the fix:
| why | |
|---|---|
includes that eager-loads |
a join can't be reduced to a primary-key subquery — use joins |
group / having |
the subquery would select an ungrouped column — use where(id: grouped.select(:id)) |
from |
it renames the table the subquery reads, so the primary key would resolve to the row being changed and every row would match — use unscope(:from) |
| model without a primary key | nothing to match rows on |
delete_all_returning on a has_many deletes. user.posts.delete_all nullifies posts.user_id
unless the association declares dependent: :delete_all. delete_all_returning always issues a DELETE,
because returning rows that still exist would be a lie. Renaming one call to the other on an association
without dependent: :delete_all therefore removes rows where the old code only unset a foreign key.
returning: :_all on a joined relation returns the updated table's columns only — RETURNING * refers
to the updated row, not the join.
Performance. The subquery is always there, even for a plain where, while update_all writes a direct
UPDATE ... WHERE unless a join or limit forces otherwise. PostgreSQL usually turns it into a semi-join on
the same index; on a large hot-path table, EXPLAIN first.
Active Record may grow its own
rails/rails#57073 proposes an update_all_returning upstream.
Still open, and its API differs:
| rails/rails#57073 | this gem | |
|---|---|---|
| columns | select(...) on the relation |
returning: keyword |
| default | all columns | the primary key |
So the gemspec caps Active Record at < 8.2, and if ActiveRecord::Relation already defines either
method, the gem leaves that one alone and warns at boot instead of silently doing nothing.
Compared to insert_all/upsert_all, which already have returning:: same return type, same default.
The difference that matters is not the keyword — upsert_all creates rows, these methods never do.
It takes a list of attributes rather than a scope, so it cannot express where(...), and any key that
isn't in the table yet becomes a new row:
User.upsert_all([{ id: 999, email: "ghost@example.com" }], returning: %i[id email])
# => #<ActiveRecord::Result [[999, "ghost@example.com"]]> looks like a row you changed
User.count # => 4 it was inserted
A stale id, a typo, a half-built payload — inserted, and handed back as though it had been updated.
update_all_returning can only touch rows the relation already matched.
Smaller differences: :_all is an addition here, returning: false raises instead of returning an empty
Result, and MySQL raises instead of quietly returning an empty Result.
Why methods, not a chainable .returning
User.returning(:id).where(...).update_all(...) was considered and rejected:
update_allwould return an Integer or a Result depending on state set somewhere else. The call site stops being readable.- It needs
prependoverupdate_all. Removing the gem would then silently change working code instead of raisingNoMethodErrorat the one place that used it. - Rails put
returning:oninsert_all/upsert_allas a keyword, not a query method.
The trade-off: you can't bake it into a scope. That's the intended cost.
Errors
| Error | When |
|---|---|
ActiveRecord::Returning::UnsupportedAdapter |
MySQL, MariaDB, SQLite < 3.35 |
ActiveRecord::Returning::Error |
eager loading, group/having, from, no primary key |
ArgumentError |
empty updates, bare String / empty list / false in returning: |
UnsupportedAdapter < Error < StandardError.
Requirements
Ruby 3.1+ · Active Record 7.0–8.1 · PostgreSQL or SQLite 3.35+
CI covers Ruby 3.1–3.4 × Rails 7.0, 7.1, 7.2, 8.0, 8.1 on SQLite, PostgreSQL on every one of those Rails versions, and MySQL 8 and MariaDB 11 for the unsupported-adapter path.
Development
bin/setup # create the dev database, load the schema, seed it
bin/console # IRB with User, Post, Session, Note (composite PK), Memo (STI)
bundle exec rake test
All three take DB=sqlite (default), DB=postgres, DB=mysql or DB=mariadb; databases are created for
you, connections come from PGHOST/PGUSER/PGPASSWORD and MYSQL_HOST/MYSQL_PORT/MYSQL_USER/
MYSQL_PASSWORD.
bundle exec appraisal install && bundle exec appraisal rake test # every supported Rails
python3 docs/render_demo.py # re-render the demo GIF
Releasing
# bump lib/activerecord/returning/version.rb, move CHANGELOG entries under the new version
bundle exec rake release
Contributing
Bug reports and pull requests: https://github.com/igorkasyanchuk/activerecord-returning
License
MIT. Copyright (c) 2026 Igor Kasyanchuk.