Ruby

Rails 7.2 Database Features You Already Have: An Active Record Reference

Rails 7.2 Database Features You Already Have: An Active Record Reference

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.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.

FeatureShipped inOn Rails 8.1Section
Composite foreign_key on has_many and belongs_toRails 7.2.0UnchangedComposite associations
Per-transaction after_commit and after_rollback, plus current_transactionRails 7.2.0UnchangedPer-transaction callbacks
ActiveRecord.after_all_transactions_commitRails 7.2.0Unchangedafter_all_transactions_commit
start_transaction.active_record event and the :transaction payload keyRails 7.2.0UnchangedInstrumenting transactions
rails new --devcontainer and bin/rails devcontainerRails 7.2.0UnchangedDev containers
Active Job enqueue_after_transaction_commitRails 7.2.0Changed 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.1Active 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:

Ruby
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
end

The schema dump records the same array:

Ruby
# 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:

text
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:

Ruby
class Order < ApplicationRecord
  self.primary_key = [:shop_id, :id]
end
Ruby
Order.primary_key
# => ["shop_id", "id"]
Order.composite_primary_key?
# => true

The 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:

Ruby
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:

Ruby
class OrdersController < ApplicationController
  def show
    @order = Order.find(params[:id].split("_"))
  end
end

One 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.

Ruby
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]
end

Both directions generate the two-column condition:

Ruby
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" = 10

The database-level constraint takes the same arrays:

Ruby
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
end

The 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.

text
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:

Ruby
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:

text
ActiveModel::MissingAttributeError: can't write unknown attribute `["shop_id", "id"]`

ActiveRecord::RecordNotFound: Couldn't find Order with '["shop_id", "id"]'=1

text
ActiveRecord::RecordNotFound: Couldn't find Order with '["shop_id", "id"]'=1

Cause: 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).

text
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:

Ruby
class ApplicationController < ActionController::Base
  rescue_from ActiveRecord::RecordNotFound do
    head :not_found
  end
end

ActiveRecord::ConfigurationError: Setting `query_constraints:` option on `OrderItem.belongs_to :order` is not allowed.

text
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:

Ruby
# 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.

text
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:

text
# 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.

text
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:

Ruby
class Event < ApplicationRecord
  self.implicit_order_column = :name
end

Dev 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.

Shell
rails new myapp --database=postgresql --devcontainer

For 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 with DB_HOST: postgres, and runs bin/setup --skip-server once the container is created.
  • .devcontainer/compose.yaml: three services, rails-app, selenium on selenium/standalone-chromium for system tests, and postgres on postgres:16.1 with a named volume for the data.
  • .devcontainer/Dockerfile: ARG RUBY_VERSION=3.4.10 feeding FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION, matching .ruby-version, with BINDING="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:

Ruby
Article.transaction do |transaction|
  article.update(published: true)
 
  transaction.after_commit do
    # Do work after commit, like send a notification email
  end
end

The 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:

Ruby
Article.transaction do |transaction|
  transaction.after_rollback do
    # Runs on ActiveRecord::Rollback and on any other exception
  end
 
  raise ActiveRecord::Rollback
end

Code that does not own the block reaches the same object through ActiveRecord::Base.current_transaction:

Ruby
def publish(article)
  article.update!(published: true)
 
  ActiveRecord::Base.current_transaction.after_commit do
    NotifyJob.perform_later(article.id)
  end
end

Called 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.

Ruby
ActiveRecord::Base.current_transaction.open?
# => false
ActiveRecord::Base.current_transaction.after_commit { puts "ran" }
# ran

The 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:

Ruby
def publish_article(article)
  article.update(published: true)
 
  ActiveRecord.after_all_transactions_commit do
    NotifyJob.perform_later(article.id)
  end
end

Nested transactions, requires_new: true savepoints included, hold it until the outermost commit, and a rollback drops it:

Ruby
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_commit with three values: :default, :always, and :never. The attribute itself defaulted to :never, and config.load_defaults 7.2 set it to :default, which asked the queue adapter through enqueue_after_transaction_commit?. AbstractAdapter answered true, so every adapter that did not override it (async and sidekiq among them) deferred enqueuing until after the commit and dropped the job on rollback, and the test adapter did the same by default. inline answered false, and so did delayed_job and queue_classic unless configured otherwise.
  • Rails 8.0 turned the attribute into a boolean that defaults to false and stopped asking the adapter: load_defaults no longer touches it, :always maps to true, and :never and :default both map to false, each with a deprecation warning. The global config.active_job.enqueue_after_transaction_commit got its own warning, which ends it 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, false unless a job class sets it, and since Rails 8.1.2 perform_all_later honors 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:

Ruby
class NotifyJob < ApplicationJob
  def perform(article_id)
  end
end
 
class DeferredNotifyJob < ApplicationJob
  self.enqueue_after_transaction_commit = true
 
  def perform(article_id)
  end
end
Ruby
ActiveJob::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 rollback

The 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:

Ruby
class ApplicationJob < ActiveJob::Base
  self.enqueue_after_transaction_commit = true
end

That 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:

Ruby
# 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"
  )
end
text
transaction 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?

  • Share this article on social media
Andrew Atkinson

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 Atkinson

Become our next author!

Find out more
$appsignal install

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!

Discover AppSignal