has_and_belongs_to_many, expanded
The reason to know what has_and_belongs_to_many expands into is that it accepts options it will
never use, and tells you nothing when you pass one. A dependent: :destroy on that line looks like
the dependent: :destroy on the has_many above it, boots without a warning, and is discarded
before Active Record sees it.
Everything below was run against activerecord 8.1.3.1, Ruby 4.0.5 and PostgreSQL 17.7 on an Apple
M2 Max, in a scratch Rails app with User has_and_belongs_to_many :roles, Role
has_and_belongs_to_many :users, and a join table from create_join_table :users, :roles. The
outputs are copied from bin/rails runner and from bin/rails test.
What the macro declares, printed
Three objects come out of one line, and only one of them has a name you can type.
activerecord-8.1.3.1/lib/active_record/associations/builder/has_and_belongs_to_many.rb:14 builds
an anonymous Class.new(ActiveRecord::Base), line 50 names it HABTM_Roles, and
associations.rb:2015 attaches it to the owner with const_set and hides it again on the next line
with private_constant. Then associations.rb:2042 declares an ordinary has_many ... through:.
hm = User._reflections[:roles]
puts "User._reflections.keys = #{User._reflections.keys.inspect}"
puts "_reflections[:roles].class = #{hm.class}"
puts "_reflections[:roles].macro = #{hm.macro.inspect}"
puts "through_reflection.name = #{hm.through_reflection.name.inspect}"
puts "through_reflection.class_name = #{hm.through_reflection.class_name}"
puts "join model table_name = #{hm.through_reflection.klass.table_name}"
puts "join model superclass = #{hm.through_reflection.klass.superclass}"
puts "parent_reflection.class = #{hm.parent_reflection.class}"
begin
User::HABTM_Roles
rescue NameError => e
puts "User::HABTM_Roles -> #{e.class}: #{e.message}"
end
puts "User.constants(false) = #{User.constants(false).inspect}"
User._reflections.keys = [:roles_users, :roles]
_reflections[:roles].class = ActiveRecord::Reflection::ThroughReflection
_reflections[:roles].macro = :has_many
through_reflection.name = :roles_users
through_reflection.class_name = User::HABTM_Roles
join model table_name = roles_users
join model superclass = ActiveRecord::Base
parent_reflection.class = ActiveRecord::Reflection::HasAndBelongsToManyReflection
User::HABTM_Roles -> NameError: private constant User::HABTM_Roles referenced
User.constants(false) = []
The macro name survives in exactly one place, parent_reflection, which is a
HasAndBelongsToManyReflection carrying the options you wrote and nothing that runs. The
association Active Record actually uses is the ThroughReflection whose macro is :has_many. Every
behaviour on the rest of this page follows from that, including the ones that read as bugs.
You have also seen the hidden class before without knowing what it was. It is the prefix on the log line:
User::HABTM_Roles Create (0.2ms) INSERT INTO "roles_users" ("user_id", "role_id") VALUES (1, 1) /*application='HabtmLab'*/
The table name and the hidden association name are derived by different rules
Two names get computed when the macro runs, and they do not use the same input. The join table name
comes from [lhs_model.table_name, klass.table_name].sort at
builder/has_and_belongs_to_many.rb:88. The hidden has_many gets its name from
[lhs_model.name.downcase.pluralize, association_name.to_s].sort at line 60, which is the class
name rather than the table name, downcased with no underscores inserted.
On User and Role the two rules agree and nobody notices. Put a two-word model on the left and
they come apart:
class BlogPost < ApplicationRecord
has_and_belongs_to_many :tags
end
BlogPost._reflections.keys = [:blogposts_tags, :tags]
BlogPost join table_name = blog_posts_tags
The table is blog_posts_tags and the association is :blogposts_tags. If you ever need to reach
the join rows directly, for a joins or an insert_all, that is the spelling you have to guess,
and blog_posts_tags will raise. It is also worth knowing before you set a custom table_name on
either model, because the table name moves and the association name does not.
The migration generator writes the index you need, commented out
Rails has a generator for this and its output is one line short of correct:
$ bin/rails generate migration CreateJoinTableUserRole user role
invoke active_record
create db/migrate/20260927100001_create_join_table_user_role.rb
class CreateJoinTableUserRole < ActiveRecord::Migration[8.1]
def change
create_join_table :users, :roles do |t|
# t.index [:user_id, :role_id]
# t.index [:role_id, :user_id]
end
end
end
Both index lines are comments, and neither is unique even if you uncomment it. Nothing in Active
Record deduplicates a <<, because a has_many :through has no reason to. With a join table
carrying no unique index, this is what you get:
p = BlogPost.create!(title: "t")
t = Tag.create!(label: "ruby")
p.tags << t
p.tags << t
blog_posts_tags rows = 2
p.reload.tags.size = 2
Tag.count = 1
One tag, two rows, and a collection that reports two members and renders the same tag twice. The
line that prevents it is a unique index on the pair, and with it the second << stops being silent:
RAISED ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "index_roles_users_on_user_id_and_role_id"
That is the right failure mode, and it means every << in your code now needs to be prepared for
it. u.roles << r unless u.roles.include?(r) is the usual answer and it races; a rescue of
ActiveRecord::RecordNotUnique does not.
Three options it accepts and throws away
associations.rb:2038 is an explicit allowlist of the options forwarded to the has_many:
:before_add, :after_add, :before_remove, :after_remove, :autosave, :validate,
:join_table, :class_name, :extend, :strict_loading, :deprecated. Anything else you wrote
stays on the HasAndBelongsToManyReflection that nothing consults. There is no assert_valid_keys
anywhere on that path, so a typo is as quiet as a real option.
class Account < ActiveRecord::Base
self.table_name = "users"
has_and_belongs_to_many :roles, foreign_key: "user_id",
dependent: :destroy,
counter_cache: :users_count,
banana: true
end
the class body raised nothing
options that reached the has_many = {through: :accounts_roles, source: :role}
options kept on the habtm shell = {foreign_key: "user_id", dependent: :destroy, banana: true, counter_cache: {active: true, column: "users_count"}}
roles.users_count after << = 0 (counter_cache: :users_count)
Role rows after account.destroy = 1 (dependent: :destroy)
join rows after account.destroy = 0
banana: true is in there to make the point that nothing is checking. The two real options are the
ones people actually write. counter_cache: never increments anything, which is the same answer
has_many :through gives and for the same reason, covered with the SQL in
Counter caches by hand. dependent: :destroy is the dangerous one,
because the behaviour it appears to ask for is close enough to the behaviour you get that nobody
checks: the join rows do disappear on account.destroy, they just disappear because
associations.rb:2026 generates a destroy_associations method that calls
delete_all(:delete_all) on the join, not because your option did anything.
The Rails Guides say this plainly. guides/source/association_basics.md in the rails/rails checkout
at commit 7d52e01 reads: "You cannot use the :dependent option directly on a
has_and_belongs_to_many association." What it does not say is that writing it anyway is accepted
in silence.
An extra column on the join table
Adding a third column is the moment people discover the join has no model they can reach. A nullable column is fine and does nothing. A NOT NULL column with no default ends every write:
ActiveRecord::NotNullViolation
PG::NotNullViolation: ERROR: null value in column "granted_by" of relation "roles_users" violates not-null constraint
DETAIL: Failing row contains (822, 3315, null).
There is no association API that sets it, because the attribute belongs to the join row and every method on the collection takes the attributes of the far model:
u.roles.create!(granted_by:) -> ActiveModel::UnknownAttributeError: unknown attribute 'granted_by' for Role.
One claim in the Guides did not reproduce. The same file says extra join columns "will be added as
attributes to records retrieved via that association" and that such records "will always be
read-only". On activerecord 8.1.3.1 neither half held: u.roles.first did not respond to
granted_by at all, and the record that came back from an explicit select was writable.
plain = u.reload.roles.first
puts "respond_to?(:granted_by) = #{plain.respond_to?(:granted_by)}"
selected = u.roles.select("roles.*, roles_users.granted_by").first
puts "granted_by = #{selected.granted_by.inspect}"
puts "readonly? = #{selected.readonly?}"
selected.update!(name: "changed")
puts "update! succeeded, name = #{selected.reload.name}"
respond_to?(:granted_by) = false
granted_by = "mehdi"
readonly? = false
update! succeeded, name = changed
That paragraph describes the pre-4.1 implementation, which built the collection with its own
SELECT and marked the results read-only. The current one is a has_many :through, so the select
is SELECT "roles".* and the rows are ordinary records. Read it as a warning that the Guides have
not caught up with the rewrite, not as a licence to put data there.
Timestamps on the join table, the one thing that was supposed to break
The first version of this page said t.timestamps on a join table was a trap, on the grounds that
nothing was going to fill two NOT NULL columns on a table with no model. That was wrong, and one
script settled it:
create_join_table :authors, :books do |t|
t.timestamps
end
inserted, row = [{"author_id" => 1, "book_id" => 1, "created_at" => 2026-09-27 08:07:01.535097 UTC, "updated_at" => 2026-09-27 08:07:01.535097 UTC}]
The join model is a real ActiveRecord::Base subclass, so it fills any created_at and
updated_at it finds. User._reflections[:roles].through_reflection.klass.record_timestamps is
true, and the same object answers nil to primary_key. Timestamps are the one extra column
pair that works, and they are free: you get "when was this role granted" with no model and no
change to your code.
What you still do not get is any way to read that value off u.roles without a select, or to
change it, or to run a callback when it is written.
Nobody deletes the orphans
create_join_table writes no foreign keys, and the association layer only cleans up on the side
that declares the macro. Destroy a User and the join rows go, because of the generated
destroy_associations. Take a Role out with anything that skips callbacks, which includes
delete, delete_all, update_all driven cleanups and every DELETE a DBA runs by hand, and the
join rows stay:
after role.delete (no callbacks):
roles_users rows = 1
u.roles = []
u.roles.count = 0
The reason nobody notices for two years is on the last two lines. The association is an INNER JOIN,
so a join row pointing at a deleted role is invisible to every query you would run to look for it.
u.roles is empty, u.roles.count is 0, and the table grows.
add_foreign_key :roles_users, :users, on_delete: :cascade
add_foreign_key :roles_users, :roles, on_delete: :cascade
foreign_keys = [[:roles_users, "roles", :cascade], [:roles_users, "users", :cascade]]
after role.delete with the FK: rows = 0
An id-less table takes foreign keys perfectly well, and on_delete: :cascade makes the database do
the cleanup the association layer only half does. The cost is an index you have to remember, and it
is not the one create_join_table suggests. A unique index on [:user_id, :role_id] cannot serve a
cascade coming from roles, because role_id is not its leading column, so PostgreSQL scans the
join table once per deleted role. Deleting 50 roles from a roles_users holding 40000 rows took
106.5, 97.8 and 90.8 ms in three runs with no index on role_id, and 9.3, 6.1 and 5.6 ms with one,
same machine and same script with the two halves swapped to rule out ordering. Add both foreign
keys and the single-column index in the migration that creates the table, and add them to the join
tables you already have. All three are additive and safe on a live table, which is the easy end of
the discussion in Migrations that do not break.
role_ids= does the right thing
The setter that a checkbox form posts into is the best-behaved part of the macro, so this section is
short. u.role_ids = ["", "12", "13"], which is the shape a checkbox collection posts, drops the
empty string, and a second assignment diffs rather than replacing. The blank is not hypothetical:
collection_check_boxes(:user, :role_ids, Role.limit(2), :id, :name) renders
<input type="hidden" name="user[role_ids][]" value="" /> before the boxes, so an unchecked form
submits [""].
--- u.role_ids = [b.id, e.id] (swap one) ---
Role Load (0.2ms) SELECT "roles".* FROM "roles" WHERE "roles"."id" IN (2509, 2510) /*application='HabtmLab'*/
Role Load (0.2ms) SELECT "roles".* FROM "roles" INNER JOIN "roles_users" ON "roles"."id" = "roles_users"."role_id" WHERE "roles_users"."user_id" = 18 /*application='HabtmLab'*/
User::HABTM_Roles Delete All (0.6ms) DELETE FROM "roles_users" WHERE "roles_users"."user_id" = 18 AND "roles_users"."role_id" = 2508 /*application='HabtmLab'*/
User::HABTM_Roles Create (0.2ms) INSERT INTO "roles_users" ("user_id", "role_id") VALUES (18, 2510) /*application='HabtmLab'*/
One row out, one row in, the untouched row left alone. Worth knowing that on a persisted record this
writes immediately rather than on the next save, which matters if the surrounding form fails
validation afterwards.
500 rows cost 503 statements
Assignment is a row at a time, because a has_many :through builds and saves one join record per
element. The numbers below are one user, 500 Role rows already in the table, join table empty at
the start, bin/rails runner in the development environment, Ruby 4.0.5 with activerecord 8.1.3.1
against PostgreSQL 17.7 on localhost:15432, on an Apple M2 Max with 12 cores. Statements are counted
from sql.active_record notifications with SCHEMA and cached queries excluded.
u.roles = [500 Role objects] : 503 statements, 419.3 ms
insert_all on roles_users : 1 statements, 12.0 ms
Four runs gave 377.1, 385.7, 411.1 and 419.3 ms for the assignment and 7.5 to 12.6 ms for
insert_all. The 503 is one SELECT to load the current collection, BEGIN, 500 INSERTs and
COMMIT.
The awkward part of the fix is that insert_all needs a class, and the only class over that table
is private. This works and I would not put it in application code:
User._reflections[:roles].through_reflection.klass.insert_all(rows)
Declaring your own class RolesUser < ApplicationRecord; self.table_name = "roles_users"; end
beside the macro is the honest version, at which point you have written half of the has_many
:through anyway. Reading is not affected by any of this: includes(:roles) preloads a
has_and_belongs_to_many exactly as it preloads any other collection, with the caveats in
N+1 queries in Rails.
Moving to has_many :through needs no migration
The received answer is that you cannot promote a has_and_belongs_to_many join table to a
has_many :through without adding a primary key, and association_basics.md in the same checkout
still says the join table for a has_many :through "requires an id". Composite primary keys
landed in 7.1, where the release notes name "many-to-many relationships" as the case they are for,
and on 8.1.3.1 the whole conversion is two model files:
class Grant < ApplicationRecord
self.table_name = "roles_users"
self.primary_key = [:user_id, :role_id]
belongs_to :user
belongs_to :role
end
class User < ApplicationRecord
has_many :grants
has_many :roles, through: :grants
end
Skip the primary_key line and most things still work, which is what makes it hard to diagnose.
Reads work, m.roles << r works, Grant.count works. Then someone calls Grant.first in a
console:
ActiveRecord::MissingRequiredOrderError: Relation has no order values, and Grant 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.
Grant.take gets around that one. destroy does not, and its error names nothing you would search
for:
ActiveRecord::StatementInvalid: PG::SyntaxError: ERROR: zero-length delimited identifier at or near """"
LINE 1: DELETE FROM "roles_users" WHERE "roles_users"."" IS NULL /*a...
^
The third line is the whole story: Active Record built a WHERE clause out of a primary key that is
nil, so the column it quoted is the empty string. With the composite key set, Grant.first.id
returns [6, 6], destroy deletes the join row and leaves the Role alone, and you now have a
place to hang granted_by, a validation, and a before_destroy.
The call, and what would change it
Use has_and_belongs_to_many when the pair of ids is the entire fact, and add a unique index and
two foreign keys in the same migration. Tags on a post, roles on a user in an app where a role is
granted by exactly one screen: the macro is four words, it reads well, and everything on this page
is either not a problem for that shape or fixed by those three lines of schema.
Reach for has_many :through the first time somebody asks when, or by whom, or under what
conditions. Not because the join will eventually need columns, which is the usual way this argument
is made and is a prediction rather than a reason, but because the migration back is free and the
macro's failure modes are all silent. A dependent: that no-ops, a counter_cache: that never
fires and an unrecognised key that raises nothing are three chances to be wrong with a green build.
What would change the verdict: an assert_valid_keys on the habtm option list, which would turn all
three of those into a boot-time error and remove most of the argument against the macro. The
allowlist at associations.rb:2038 already enumerates exactly what is supported, so the check is
available for the writing.
What this post does not cover
Scopes and extensions on the association, which are forwarded as the scope argument and behave as
they do on any has_many. accepts_nested_attributes_for over a habtm, which works and is a form
question rather than an association one. Self-referential many-to-many, where both sides are the
same class and the foreign_key and association_foreign_key options stop being optional.
Composite primary keys on the joined models themselves, as opposed to on the join row, which is a
different feature that happens to share a method name. And MySQL and SQLite, where the index and
foreign key behaviour above was not tested; every output on this page came from PostgreSQL 17.7.
The scratch app is a rails new with six models and one migration, and the claims are pinned by 17
Minitest examples that run in 0.21 seconds against any PostgreSQL you point them at.
Comments
No comments yet. Be the first.