Cache keys in Rails
The key you write is not the key the store gets. Rails.cache.fetch(["sidebar", user, product]) looks
like it hands three objects to a cache, and what actually arrives at the store is the string
"sidebar/users/7/products/12" with the version "20260924142512935621/20260113093000000000"
attached to the entry beside it. Almost every confusing thing about caching in Rails lives in the gap
between those two, and the gap is about eighty lines of active_support/cache.rb that nobody reads
until something goes stale.
Everything below was run against activesupport 8.1.3.1 and activerecord 8.1.3.1, on PostgreSQL 17.7 on port 15432, and the output is copied out of the scripts rather than remembered.
What Rails hands the store
A Rails cache key is a String by the time any store sees it, whatever you passed in.
Store#normalize_key is the whole translation, and it is two calls: expand and namespace, then
truncate. The expansion is expanded_key, at active_support/cache.rb:1037, and its rules are worth
having memorised because they are the source of the surprises further down.
Here is every rule, printed by calling the private method directly on a MemoryStore:
string: "views"
symbol: "views"
array: "v/1/x"
nested array: "v/1/2"
hash: "a=1/b=2"
record: "ck_products/1"
arr+record: "frag/ck_products/1"
nil-in-array: "v/"
false element: "u/false"
empty string: ArgumentError: key cannot be blank
nil key: ArgumentError: key cannot be blank
Arrays join on /, via to_param. Hashes become k=v pairs and are sorted, so {b: 2, a: 1} and
{a: 1, b: 2} are the same key, which is deliberate and useful. Anything responding to cache_key
is asked for it, and the branch is first in the method, so a record inside an array contributes its
own key rather than its to_s.
Then truncation, which people discover the hard way. ActiveSupport::Cache::MAX_KEY_SIZE = 250 sits
at line 195, and line 307 applies it to every store unless you pass max_key_size: false. A key of
306 bytes comes out like this:
len 250: ...aaaaaaaaaaaaaaaaa:hash:edf624d4bc51c6a90f32444d6c47f6f5
The tail is replaced by a 32 character digest of the full key, which keeps it unique and makes it unreadable in a log.
The stable key and the version beside it
ActiveRecord::Base.cache_versioning is false on the class itself and true in any application
that calls config.load_defaults 5.2 or later, which railties sets at
rails/application/configuration.rb:139. The flag changes what a record answers, and both answers
are below, from the same row.
With versioning off, which is how Rails 5.1 behaved:
cache_key: "ck_products/1-20260924103000000000"
cache_version: nil
cache_key_with_version: "ck_products/1-20260924103000000000"
With versioning on, which is the default you have:
cache_key: "ck_products/1"
cache_version: "20260924103000000000"
cache_key_with_version: "ck_products/1-20260924103000000000"
Same string at the end, split differently in the middle. Twenty digits, because the format is
:usec. Two code paths produce them. The fast one, raw_timestamp_to_cache_version in
active_record/integration.rb, strips - :. out of the raw database string and pads to twenty
characters, with a comment naming the PostgreSQL commit that made the padding necessary: the server
truncates trailing zeros, so a whole second arrives shorter than a fractional one. The slow one
formats the parsed Time.
Which path you get depends on your adapter, and the fast one is not the Postgres one:
updated_at_before_type_cast class: Time
can_use_fast_cache_version?: false
can_use_fast_cache_version? requires timestamp.is_a?(String), and the pg adapter has already
built a Time by then. The output is identical either way, so this costs you an object allocation
per cache_version call and nothing else. Worth knowing only because the method name promises a
fast path most Rails applications never take.
Two smaller facts. An unsaved record answers "ck_products/new", so caching a Model.new gives every
unsaved instance one shared key. And the prefix is not the table name: model_name.cache_key is
aliased to ActiveModel::Name#collection at active_model/naming.rb:15, which is tableize of the
class name, so Blog::Comment answers "blog/comments" whatever its table_name is. A namespaced
model therefore puts a slash inside the part of the key you thought was one segment, which matters
for the collision in "Separators collide because the key is a flat string" below.
Why the versioned key is called recyclable
Recyclable cache keys is the name the feature shipped under. The Rails 5.2 release notes put it in
the Active Support section as "support for versioned cache entries. This enables the cache stores to
recycle cache keys, greatly saving on storage in cases with frequent churn", and the API docs for
cache_version call it "a recyclable caching scheme". Recyclable is a claim about rows, not about
strings. Under Rails 5.1 an edited product produced a brand new key, the old
entry stayed in the store holding bytes nobody would ever read again, and you paid for it until the
LRU or the expiry got around to it. Under the split, the key does not move, so the new value
overwrites the old one.
Counted on a real Solid Cache table in PostgreSQL, writing a record, touching it, writing it again:
rows before=4 after=4
key before: development:ck_product2s/1
key after: development:ck_product2s/1
read with fresh record: "v2 html"
One row, reused. On a site with a hundred thousand products and a fragment each, the pre-5.2 scheme
made the cache grow with the edit rate rather than with the catalogue, and eviction pressure meant a
busy product could push a quiet one out. That is the whole argument, and it is a better one when the
store has no eviction at all: Solid Cache trims by max_size rather than by LRU, a difference
Solid Cache against Redis goes through, and a store that trims oldest
first does not want a pile of superseded rows.
Recycling has two costs. You can no longer look at a key and know which version of the record
produced it, because the version lives inside the entry. And on Solid Cache the recycled row keeps
its original identity: Entry.write_multi upserts with update_only: [:key, :value, :byte_size], so
id and created_at survive the overwrite.
first write: id=1 created_at=2026-09-24T14:32:42.561730Z value_bytes=17
second write: id=1 created_at=2026-09-24T14:32:42.561730Z value_bytes=41
Entry::Expiration#expiry_candidate_ids then picks victims with order(:id).limit(count * 3) and
.sample(count). Lowest ids first, which is oldest first write, not least recently used. A fragment
rewritten a thousand times a day ages out on the day it was first written.
The version check is an equality test, and nil skips it
ActiveSupport::Cache::Entry#mismatched? is one line, at active_support/cache/entry.rb:37:
def mismatched?(version)
@version && version && @version != version
end
Read the two guards before the comparison. When the stored entry has no version, there is no mismatch. When the read supplies no version, there is no mismatch. The check is not "is the stored version older", it is "are both present and different".
Which produces the most common stale fragment report there is:
--- a plain String key gets no version ---
read after touch: "html"
That entry was written under "products/1", the record was then updated, and the read still hits.
normalize_version("products/1") is nil, because a String has no cache_version, so the version
branch never runs. The fragment is frozen until the expiry or a manual delete. Pass the record and
the same sequence misses.
The other half of the same rule is that the comparison is equality, not recency. A stale in-memory record reads its own stale entry perfectly well:
stale version: 20260924142512935621 fresh version: 20260924142624113906
read(fresh): nil read(stale): "html v1"
Until something writes with the fresh version, at which point the row is recycled and the stale reader misses too. Two objects for one database row, disagreeing about the cache, and no exception at any point.
A record you did not fully load has no key
cache_key on a record loaded with a partial select does not fall back and does not return nil:
SQL> SELECT "ck_products"."id" FROM "ck_products" ORDER BY "ck_products"."id" ASC LIMIT 1
select(:id).first.cache_key -> ActiveModel::MissingAttributeError: missing attribute 'updated_at' for CkQ
The raise is explicit in integration.rb: cache_version checks has_attribute?("updated_at") on
the instance, and when the instance lacks it but the class has it, it raises rather than guess. The
reasoning is sound. A cache key derived from an attribute you did not load would be a key that never
changes, which is the silent failure from the previous section promoted to a design.
Where this bites is an index action optimised with select(:id, :name) feeding a partial that calls
cache product. The optimisation and the cache are in different files, the exception only fires when
fragment caching is on, and fragment caching is off in development by default. So the first machine
to see it is production.
Add updated_at to the select, or stop selecting columns. There is no third option, and overriding
cache_version to return something cheaper is a fourth option that works and that you will regret,
because the override applies to every use of that model rather than to the one query you were tuning.
A relation has its own key and a query to pay for it
A relation answers cache_key too, and the construction is different: a digest of the SQL, not of
the rows.
relation.cache_key: "ck_ps/query-009aabf0544a7f513c9f5d92d54c2fe9"
relation.cache_version: "1-20260924142624113906"
relation.cache_key_with_version: "ck_ps/query-009aabf0544a7f513c9f5d92d54c2fe9-1-20260924142624113906"
compute_cache_key is ActiveSupport::Digest.hexdigest(to_sql), so two relations differing only in a
bound value share nothing, and the same relation built two ways with the same SQL shares everything.
The version is count and maximum timestamp, joined, and getting it costs a query:
SELECT COUNT(*) AS "size", MAX("ck_products"."updated_at") AS timestamp
FROM "ck_products" WHERE (id > 0)
Count plus max catches an insert, an update and a delete, which is exactly the set of changes that should invalidate a list. It does not catch a delete paired with an insert in the same second, which is rare enough to accept and worth knowing about.
An empty relation answers "0":
empty cache_key: "ck_qs/query-339ec6bf431a986aa91f4c403e52e263"
empty cache_version: "0"
So an empty list caches, which is usually right and occasionally the reason a newly seeded collection
renders blank. The COUNT(*) is the real cost here. On a large filtered set it is the same scan that
makes a page number bar expensive in Pagination without a gem, and
you are paying it on every render to decide whether to skip a render.
Separators collide because the key is a flat string
Array keys read as structured and are not. Three different arrays, one string:
"user/1/posts"
"user/1/posts"
"user/1/posts"
equal? true
Those are ["user", 1, "posts"], ["user/1", "posts"] and ["user", "1/posts"]. Nothing in
expanded_key escapes the separator, so any element containing a slash merges into its neighbours.
For integers and record keys that never happens. For a slug, a path, an email address or anything a
user typed, it happens the day somebody registers a name with a slash in it.
The sharper version is nil:
nil element: "u/" vs "u/"
["user", nil] and ["user", ""] are the same key, and so is ["user", current_user&.id] for every
logged out visitor. A cache of per-user sidebars keyed that way serves one shared entry to everybody
who is not signed in, which is correct by accident, and the same construction one line later serves
one shared entry to every user whose id lookup returned nil, which is not.
Single element arrays unwrap, so ["solo"] and "solo" are the same key. And the version of an array
key is the versions of its elements joined the same way, which means mixed keys work as you would
hope:
key: "sidebar/things/1/things/2"
version: "111/222"
Change either object's version and the read misses. Add a bare String to that array and it contributes nothing to the version, silently.
Nothing will list the keys for you
ActiveSupport::Cache::Store defines no keys method, no each_key, no entries, and neither does
any store shipped with Rails:
Store responds to :keys? false
MemoryStore responds to :keys? false
Listing cache keys is not a slow operation in Rails, it is an absent one, and the absence is a design position rather than an oversight. A cache key namespace is not a directory, entries expire out from under any enumeration, and a store that answered the question would encourage the pattern that answer enables, which is a client-side scan over a shared server.
What each store will give you, if you go under the API, differs enough to be worth listing plainly.
MemoryStore holds a plain Hash in @data, so @data.keys works and is safe because the store is
per-process. FileStore writes one file per key under a two level hash directory, URL escaped:
["/564/8E0/user%2F1%2Fposts"]
Walking that directory and unescaping is what delete_matched does internally. Redis has SCAN,
which the store uses internally and does not expose. For Memcached the Rails store offers nothing at
all. Solid Cache is the odd one out, and the next section is about why that is less useful than it
sounds.
Solid Cache stores the key in a column and still cannot find it
Solid Cache keeps every entry as a row, so the keys are right there in a table you can query, which looks like the exception to the rule above. Reading the table works:
key=development:views/articles/index key_hash=32069871337534504 byte_size=193
key=development:user/7/sidebar key_hash=5915700926025593794 byte_size=198
key=development:user/8/sidebar key_hash=3949980635683725464 byte_size=198
Now look at the schema the gem generates, in db/cache_schema.rb. Three indexes: one unique on
key_hash, one on byte_size, one on the pair. None on key. Every read goes through
key_hash_for, which is Digest::SHA256.digest(key.to_s).unpack("q>").first, the first eight bytes
of a SHA256 read as a signed integer. Exact lookup by hash, and nothing else.
So a prefix query is a sequential scan over a bytea column, and PostgreSQL will show you exactly
that:
Seq Scan on solid_cache_entries (cost=0.00..18.50 rows=3 width=8) (actual rows=0 loops=1)
Filter: (key ~~ '\x757365722f25'::bytea)
Rows Removed by Filter: 3
Two things in four lines. The pattern was compiled to a hex bytea literal, because the column is
binary rather than text. And it matched nothing, on a table that visibly contains two user/ keys,
because the stored keys begin development: and the pattern did not. That is the namespace from the
next section, doing its job, and quietly turning a maintenance query into a no-op.
Deleting by pattern, and the four stores that allow it
delete_matched exists on the base class as a raise, at active_support/cache.rb:732. In
activesupport 8.1.3.1 exactly four stores override it, and grepping is the fastest way to settle the
question for whatever version you are on:
memory_store.rb:175: def delete_matched(matcher, options = nil)
null_store.rb:34: def delete_matched(matcher, options = nil)
file_store.rb:89: def delete_matched(matcher, options = nil)
redis_cache_store.rb:210: def delete_matched(matcher, options = nil)
MemCacheStore is not on that list, and neither is Solid Cache, which means the Rails 8 default store does not support the method at all:
delete_matched -> NotImplementedError: SolidCache::Store does not support delete_matched
cleanup -> NotImplementedError: SolidCache::Store does not support cleanup
Read the class of that error before you write a fallback around it:
NotImplementedError.ancestors: [NotImplementedError, ScriptError, Exception, ...]
NotImplementedError <= StandardError? nil
NotImplementedError descends from ScriptError, not StandardError, so rescue => e does not
catch it and neither does a bare rescue in a method body. The defensive wrapper somebody writes
around a store that might not support the method therefore does not work, and the script aborts with
a stack trace instead of printing the fallback message. The two stores that matter differ in argument
type as well: MemoryStore and FileStore take a Regexp, Redis
takes a glob String and raises ArgumentError, "Only Redis glob strings are supported" for anything
else. Code that works on a development MemoryStore fails in production on Redis, on the type of its
own argument.
What the Redis glob costs the server
RedisCacheStore's delete_matched is a SCAN loop with MATCH, batched, followed by UNLINK. The
batching is real and the comment says why: "Fetch keys in batches using SCAN to avoid blocking the
Redis server." What batching does not change is the amount of work. SCAN with MATCH filters on
the server after reading, so the cursor walks the entire keyspace whatever the pattern is, and the
filter only decides what comes back over the wire.
On a cache holding two million keys, a delete_matched("views/products/*") reads two million keys to
find the four thousand it wants, in batches, competing with every request in flight. Redis stays
responsive, which is the point of SCAN, and the operation is still linear in a number you did not
choose.
The failure mode nobody plans for is the second call. A deploy hook, a rake task somebody runs twice,
or a before_action that clears a pattern will happily run several of those loops concurrently, and
they do not coordinate. Cluster handling is worth reading before trusting it too: the implementation
checks c.respond_to?(:nodes) and loops over them while sharing one cursor variable across the whole
loop, which is enough to make it something to test rather than assume.
None of that makes pattern deletion wrong. It makes it an operation with a cost proportional to your whole cache, called from application code that looks like it costs nothing.
What to reach for instead of a pattern delete
Wanting to delete by pattern almost always means the key does not carry what changed. The fix is to put it in the key, which the framework already does for records and which you can do for anything else.
Version the key by hand when the subject is not a record. A settings object, a price table, a feature flag: keep a counter or a timestamp somewhere cheap and make it part of the key.
def self.price_list_version = PriceList.maximum(:updated_at).to_i
Rails.cache.fetch(["pricing-table", price_list_version, plan.id]) { render_table(plan) }
Bump the source and every key derived from it changes at once. Nothing is deleted, the dead entries
age out on their own, and the operation that used to be a scan is now an integer read. On Solid Cache
the dead rows are trimmed against max_size, which config/cache.yml sets to 256 megabytes in a
generated app, so the cost of not deleting is bounded by configuration rather than by your discipline.
The same trick at store level is the :namespace option, covered next, since bumping a namespace
invalidates everything under it in one assignment.
When you genuinely need to enumerate, keep your own index. A Set under a known key, or a small table,
written when you write the entry. Both are more work than delete_matched and both are honest about
the cost, which is the trade. And if the thing you want to enumerate is really a queue or a counter,
the cache was the wrong store for it: durability is the difference, and
Running Rails 8 without Redis is where that line gets drawn.
The namespace your application already set
rails new generates a config/cache.yml, and both this sales site and the LaunchKit boilerplate
carry it unedited:
default: &default
store_options:
max_size: <%= 256.megabytes %>
namespace: <%= Rails.env %>
So every key written in development is stored as development: plus the key, and every key in
production as production: plus the key. The prefix is added by namespace_key at
active_support/cache.rb:1012, and the separator is a colon rather than the slash used inside the
key, which is the one bit of punctuation in the whole scheme that tells you where Rails stops and you
start:
ns string: "v3:views"
ns array: "v3:v/1"
ns proc: "rel-abc:views"
per-call: "other:views"
A namespace can be a Proc, evaluated per call, which is the supported way to bump everything at once: set it to a deploy identifier and a release invalidates the whole cache without a single delete.
key_matcher translates a pattern into the namespaced space for you, and its two branches are worth
knowing: an anchored /^user/ becomes /^v3:user/, an unanchored /user/ becomes /^v3:.*user/.
So delete_matched does handle the prefix, and only for the stores that implement it. A query you
write yourself against Solid Cache's table does not, which is the empty result from three sections
back.
One asymmetry to know before you rely on it. clear respects the namespace on Redis, where it is
implemented as delete_matched "*" scoped to the prefix. On MemoryStore it is @data.clear and on
Solid Cache it is a TRUNCATE. Two applications sharing a Solid Cache database are one
Rails.cache.clear away from emptying each other, namespace or no namespace.
The key the boilerplate actually writes
The LaunchKit boilerplate calls Rails.cache nowhere and caches no fragment in any view. What it does
have is four rate_limit declarations, in sessions_controller.rb, passwords_controller.rb,
registrations_controller.rb and email_confirmations_controller.rb, all of the shape
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { rate_limited }. Rails 8 implements
that on top of the cache store, so those four lines are the only cache keys the product's own code
produces.
The construction is one line, in
action_controller/metal/rate_limiting.rb:75:
cache_key = ["rate-limit", scope, name, by].compact.join(":")
count = store.increment(cache_key, 1, expires_in: within)
Note the joiner. Colons, not slashes, and built by hand rather than through expanded_key, so this
one key in the framework does not follow the convention the rest of this post describes. With scope
defaulting to controller_path and by to request.remote_ip, the product writes:
"rate-limit:sessions:203.0.113.9"
and with a name: given, which is how you run two windows on one controller:
"rate-limit:sessions:short-term:203.0.113.9"
Prefix that with the namespace and the row in solid_cache_entries reads
production:rate-limit:sessions:203.0.113.9. The compact matters more than it looks: by returning
nil would collapse every visitor onto one counter and rate limit the whole internet together. It does
not, because request.remote_ip does not return nil, and a custom by: reading a header can.
The call, and what would change it
Pass objects, not strings. Rails.cache.fetch(product) and cache product in a view are shorter
than any key you would write and they are the only form that gets a version, which is the only form
that invalidates on its own. Every hand written "products/#{id}" in a codebase is a fragment
waiting to go stale, and the fix is to delete the interpolation.
Design so you never need to enumerate or pattern delete. Put the thing that changes into the key, or
into a namespace you can bump. That is not a workaround for Solid Cache lacking delete_matched, it
is the better design that the missing method makes unavoidable, and applications that had Redis and
used delete_matched freely usually have a slow operation in a request path that nobody has measured.
What would change this: a Rails release adding an enumeration API with honest cost documentation, or
a Solid Cache release adding an index on key and a prefix delete, which is plausible because the
data is sitting in a table and the query is not hard. Either would make "keep your own index" the
wrong advice rather than the careful one.
The position has a cost and it is the cache that never empties. Key-based expiration leaves dead
entries behind by design, and on a store with no eviction the only thing bounding them is max_size
and the trimming job. Get that wrong and the reward for following this advice is a cache database
that grows until somebody notices.
What this post does not cover
Cache stores as a choice are elsewhere: Solid Cache against Redis on durability, eviction and latency is its own post, and nothing above argues for one over the other. So is the question of what belongs in a cache at all rather than in a column, which Counter caches by hand works through for one counter.
Also absent: Russian doll caching and touch: true, which is the same key mechanism applied
recursively and deserves the space; template digests, where fragment_name_with_digest adds the
compiled template's digest to the key so an edited partial invalidates its fragments, and the
dependency tracker that decides what counts as edited; cache stampedes and the race_condition_ttl
option; encryption, which Solid Cache supports and which changes what is in the value column and not
what is in the key; and any timing figure, because the numbers above are row counts, buffer counts
and key strings, all of which reproduce on your machine, and a millisecond from mine would not.
Comments
No comments yet. Be the first.