Rails Native Composite Primary Keys: A Complete Evolution from Rails 3 to Rails 8

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 id column
  • id auto 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_number
  • tenant_id + user_id
  • country_code + region_code
  • product_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
end

ActiveRecord learned to treat the primary key as non-numeric.

Custom Primary Key Names

We could express:

class Country < ApplicationRecord
  self.primary_key = :iso_code
end

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

And inside model:

class User < ApplicationRecord
  self.primary_keys = :tenant_id, :user_id
end

Now:

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_doe

Rails 8 supports a normalized param format.

user.to_param
# "acme:john_doe"

Routing becomes:

GET /users/acme:john_doe

And 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_param encoding and parsing
  • Parameter reconstruction in controllers
  • find and exists? 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_number
  • country_code + postal_code
  • ledger_account + entry_index

Bad Examples

  • tenant_id + id because we don’t want to shard correctly
  • user_id + random_token because 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]
end

Rails thrives when DB identity is canonical.

Step 2: Move Identity to the Model

class Subscription < ApplicationRecord
  self.primary_keys = :customer_id, :service_code
end

Remove internal helpers like:

def self.find_by_identity(customer, service)
  find_by(customer_id: customer, service_code: service)
end

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

  1. Stay on Rails 7 until gem support arrives
  2. 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.

References

Need help on your Ruby on Rails or React project?

Join Our Newsletter