How Rails Cache Keys Really Work
Learn how Rails cache keys work, including cache_key, cache_key_with_version, fragment caching, and cache invalidation.
• 18 min read
• 18 min read
Learn how Rails cache keys work, including cache_key, cache_key_with_version, fragment caching, and cache invalidation.
• 18 min read
• 18 min read
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.
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
endRails 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.
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.
Key-based expiration has two important consequences:
Rails.cache.delete in normal Rails code.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.
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.
cache_key Methodcache_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.
cache_version Methodcache_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.
cache_key_with_version Methodcache_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.
cache_key and cache_key_with_versionHere 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.
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:
product.cache_key, which gives products/42.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.
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.
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.
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.
updated_atIf 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.
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).
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.
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.
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.
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.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.
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.
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 and cached: trueRendering 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:
partial: and collection: form rather than the render @products shorthand when you want this.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] } %>
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.
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:
/.key=value pairs, sorted so that ordering does not matter.cache_key contribute that value.to_param.The important takeaway: you can pass structured data instead of building strings with interpolation, and Rails will produce a deterministic key.
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.
Different stores treat keys slightly differently, and the differences are worth knowing:
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.
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:
cache With Custom KeysThe 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.
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.
Most caching bugs are key construction bugs. These are the ones that show up again and again.
# 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 }# 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.
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.
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.
expires_in Instead of VersioningTime-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.
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.
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)
# 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
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.
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.
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.
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.