
Rails 7.2 (August 2024) shipped four Active Record features you already have: composite foreign_key: [...] associations for composite primary keys, per-transaction after_commit and after_rollback callbacks, ActiveRecord.after_all_transactions_commit, and the start_transaction.active_record event — plus rails new --devcontainer. All still work unchanged on Rails 8.1; only Active Job’s enqueue_after_transaction_commit changed, from adapter-decided to a per-job opt-in.
Every example, SQL statement, and error message on this page is reproduced on Rails 8.1 (activerecord 8.1.3.1, Ruby 3.4.10) against both SQLite and PostgreSQL 17.11. That way you can match them against your own logs. If you landed here mid-debug, jump straight to the composite primary key error reference.
Rails 7.2 Database Features at a Glance
Rails 7.2.0 was released on August 9, 2024, followed by Rails 8.0.0 on November 7, 2024 and Rails 8.1.0 on October 22, 2025. Each row links to the section that shows the feature in code, with the errors it can raise.
| Feature | Shipped in | On Rails 8.1 | Section |
|---|---|---|---|
Composite foreign_key on has_many and belongs_to | Rails 7.2.0 | Unchanged | Composite associations |
Per-transaction after_commit and after_rollback, plus current_transaction | Rails 7.2.0 | Unchanged | Per-transaction callbacks |
ActiveRecord.after_all_transactions_commit | Rails 7.2.0 | Unchanged | after_all_transactions_commit |
start_transaction.active_record event and the :transaction payload key | Rails 7.2.0 | Unchanged | Instrumenting transactions |
rails new --devcontainer and bin/rails devcontainer | Rails 7.2.0 | Unchanged | Dev containers |
Active Job enqueue_after_transaction_commit | Rails 7.2.0 | Changed in 8.0: a per-job boolean, default false; the 7.2 symbols and global setting were deprecated in 8.0 and removed in 8.1 | Active Job |
Rails 8.0’s own additions to Active Record are a separate list, covered in the Rails 8 guide.
Composite Primary Keys in Active Record
A primary key uniquely identifies a row. Most Rails tables use a surrogate key: an integer id column the database fills in, with no meaning outside the table. A natural key comes from the data itself. A composite primary key is a natural key spanning several columns, such as a shop_id paired with a per-shop order number, or the two foreign keys of a join table. Rails 7.1 added composite primary keys to Active Record, and Rails 7.2 finished the job on the association side.
Defining a Composite Primary Key
Pass an array to primary_key: in the migration:
class CreateOrders < ActiveRecord::Migration[8.1]
def change
create_table :orders, primary_key: [:shop_id, :id] do |t|
t.integer :shop_id, null: false
t.integer :id, null: false
t.string :status
t.timestamps
end
end
endThe schema dump records the same array:
# db/schema.rb
create_table "orders", primary_key: ["shop_id", "id"], force: :cascade do |t|And on PostgreSQL, \d orders shows a two-column primary key index:
Indexes:
"orders_pkey" PRIMARY KEY, btree (shop_id, id)Active Record reads the composite key from the schema, so the model needs no configuration. Declaring it is equivalent and documents intent:
class Order < ApplicationRecord
self.primary_key = [:shop_id, :id]
endOrder.primary_key
# => ["shop_id", "id"]
Order.composite_primary_key?
# => trueThe model generator has no flag for this: bin/rails generate model accepts --primary-key-type and nothing else about keys, so write the migration by hand. The composite primary keys guide covers fixtures and a few other corners this reference skips.
Finding Records by Composite Key
find takes the key as an array, in the order the primary key declares it:
order = Order.find([1, 10])
# SELECT "orders".* FROM "orders" WHERE "orders"."shop_id" = 1 AND "orders"."id" = 10 LIMIT 1
order.id # => [1, 10]
order.id_value # => 10
order.to_param # => "1_10"Several records take an array of arrays: Order.find([[1, 10], [2, 20]]). id returns the whole key, id_value the value of the column literally named id, and to_param joins the key with underscores, which is what order_path(order) puts in the URL: /orders/1_10. find does not reverse that string on its own, and Order.find("1_10") raises ActiveRecord::RecordNotFound. Split the param in the controller:
class OrdersController < ApplicationController
def show
@order = Order.find(params[:id].split("_"))
end
endOne thing find does not check is extra elements. Order.find([1, 10, 3]) returns the [1, 10] record and ignores the 3.
Composite Associations: foreign_key Replaces query_constraints
A composite foreign key points at every column of the composite primary key. Rails 7.2 made the association foreign_key option accept an array and deprecated query_constraints, the option Rails 7.1 used for the same purpose. On Rails 8.0 and later, query_constraints: on an association raises, as the error reference shows.
class Order < ApplicationRecord
self.primary_key = [:shop_id, :id]
has_many :order_items, foreign_key: [:shop_id, :order_id]
end
class OrderItem < ApplicationRecord
belongs_to :order, foreign_key: [:shop_id, :order_id]
endBoth directions generate the two-column condition:
OrderItem.joins(:order).to_sql
# => SELECT "order_items".* FROM "order_items" INNER JOIN "orders" ON "orders"."shop_id" = "order_items"."shop_id" AND "orders"."id" = "order_items"."order_id"
order.order_items.to_sql
# => SELECT "order_items".* FROM "order_items" WHERE "order_items"."shop_id" = 1 AND "order_items"."order_id" = 10The database-level constraint takes the same arrays:
class CreateOrderItems < ActiveRecord::Migration[8.1]
def change
create_table :order_items do |t|
t.integer :shop_id, null: false
t.integer :order_id, null: false
t.string :sku
t.timestamps
end
add_foreign_key :order_items, :orders, column: [:shop_id, :order_id], primary_key: [:shop_id, :id]
end
endThe belongs_to presence validation catches a missing parent before the database does: OrderItem.create!(shop_id: 9, order_id: 9) fails with ActiveRecord::RecordInvalid: Validation failed: Order must exist. Skip validation and the constraint answers instead, as ActiveRecord::InvalidForeignKey wrapping SQLite3::ConstraintException: FOREIGN KEY constraint failed or PostgreSQL’s PG::ForeignKeyViolation.
The model-level query_constraints class method is a different thing and still exists on Rails 8.1: Order.query_constraints :shop_id, :id declares a virtual composite key for a table that has none.
The Bookshop repo implements the models from the Rails composite primary key documentation as a single-file Rails app you can modify, so you can compare surrogate keys with composite keys hands-on.
The rest of this section is the error reference. Every message is copied from a real Rails 8.1.3.1 exception, so you can match it against your logs character for character.
TypeError: Expected value matching ["shop_id", "id"], got 10.
TypeError: Expected value matching ["shop_id", "id"], got 10.Cause: One column of the composite key is named id, so the id= writer expects the whole key. Order.create!(shop_id: 1, id: 10), and Order.new with the same attributes, hand it a single integer.
Fix: Pass the full key to id, or set the single column through id_value:
Order.create!(id: [1, 10], status: "paid")
order = Order.new(shop_id: 3)
order.id_value = 30
order.save!Writing the column through the attribute hash does not work either. order[:id] = 10 raises:
ActiveModel::MissingAttributeError: can't write unknown attribute `["shop_id", "id"]`ActiveRecord::RecordNotFound: Couldn't find Order with '["shop_id", "id"]'=1
ActiveRecord::RecordNotFound: Couldn't find Order with '["shop_id", "id"]'=1Cause: find received a scalar or a one-element array where the two-element key is required. Order.find(1) and Order.find([1]) raise exactly this message, Order.find("1") renders the value as '["shop_id", "id"]'="1", and the to_param string does the same: Order.find("1_10") reports '["shop_id", "id"]'="1_10".
Fix: Pass the key as an array, and split route params: Order.find(params[:id].split("_")).
A variant: several scalar arguments raise a different error, and its class depends on their type. Order.find(*params[:id].split("_")), which passes the strings "1" and "10", raises ArgumentError: Expected corresponding value for ["shop_id", "id"] to be an Array; with integers, Order.find(1, 10) raises NoMethodError: undefined method 'first' for an instance of Integer.
ActiveRecord::RecordNotFound: Couldn't find all Orders with '["shop_id", "id"]': (1, 999) (found 0 results, but was looking for 1).
ActiveRecord::RecordNotFound: Couldn't find all Orders with '["shop_id", "id"]': (1, 999) (found 0 results, but was looking for 1).Cause: A well-formed key that matches no row. A multi-record lookup reports a partial miss the same way: Order.find([[1, 10], [2, 999]]) says (found 1 results, but was looking for 2). The find_by! form reads differently, Couldn't find Order with [WHERE "orders"."shop_id" = ? AND "orders"."id" = ?] on SQLite, with $1 and $2 in place of the question marks on PostgreSQL.
Fix: A missing composite key is a missing record, not a malformed one, so handle it as usual. find_by returns nil, and a rescue_from turns the exception into a 404 for every controller:
class ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound do
head :not_found
end
endActiveRecord::ConfigurationError: Setting `query_constraints:` option on `OrderItem.belongs_to :order` is not allowed.
ActiveRecord::ConfigurationError: Setting `query_constraints:` option on `OrderItem.belongs_to :order` is not allowed. To get the same behavior, use the `foreign_key` option instead.Cause: The association option that Rails 7.2 deprecated is gone on Rails 8.0 and later, and the deprecation warning became this error. A has_many reports its own name in the same template (Order.has_many :order_items).
Fix: Rename the option and keep the array:
# Rails 7.1
belongs_to :order, query_constraints: [:shop_id, :order_id]
# Rails 7.2 and later
belongs_to :order, foreign_key: [:shop_id, :order_id]ActiveRecord::UnknownPrimaryKey: Unknown primary key for table events in model Event.
ActiveRecord::UnknownPrimaryKey: Unknown primary key for table events in model Event.Cause: The table was created with id: false and no primary_key:, so Event.primary_key is nil and Event.find(1) has nothing to look up. Only find raises this clear message. update! or destroy on such a record gets as far as the database and fails as ActiveRecord::StatementInvalid, because Active Record builds WHERE "events"."" IS NULL:
# SQLite
ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: events.:
# PostgreSQL
ActiveRecord::StatementInvalid: PG::SyntaxError: ERROR: zero-length delimited identifier at or near """"Fix: Give the table a primary key when you create it, composite keys included (create_table :audits, primary_key: [:name, :occurred_at]). Or point self.primary_key at an existing unique column on the model.
ActiveRecord::MissingRequiredOrderError: Relation has no order values, and Event has no order columns to use as a default.
ActiveRecord::MissingRequiredOrderError: Relation has no order values, and Event has no order columns to use as a default. Set at least one of `implicit_order_column`, `query_constraints` or `primary_key` on the model when no `order `is specified on the relation.Cause: Rails 8.1 only. first and last need a column to order by. A Rails 8.1 app with load_defaults 8.1 sets config.active_record.raise_on_missing_required_finder_order_columns = true, so a model with no primary_key, no query_constraints, and no implicit_order_column raises instead of returning an arbitrary row. The query_constraints in the message is the model-level virtual key, the one place the name survives. The odd spacing in `order `is is in the original message.
Fix: Any of the three the message lists, or an explicit order on the relation:
class Event < ApplicationRecord
self.implicit_order_column = :name
endDev Containers with rails new --devcontainer
A dev container is a Docker-based development environment described by a devcontainer.json file, so everyone on a team boots the same Ruby, database, and browser without installing them locally. Rails 7.2 added a generator for it. The generator is opt-in on both Rails 7.2 and 8.1: a plain rails new creates no .devcontainer folder.
rails new myapp --database=postgresql --devcontainerFor a PostgreSQL app on Rails 8.1, the flag generates three files:
.devcontainer/devcontainer.json: the container definition. It adds the GitHub CLI, Active Storage, docker-outside-of-docker, and PostgreSQL client features, forwards ports 3000 and 5432, points the app at the database withDB_HOST: postgres, and runsbin/setup --skip-serveronce the container is created..devcontainer/compose.yaml: three services,rails-app,seleniumonselenium/standalone-chromiumfor system tests, andpostgresonpostgres:16.1with a named volume for the data..devcontainer/Dockerfile:ARG RUBY_VERSION=3.4.10feedingFROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION, matching.ruby-version, withBINDING="0.0.0.0"so the server is reachable from outside the container.
Open the folder in an editor that supports dev containers and rebuild in the container; bin/rails server then answers on localhost:3000 on your machine through the forwarded port. The Rails dev container guide walks through the editor side. An existing app gets the same files from bin/rails devcontainer, which generates the setup from the current application configuration.
Transaction Callbacks and Active Job’s enqueue_after_transaction_commit
The classic Rails timing bug: a transaction inserts a row and enqueues a job for it. A worker then picks the job up, from Redis with Sidekiq or from the database with Solid Queue (the Rails 8 default), before the transaction commits. The job runs against data that is not visible yet, or against data a rollback then erases. Rails 7.2 addressed this from both sides: callbacks that fire when a transaction settles, and an Active Job setting that delays enqueuing until the commit.
Per-Transaction after_commit and after_rollback
ActiveRecord::Base.transaction yields an ActiveRecord::Transaction object, and you register callbacks on it for that one transaction, without a model callback:
Article.transaction do |transaction|
article.update(published: true)
transaction.after_commit do
# Do work after commit, like send a notification email
end
endThe block runs after the transaction block finishes and the COMMIT succeeds, so article.published is true inside it. after_rollback is the mirror: it runs on raise ActiveRecord::Rollback (the silent rollback) and on any other exception that leaves the block. A rollback discards every after_commit registered on that transaction:
Article.transaction do |transaction|
transaction.after_rollback do
# Runs on ActiveRecord::Rollback and on any other exception
end
raise ActiveRecord::Rollback
endCode that does not own the block reaches the same object through ActiveRecord::Base.current_transaction:
def publish(article)
article.update!(published: true)
ActiveRecord::Base.current_transaction.after_commit do
NotifyJob.perform_later(article.id)
end
endCalled inside a transaction, the job is enqueued after that transaction commits. Called outside one, current_transaction returns a closed ActiveRecord::Transaction whose open? is false, and after_commit runs the block immediately. An after_rollback registered outside a transaction never runs.
ActiveRecord::Base.current_transaction.open?
# => false
ActiveRecord::Base.current_transaction.after_commit { puts "ran" }
# ranThe public API of ActiveRecord::Transaction is small: after_commit, after_rollback, blank?, closed?, open?, and uuid (a UUID generated on demand for tracing). closed? answers with the transaction’s state, fully_committed after a commit, so test it for truthiness rather than against true. Model-level after_commit and after_rollback callbacks are unchanged; the transactions guide covers them, and its section on requires_new covers nesting.
ActiveRecord.after_all_transactions_commit
For code that may run inside or outside a transaction, ActiveRecord.after_all_transactions_commit runs its block immediately when no transaction is open, and after the outermost transaction commits otherwise:
def publish_article(article)
article.update(published: true)
ActiveRecord.after_all_transactions_commit do
NotifyJob.perform_later(article.id)
end
endNested transactions, requires_new: true savepoints included, hold it until the outermost commit, and a rollback drops it:
log = []
Article.transaction do
Article.transaction(requires_new: true) do
ActiveRecord.after_all_transactions_commit { log << "after outermost commit" }
log << "inner block done"
end
log << "outer block done"
end
log
# => ["inner block done", "outer block done", "after outermost commit"]This is the hook Active Job builds on for the next feature.
Active Job: enqueue_after_transaction_commit on Rails 7.2, 8.0, and 8.1
This is the one feature on the page whose default changed, and the 2024 version of this post described the Rails 7.2 behavior as if it were permanent. The version story, from the gem sources:
- Rails 7.2 added
enqueue_after_transaction_commitwith three values::default,:always, and:never. The attribute itself defaulted to:never, andconfig.load_defaults 7.2set it to:default, which asked the queue adapter throughenqueue_after_transaction_commit?.AbstractAdapteransweredtrue, so every adapter that did not override it (asyncandsidekiqamong them) deferred enqueuing until after the commit and dropped the job on rollback, and thetestadapter did the same by default.inlineansweredfalse, and so diddelayed_jobandqueue_classicunless configured otherwise. - Rails 8.0 turned the attribute into a boolean that defaults to
falseand stopped asking the adapter:load_defaultsno longer touches it,:alwaysmaps totrue, and:neverand:defaultboth map tofalse, each with a deprecation warning. The globalconfig.active_job.enqueue_after_transaction_commitgot its own warning, which endsit is not recommended to be set globally. - Rails 8.1 removed the symbols and the global setting. What remains is a per-job class attribute,
falseunless a job class sets it, and since Rails 8.1.2perform_all_laterhonors it too.
From Rails 8.0 on, a job enqueued inside a transaction leaves immediately, and survives a rollback, unless its class opts in. On Rails 8.1, the test adapter shows both behaviors:
class NotifyJob < ApplicationJob
def perform(article_id)
end
end
class DeferredNotifyJob < ApplicationJob
self.enqueue_after_transaction_commit = true
def perform(article_id)
end
endActiveJob::Base.queue_adapter = :test
adapter = ActiveJob::Base.queue_adapter
Article.transaction do
NotifyJob.perform_later(1)
adapter.enqueued_jobs.size # => 1, enqueued before the commit
raise ActiveRecord::Rollback
end
adapter.enqueued_jobs.size # => 1, still enqueued after the rollback
adapter.enqueued_jobs.clear
Article.transaction do
DeferredNotifyJob.perform_later(1)
adapter.enqueued_jobs.size # => 0
end
adapter.enqueued_jobs.size # => 1, enqueued after the commit
adapter.enqueued_jobs.clear
Article.transaction do
DeferredNotifyJob.perform_later(1)
raise ActiveRecord::Rollback
end
adapter.enqueued_jobs.size # => 0, dropped with the rollbackThe adapter hook is out of the decision: on Rails 8.1 the test adapter no longer even responds to enqueue_after_transaction_commit?. A Rails 7.2 :always carried forward is accepted without a warning as a truthy value, so replace it with true. The place to set it is ApplicationJob, which every job inherits from:
class ApplicationJob < ActiveJob::Base
self.enqueue_after_transaction_commit = true
endThat is the same recommendation Solid Queue’s README makes, and its adapter answers true to the old hook that Rails no longer consults. For what happens to a job after it is enqueued, see the life and death of a Solid Queue job. For the Solid adapters as a whole, see the Rails 8 guide.
Instrumenting Transactions with transaction.active_record
Rails 7.2 added the start_transaction.active_record event, emitted when a transaction or savepoint starts, with :connection and :transaction in its payload. It also put the ActiveRecord::Transaction object into the :transaction key of two existing events. transaction.active_record is emitted when the transaction finishes, with :connection, :transaction, and :outcome; on sql.active_record, the key is nil for a query outside a transaction and for the COMMIT statement itself.
A subscriber that logs every finished transaction with its UUID, outcome, and duration:
# config/initializers/transaction_instrumentation.rb
ActiveSupport::Notifications.subscribe("transaction.active_record") do |event|
transaction = event.payload[:transaction]
Rails.logger.info(
"transaction #{transaction.uuid} #{event.payload[:outcome]} in #{event.duration.round(1)}ms"
)
endtransaction ec3e8573-c68e-460e-be37-aaf7cb6dcd91 commit in 1.1ms
transaction 70c66d7c-257f-40d6-aa96-68e1c441d856 rollback in 0.8ms:outcome was :commit and :rollback in these runs, plus :restart for a requires_new: true savepoint that rolled back inside a transaction that then committed. The instrumentation guide documents the full payload of each event.
One caveat: transactions are lazy. A transaction block that runs no query never sends BEGIN, a requires_new: true block that runs no query never creates a savepoint, and neither emits any event. An empty transaction is invisible to this subscriber. For the database side of savepoints, PostgreSQL Savepoints goes deeper.
Subscribing to transaction.active_record tells you that a transaction rolled back or how long it stayed open; it does not tell you which request or job it belonged to, or which query inside it was slow. AppSignal’s Ruby gem records Active Record’s sql.active_record and instantiation.active_record events on every request and background-job trace automatically. Any event your own code emits through ActiveSupport::Notifications appears in the same trace, so a slow or rolled-back transaction shows up in context. The AppSignal Rails integration docs list what is instrumented out of the box, and the custom instrumentation guide covers ActiveSupport::Notifications events.
Wrapping Up
If your app moved from Rails 7.2 to 8.1, three things on this page changed under you: the query_constraints: association option is an error, so rename it to foreign_key; jobs enqueue immediately inside transactions unless the job class sets enqueue_after_transaction_commit = true; and Rails 8.1 requires Ruby 3.2.0 or newer, where 7.2 ran on Ruby 3.1. Everything else here, composite keys, per-transaction callbacks, after_all_transactions_commit, the transaction events, and the dev container generator, works as it did the day 7.2 shipped.
The 7.2 extras outside Active Record are still generated by Rails 8.1 too: jemalloc in the Dockerfile, a .rubocop.yml, a GitHub Actions workflow in .github/workflows/ci.yml, and the progressive web app files under app/views/pwa. The Rails 7.2 release notes list the rest of that release and the Rails 8.1 release notes the current one. For the upgrade itself, the Rails 8 guide covers the path from 7.1 or 7.2 and what changed in 8.1, and the Rails 7.1 counterpart to this reference is the strict locals guide.
P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Frequently asked questions
- What database features did Rails 7.2 add to Active Record?
- Rails 7.2 added composite foreign keys on associations through the foreign_key option, per-transaction after_commit and after_rollback callbacks on the object yielded by transaction, ActiveRecord.after_all_transactions_commit, and a start_transaction.active_record instrumentation event. All of them still work the same way on Rails 8.1.
- Does Rails still enqueue Active Job jobs after the transaction commits by default?
- Not since Rails 8.0. Rails 7.2 deferred enqueuing by default when the queue adapter allowed it. Rails 8.0 deprecated that adapter-driven behavior and Rails 8.1 removed it, so enqueue_after_transaction_commit is now a per-job boolean that defaults to false. Set it to true on ApplicationJob to defer every job.
- How do I use a composite primary key in Rails 8?
- Create the table with primary_key set to an array, for example primary_key: [:shop_id, :id], and Active Record derives the composite key from the schema. Look records up with Order.find([1, 10]), and declare associations with foreign_key: [:shop_id, :order_id]. The old query_constraints association option raises a ConfigurationError on Rails 8.
- What does after_commit do when there is no open transaction?
- The block runs immediately. ActiveRecord::Base.current_transaction returns a closed transaction object outside any transaction block, and registering after_commit on it executes the block right away. An after_rollback registered the same way never runs. ActiveRecord.after_all_transactions_commit behaves the same: immediate outside a transaction, deferred inside one.
- Does rails new --devcontainer still work on Rails 8.1?
- Yes. The flag is opt-in on both Rails 7.2 and 8.1. For an app generated with database postgresql it creates a .devcontainer folder holding devcontainer.json, a compose.yaml with PostgreSQL and Selenium services, and a Dockerfile based on the official Rails dev container image. Existing apps can run bin/rails devcontainer instead.
Published , Updated
Wondering what you can do next?
- Subscribe to our Ruby Magic newsletter and never miss an article again.
- Start monitoring your Ruby app with AppSignal.
- Share this article on social media

Andrew Atkinson
Guest author Andrew Atkinson is a Software Engineer, PostgreSQL and Ruby on Rails specialist, and the author of "High Performance PostgreSQL for Rails".
All articles by Andrew AtkinsonBecome our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!


