LaunchKit
← All posts
· 15 min read · by The LaunchKit team · 0 views

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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.