How Rails Cache Keys Really Work

Learn how Rails cache keys work, including cache_key, cache_key_with_version, fragment caching, and cache invalidation.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet Ruby on Rails Developer
How Rails Cache Keys Really Work

• 18 min read

Share with friends

Caching in Rails looks simple on the surface. You wrap a block in Rails.cache.fetch, or you wrap part of a view in a cache helper, and suddenly the page is faster. But the moment something goes wrong, the question is almost never "is caching enabled?" It is "why is this cache entry still here?" or "why does this cache never hit?"

Both of those questions come down to the same thing: Rails cache keys.

A cache key is the identity of a cached value. If you understand how Rails builds that identity, cache invalidation stops feeling like a dark art and starts feeling like plain string construction. This guide walks through how Rails cache keys work in Active Record models, in collections, in fragment caching, and inside the cache stores themselves. Everything here targets Rails 8 conventions, and most of it applies cleanly back to Rails 6.


What Is a Cache Key in Rails?

A cache key is a string that Rails uses to store and retrieve a value from the cache store. When you write this:

Rails.cache.fetch("homepage/featured_products") do
Product.featured.limit(10).to_a
end

Rails asks the cache store for the entry stored under homepage/featured_products. If something is there, it is returned and the block never runs. If nothing is there, the block runs, the result is written under that key, and the value is returned.

That is the whole contract. The cache store is a key and value box. Everything interesting in Rails caching is about choosing the right key.

Why Rails Uses Cache Keys at All

You could imagine a caching system where you explicitly delete entries when data changes. Rails supports that, but it does not encourage it, because explicit invalidation is where bugs live. You add a new place that updates a product, you forget to sweep the cache, and now a stale price is on the page for a week.

Rails prefers a different strategy: key-based expiration. Instead of deleting an entry when data changes, Rails changes the key. The old entry is never looked up again. The new key misses, the block runs, fresh content gets written.

Nothing needs to be invalidated, because nothing needs to be found.

What That Means in Practice

Key-based expiration has two important consequences:

  • You rarely call Rails.cache.delete in normal Rails code.
  • Your cache store will accumulate entries that nobody will ever read again, and it is the store's job to evict them.

That second point surprises people, but it is intentional. Cache stores are designed to evict least recently used data. Orphaned entries are a normal, expected cost of this design, and Rails has a feature called recyclable cache keys that reduces it, which we will get to below.


How Rails Generates Cache Keys for Active Record Models

Every Active Record model gets cache key methods for free through the ActiveRecord::Integration module. There are three you need to know: cache_key, cache_version, and cache_key_with_version.

The cache_key Method

cache_key returns a stable identifier for a record:

product = Product.find(42)
product.cache_key
# => "products/42"

The format is the pluralized, underscored model name, a slash, and the record's ID. It does not change when the record changes. It is the record's identity, not the record's state.

The cache_version Method

cache_version returns a string derived from the record's updated_at column:

product.cache_version
# => "20260918143205123456"

product.update!(price_cents: 4_500)
product.cache_version
# => "20260920091144778899"

That string is the UTC timestamp formatted with microsecond precision. It represents the record's state. Any save that touches updated_at produces a new version.

If the model has no updated_at column, cache_version returns nil.

The cache_key_with_version Method

cache_key_with_version is exactly what the name says: the key and the version joined with a hyphen.

product.cache_key_with_version
# => "products/42-20260918143205123456"

This is the classic Rails cache key that most developers picture when they think about caching. It is unique to one record in one specific state. Update the record and the string changes.

The Difference Between cache_key and cache_key_with_version

Here is the short version:

  • cache_key identifies which record it is.
  • cache_version identifies what state the record is in.
  • cache_key_with_version combines both into one string.

The reason Rails splits them apart is a feature introduced in Rails 5.2 and enabled by default ever since, controlled by this setting:

# config/application.rb or a framework defaults file
config.active_record.cache_versioning = true

When cache_versioning is enabled, cache_key is stable and the version lives separately. When it is disabled, cache_key behaves like cache_key_with_version and embeds the timestamp directly:

# with cache_versioning = false
product.cache_key
# => "products/42-20260918143205123456"

You should not turn this off in a modern app. It exists for compatibility with older code that assumed the timestamp was baked into the key.


Cache Key Versioning and Recyclable Cache Keys

Separating the key from the version is what makes recyclable cache keys possible, and it is the single most useful idea in this whole article.

Watch what happens when you hand a record directly to Rails.cache.fetch:

Rails.cache.fetch(product) do
ExpensiveProductReport.new(product).to_h
end

Rails does two separate things here:

  1. It normalizes the key by calling product.cache_key, which gives products/42.
  2. It normalizes the version by calling product.cache_version.

The version is stored alongside the entry, not inside the key. On the next read, Rails compares the stored version to the current version. If they differ, the entry is treated as a miss, the block runs, and the new value overwrites the same key.

That is the recycling part. One record produces one cache slot, no matter how many times it is updated. Compare that with embedding the version in the key, where every update leaves behind an orphan that sits in the store until it is evicted.

Why Versioning Is Useful

  • Cache entries for a record get overwritten instead of duplicated, so the store stays smaller.
  • Hot records stay hot in an LRU store, because they keep reusing the same key instead of constantly creating cold new ones.
  • Staleness is still impossible, because a version mismatch is treated as a miss.

When You Still Get Versioned Keys

Not every part of Rails uses the split form. Fragment caching in views expands the key through ActiveSupport::Cache.expand_cache_key, which prefers cache_key_with_version when the object responds to it. So view fragments do embed the version in the key string. That is expected, and we will look at why in the fragment caching section.


How Record IDs and Timestamps Affect Cache Keys

Since the key is built from the ID and the version is built from updated_at, anything that affects those two columns affects your caching.

New Records

An unsaved record has no ID, so Rails returns a key that reflects that:

Product.new.cache_key
# => "products/new"

Never cache against an unsaved record. Every new instance of the model will collide on the same key.

Records Without updated_at

If a table has no updated_at column, cache_version returns nil and cache_key_with_version is just products/42. The cache entry will then never expire on its own. For tables like this, you need either an explicit expires_in or a custom key built from something that does change.

Timestamp Precision

The version string uses microsecond precision by default, set through ActiveRecord::Base.cache_timestamp_format. This matters because two updates in the same second must produce different versions. If your database column does not store sub-second precision, two rapid updates can produce the same version string and the second update will not invalidate the cache. PostgreSQL stores microseconds by default. On MySQL, make sure your datetime columns are declared with precision, for example datetime(6).

Touching Associated Records

A record's version only changes when that record is saved. If a product's cached fragment shows its category name, updating the category does not change the product's version. The fix is touch:

class Product < ApplicationRecord
belongs_to :category, touch: true
end

Now saving a product updates the category's updated_at, which changes the category's cache_version, which invalidates any cache entry keyed on the category. This is the mechanism behind Russian doll caching, and it is worth being deliberate about, since a chain of touch: true associations means a single write can cascade into several UPDATE statements.


Collection Cache Keys

Single records are the easy case. Collections are more interesting, because a collection can change in three ways: a record is updated, a record is added, or a record is removed.

Active Record relations implement the same three methods:

products = Product.published.order(:name)

products.cache_key
# => "products/query-a91c4f2e1d3b5a7c9e0f2b4d6a8c1e3f"

products.cache_version
# => "37-20260920091144778899"

products.cache_key_with_version
# => "products/query-a91c4f2e1d3b5a7c9e0f2b4d6a8c1e3f-37-20260920091144778899"

Read that carefully, because each piece is doing a job:

  • query-a91c4f... is a digest of the SQL the relation would run. A different scope, a different order, or different bind values produce a different digest.
  • 37 is the number of records in the relation.
  • 20260920091144778899 is the maximum updated_at across the relation.

Together, the count and the max timestamp catch all three kinds of change. An added or removed record changes the count. An updated record changes the max timestamp. A record that is both added and removed between reads changes neither, which is a known and accepted trade-off.

Collection Cache Versioning

Just like models, collections have a setting that controls whether the version is separate:

config.active_record.collection_cache_versioning = true

This is on by default in modern Rails apps. Leave it on so collections get the same recyclable key benefits as individual records.

Be aware that computing cache_version for a relation runs a query, something like SELECT COUNT(*), MAX(updated_at) FROM products WHERE .... That query is cheap only if the columns involved are indexed. If you are caching a large or heavily filtered collection, it is worth reading our practical guide to database indexes in Rails before you assume the version lookup is free.


How Cache Keys Work With Fragment Caching

Fragment caching is where most Rails developers meet cache keys for the first time.

<% cache @product do %>
<article class="product">
<h2><%= @product.name %></h2>
<p><%= number_to_currency(@product.price) %></p>
</article>
<% end %>

The key that actually reaches the cache store looks roughly like this:

views/products/show:8f2a1c9d4e6b3a5f7c0e2d4b6a8c1e3f/products/42-20260918143205123456

Three parts are stacked here:

  • views/ is the fragment cache prefix that Action Controller adds.
  • products/show:8f2a1c... is the template digest, a hash of the template's source and everything it renders.
  • products/42-2026... is the record's cache_key_with_version.

Why the Template Digest Matters

Without the digest, changing your view markup would leave every cached fragment in the store showing the old HTML. The digest solves that: change the template, the digest changes, every fragment built from it misses and gets rebuilt.

The digest is computed from the template and its dependencies, which Rails discovers by scanning for render calls. When you render a partial dynamically, Rails cannot see the dependency, so you declare it explicitly:

<%# Template Dependency: products/product %>
<%= render @renderable %>

If you want a deeper walkthrough of how fragments are nested and how Russian doll caching composes these keys, we covered it in detail in our complete guide to fragment caching in Ruby on Rails.

Skipping the Digest

You can opt out of the digest, though you rarely should:

<% cache @product, skip_digest: true do %>
<%= render "product_summary", product: @product %>
<% end %>

Now the key is just views/products/42-2026.... This is useful when you are constructing keys yourself and want full control, but it means template edits no longer invalidate anything. If you skip the digest, add your own version marker to the key and bump it when you change the markup.

Nesting Fragments

Nested fragments each get their own key, and the inner keys do not automatically bust the outer one:

<% cache @category do %>
<h1><%= @category.name %></h1>

<% @category.products.each do |product| %>
<% cache product do %>
<%= render "products/product", product: product %>
<% end %>
<% end %>
<% end %>

If a product changes but the category does not, the outer fragment still hits, and the stale inner HTML comes along with it. That is exactly the problem touch: true solves. Add it to the association, and a product save bumps the category's version, which changes the outer key, which rebuilds the wrapper while the untouched inner fragments still hit.


Cache Keys With cache and cached: true

Rendering a collection of partials one at a time means one cache read per item. Rails can batch those reads instead:

<%= render partial: "products/product", collection: @products, cached: true %>

With cached: true, Rails computes the key for every item in the collection, issues a single multi-read against the cache store, and renders only the partials that missed. On a page with fifty products where forty-eight are cached, that is one round trip instead of fifty.

Two practical requirements:

  • The partial name must be resolvable up front, so use the explicit partial: and collection: form rather than the render @products shorthand when you want this.
  • Each item still needs a usable cache key, which any persisted Active Record object with an updated_at column already has.

You can combine this with a custom key by passing a lambda:

<%= render partial: "products/product",
collection: @products,
cached: ->(product) { [product, current_currency] } %>

How Cache Stores Use Cache Keys

Once Rails has a key, the store takes over. Before anything is written, the store normalizes the key through ActiveSupport::Cache::Store#normalize_key, which does two things: expansion and namespacing.

Key Expansion and Normalization

You are not limited to strings. Rails expands arrays, hashes, symbols, and objects into a canonical string:

Rails.cache.fetch(["dashboard", current_user, :summary]) { ... }
# key becomes roughly "dashboard/users/7-20260901.../summary"

The rules are straightforward:

  • Arrays are expanded element by element and joined with /.
  • Hashes become key=value pairs, sorted so that ordering does not matter.
  • Objects that respond to cache_key contribute that value.
  • Everything else falls back to to_param.

The important takeaway: you can pass structured data instead of building strings with interpolation, and Rails will produce a deterministic key.

Namespacing

A namespace prefixes every key the store handles. It is set on the store itself:

# config/environments/production.rb
config.cache_store = :solid_cache_store, { namespace: "codecurious_v1" }

Every key becomes codecurious_v1:views/products/.... Namespaces are useful for two reasons: they keep multiple applications from colliding when they share one Redis or Memcached instance, and bumping the namespace instantly invalidates the entire cache, which is a blunt but effective deploy-time escape hatch.

Store-Specific Behavior

Different stores treat keys slightly differently, and the differences are worth knowing:

  • Solid Cache, the default in Rails 8, stores entries in the database. Keys are hashed before storage, so long keys are not a problem, and the cache survives restarts and deploys.
  • Redis Cache Store handles long keys comfortably and supports namespaces natively.
  • Memcached Store enforces a 250-byte key limit. Rails handles this for you by truncating the key and appending a digest of the full value, so correctness is preserved, but readability in your logs suffers.
  • File Store hashes the key into a nested directory path, which keeps filesystem limits manageable.
  • Memory Store lives in a single process. Two Puma workers do not share it, so cache hits will look inconsistent in production.

The practical rule: your key logic stays the same across stores, but very long keys degrade gracefully rather than failing loudly. Do not rely on being able to read a Memcached key verbatim in a log line.


Custom Cache Keys and When You Need Them

The built-in keys cover the common case of "this content depends on this record." You need a custom key when the content depends on something else.

Typical situations:

  • Output varies by current user, role, or permission level.
  • Output varies by locale, currency, or timezone.
  • Output is derived from several records with no single parent.
  • Output depends on an external API response or an expensive computation with no Active Record source.
  • You want a manual version marker so you can bust the cache during a deploy.

Using cache With Custom Keys

The cache helper accepts any of the expandable types, so an array is usually the cleanest choice:

<% cache ["v2", I18n.locale, current_user.role, @product] do %>
<%= render "products/detail", product: @product %>
<% end %>

Each element earns its place:

  • "v2" is a manual version you bump when you make a structural change.
  • I18n.locale prevents French content from being served to English visitors.
  • current_user.role separates the admin view from the public view.
  • @product supplies the record identity and version.

The same idea works outside views:

def dashboard_metrics(user)
Rails.cache.fetch(["dashboard_metrics", user, Date.current], expires_in: 1.hour) do
{
orders: user.orders.completed.count,
revenue: user.orders.completed.sum(:total_cents),
top_product: user.orders.top_product
}
end
end

Here Date.current gives you a daily rollover and expires_in gives you a hard ceiling, which is a sensible combination for data that has no single updated_at to key on.

Composing Keys From Multiple Records

When content depends on several records, combine their versions rather than inventing something fragile:

cache_key = [
"invoice_summary",
invoice,
invoice.line_items.cache_key_with_version,
invoice.customer
]

Rails.cache.fetch(cache_key) do
InvoiceSummary.new(invoice).render
end

Any change to the invoice, its line items, or the customer produces a different key.


Common Mistakes When Creating Custom Cache Keys

Most caching bugs are key construction bugs. These are the ones that show up again and again.

Putting Constantly Changing Data in the Key

# Never hits. Ever.
Rails.cache.fetch(["report", Time.current]) { build_report }

Time.current changes on every request, so every read is a miss and every request writes a new entry. The cache becomes pure overhead: slower than no caching at all, and steadily filling the store with garbage.

The same trap appears in subtler forms, like including rand, a request ID, a session ID, or SecureRandom.uuid in a key. If a value is not stable across the requests that should share a cache entry, it does not belong in the key.

If you want time-based rollover, round it:

Rails.cache.fetch(["report", Time.current.beginning_of_hour.to_i]) { build_report }

Using Only the ID

# Stale forever
Rails.cache.fetch("product/#{product.id}") { render_product(product) }

Nothing in this key reflects the record's state, so the entry survives every update. Pass the record itself and let Rails handle the version.

Forgetting Data That Varies the Output

If the rendered content differs by user, locale, or currency and none of those appear in the key, one visitor's content will be served to another. This is the mistake most likely to become a security incident rather than a display bug. Audit any cached block that references current_user and make sure the key reflects it.

Unbounded Key Cardinality

Rails.cache.fetch(["search", params[:q]]) { Product.search(params[:q]) }

Raw user input in a key means an unlimited number of distinct keys, most of which will be read exactly once. Normalize the input, restrict it to a known set of values, or add an expires_in so the entries do not linger.

Relying on expires_in Instead of Versioning

Time-based expiry is a fallback, not a primary invalidation strategy. It guarantees staleness for the duration of the window. Use version-based keys for anything backed by a record, and reserve expires_in for data that has no version to key on.

Caching Mutable Objects Carelessly

Cached values are serialized and deserialized, so what you read back is a copy, not the object you wrote. Mutating it does not update the cache, and code that assumes otherwise will behave differently on a cache hit than on a miss.


How to Inspect Cache Keys While Debugging

When caching misbehaves, look at the actual keys. The console is the fastest way:

product = Product.first

product.cache_key # => "products/1"
product.cache_version # => "20260918143205123456"
product.cache_key_with_version # => "products/1-20260918143205123456"

Product.published.cache_key_with_version

Check whether an entry actually exists, remembering that the version is passed separately:

Rails.cache.exist?(product.cache_key, version: product.cache_version)
Rails.cache.read(product)

Turn On Fragment Cache Logging

# config/environments/development.rb
config.action_controller.enable_fragment_cache_logging = true

Your development log will then show the full expanded key for every fragment read and write, which is the quickest way to spot a key that is missing a locale or a user role.

Remember that caching is disabled in development by default. Toggle it with:

bin/rails dev:cache

Subscribe to Cache Notifications

For a broader view, subscribe to the Active Support instrumentation events:

# config/initializers/cache_logging.rb
ActiveSupport::Notifications.subscribe(/cache_(read|write|fetch_hit|generate)\.active_support/) do |name, start, finish, _id, payload|
Rails.logger.debug(
"[cache] #{name.split('.').first} key=#{payload[:key]} " \
"hit=#{payload[:hit].inspect} duration=#{((finish - start) * 1000).round(2)}ms"
)
end

This gives you hit rates and key shapes in one place. A run of cache_read events with hit=false on keys that look almost identical is the signature of a volatile value hiding in your key.

Inspect the Template Digest

When a fragment refuses to refresh after a view change, check the digest directly:

ActionView::Digestor.digest(name: "products/show", format: :html, finder: ApplicationController.new.lookup_context)

If the digest is not changing after you edit a partial, you probably have a dependency Rails cannot see, and you need an explicit Template Dependency comment.


Performance and Invalidation Considerations

A few closing points to keep your caching honest.

Caching does not fix bad queries. If a cached block wraps an N+1 query, every cache miss still pays the full cost, and misses happen constantly right after a deploy or an eviction. Fix the query first, then cache the result. Our guide on how to optimize Active Record queries in Rails covers the patterns that matter most here.

Measure the key computation. Building a collection cache key runs a COUNT and MAX query. For most tables, that is trivial. For a large table with an unindexed filter, it can cost more than the work you were trying to avoid.

Watch your touch: chains. Cascading touches keep Russian doll caches correct, but a deep chain turns one write into several. If a hot write path is slower than expected, count the UPDATE statements it triggers.

Prefer versioned keys over deletion. Reaching for Rails.cache.delete usually means the key is missing a dependency. Fix the key instead of adding a sweeper.

Expect misses. Cache hit rate is never 100 percent. Deploys change template digests, stores evict entries, and new records have no cached form yet. Your uncached path needs to be acceptable, not just survivable.


Wrapping Up

Rails cache keys are less mysterious than they look. A model key is the class name and the ID. A version is the updated_at timestamp in microseconds. A collection key is a digest of the query plus the record count and the newest timestamp. A fragment key is all of that with a template digest and a views/ prefix stacked on top.

Once you can predict the key, you can predict the behavior. If content is stale, something that changed is not represented in the key. If nothing ever hits, something volatile is in the key that should not be. Almost every Rails caching problem reduces to one of those two sentences.

Open a console, call cache_key_with_version on the record you are caching, and read the string. The answer is usually right there.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.