Introduction
Composite Primary Keys (CPKs) are one of those “real world engineering” features that frameworks eventually grow into. Many enterprise databases, analytics systems, geographic indexes, and ledger tables naturally model identity using more than one attribute.
For years, Rails avoided this space and assumed a single integer column called id, and our applications were expected to adapt with Rails 8, this is finally changing. The framework now understands identity that is multi column, not just “one number per record”.
Even more interesting: Rails does this without requiring external gems, and without asking us to break conventions everywhere. The feature is still maturing, but its foundations are strong enough that we can start building real systems on top of it.
In this post, we walk through the full Rails journey where we came from, why this matters, how to use the new API, and how to migrate from gem based systems to native CPK support thoughtfully.
A Brief History of Identity in Rails
Rails’s early identity model was extremely simple:
- Every table has an integer
idcolumn idauto increments- Routing uses
id - Forms bind to
id - Associations pivot around
id
This design powered most CRUD apps beautifully.
If our tables looked like id | name | email, everything worked.
But the moment we had domain identity, things got messy:
invoice_id + line_numbertenant_id + user_idcountry_code + region_codeproduct_sku + release_version
Rails had no native vocabulary for “identity composed of two columns”.
We carried the burden: custom finders, 2 column joins everywhere, brittle URLs, hand rolled validations.
A community maintained gem— composite_primary_keys —stepped in to fill the gap.
For many years, it was the only viable approach for large systems. We respect the gem. It proved the problem was real. But Rails itself stayed opinionated: one row, one id.
Cracks in the Model: Rails 6 and Rails 7
Rails 6 and 7 did not deliver composite keys, but they softened assumptions that identity must equal “one integer”.
UUID Primary Keys
We could finally declare identity as:
create_table :orders, id: :uuid do |t|
t.string :customer
endActiveRecord learned to treat the primary key as non-numeric.
Custom Primary Key Names
We could express:
class Country < ApplicationRecord
self.primary_key = :iso_code
endWe understood that identity could be semantic, not machine generated. These were meaningful steps, yet they did not answer the core question:
“What if identity is multiple attributes acting together?”
Rails still expected a scalar primary key.
Rails 8 Changes the Conversation
Rails 8 introduces a crucial piece of architecture ActiveRecord can now accept composite primary keys through id= and parameter serialization. This is the foundational change. We can finally describe identity as a tuple.
Example: Tenant/User Model
Let’s imagine a shared schema SaaS:
create_table :users, id: false do |t|
t.string :tenant_id
t.string :user_id
t.primary_key [:tenant_id, :user_id]
t.string :email
endAnd inside model:
class User < ApplicationRecord
self.primary_keys = :tenant_id, :user_id
endNow:
user = User.find(["acme", "john_doe"])
user.id
# => ["acme", "john_doe"]Rails treats this as a single identity, not a coincidence of two attributes. We do not patch the ORM. We do not override find_by. We do not teach Rails to “pretend”. Identity is encoded at the model level and reflected everywhere.
Routing Finally Becomes Sensible
Before native support, our routes became messy:
User.find_by(tenant_id: params[:t], user_id: params[:u])Or we encoded our own surrogate:
/users/acme--john_doeRails 8 supports a normalized param format.
user.to_param
# "acme:john_doe"Routing becomes:
GET /users/acme:john_doeAnd lookup:
User.find(params[:id])Rails decodes the tuple.
This is the difference between supporting composite keys and merely allowing two columns.
What Rails 8 Native Support Does Well
- Multi column primary key definition
- Identity hydration via
id= to_paramencoding and parsing- Parameter reconstruction in controllers
findandexists?operations using composite identity
The key here is consistency—identity flows end-to-end.
What Rails Does Not Attempt (Yet)
Rails 8 does not try to replace everything the gem can do.
That’s the right decision.
- Associations expecting a single PK are unchanged
- Deeply nested join tables still require discipline
- Polymorphic associations are not CPK-aware
- ActiveRecord query helpers behave conservatively
Rails 8 is not a magical CPK framework. It is a native identity foundation. That foundation is enough for most modern use cases.
Designing with CPK Is Not “Just Add Two IDs”
We should resist the temptation to treat CPK as a “clever trick”. Composite identity must reflect the domain.
Good Examples
tenant_id + invoice_numbercountry_code + postal_codeledger_account + entry_index
Bad Examples
tenant_id + idbecause we don’t want to shard correctlyuser_id + random_tokenbecause we don’t want to validate uniqueness
Composite keys should express meaning. When identity has meaning, queries become stable, URLs become readable, and migrations become safer.
Migration Strategy: From composite_primary_keys Gem to Rails Native
Many organizations have lived with the gem for years. Migrating is possible, but we must approach it deliberately. We recommend a progressive strategy.
Step 1: Make Identity Real in the Database
Do not rely on surrogate integer keys plus unique indexes. Prefer:
create_table :subscriptions, id: false do |t|
t.string :customer_id
t.string :service_code
t.primary_key [:customer_id, :service_code]
endRails thrives when DB identity is canonical.
Step 2: Move Identity to the Model
class Subscription < ApplicationRecord
self.primary_keys = :customer_id, :service_code
endRemove internal helpers like:
def self.find_by_identity(customer, service)
find_by(customer_id: customer, service_code: service)
endIdentity no longer needs abstractions.
Step 3: Let Controllers Rely on Param Identity
Before:
@subscription = Subscription.find_by(
customer_id: params[:customer],
service_code: params[:service]
)After:
@subscription = Subscription.find(params[:id])Rails resolves the encoded form automatically.
Step 4: Retire Gem Semantics Gradually
The gem introduced patterns Rails never intended:
- Identity arrays as first class objects
- Extra join syntax
- Custom finder APIs
Rails expects model driven identity, not “ORM override first”. Systems become healthier when our domain language is reflected in schema, not in monkey patches.
When Native Support Is Enough
We can adopt native CPK today if:
- Keys are 2–3 columns
- Associations are shallow
- We own the schema
- We do not need polymorphic CPKs
- Identity conveys business meaning
This covers 90% of real world use cases.
When the Gem Is Still Justified
The gem remains valuable for legacy enterprise scenarios:
- Warehouse/ERP migration from Oracle/DB2
- Multidimensional historical keys
- 5+ column identities
- Intersecting many-to-many composite foreign keys
For these domains, CPK isn’t a convenience—it’s a survival mechanism. Rails will evolve, but the gem’s niche will live on.
Important Note About Rails 8 Compatibility
As of December 2025, the composite_primary_keys gem does not yet support Rails 8.
This means if we’re currently using the gem on Rails 7 or earlier, we have two paths forward:
- Stay on Rails 7 until gem support arrives
- Migrate to native Rails 8 CPK support if our use case fits within the native capabilities
For teams planning to upgrade to Rails 8, this is an excellent opportunity to evaluate whether native CPK support meets our needs.
If our application uses simple 2-3 column composite keys without complex associations, migrating to native support is likely the better long term choice.
