# cached: Rust function memoization with sync_writes, concurrent stores and three migration cycles

> cached provides proc macros and cache store types for Rust function memoization, with sync_writes options for controlling miss deduplication and an async surface that splits between ConcurrentCachedAsync and CachedGetOrSetAsync. All features share a minimum Rust version because the crate-level rust-version cannot be feature-gated.

**jaemk/cached** — Rust cache structures and easy function memoization

- Repository: https://github.com/jaemk/cached
- Stars: 2,097 · Forks: 109
- Language: Rust
- License: MIT
- Published: 2026-10-11 · Updated: 2026-10-11 · Language: en
- Canonical page: https://hysenlabs.com/projects/jaemk-cached

## #[cached] replaces manual mutex and HashMap boilerplate, with the default behavior matching functools.lru_cache

Function memoization in Rust without a library requires a static synchronization primitive, initialization logic, a lock, a key lookup, a compute-on-miss path, a store, and a return value. The cached crate reduces that to a proc macro annotation: apply #[cached] to a function and the library creates the backing store, wraps it in a mutex or rwlock, and handles the lock/check/compute/store sequence on every call.

For engineers coming from Python, the default behavior deliberately matches functools.lru_cache. Concurrent uncached calls for the same key each compute the backing function independently and overwrite each other, with the last write winning. This is intentional and matches the documented prior behavior, as well as Python's functools.lru_cache. If that concurrent-overwrite default is wrong for the use case at hand, sync_writes provides three levels of write control.

The #[concurrent_cached] macro takes a structurally different approach. Rather than a library-managed lock wrapping the whole store, it relies on the store's own internal synchronization. In sharded stores, each shard carries its own parking_lot::RwLock, so multiple readers proceed concurrently without any cache-level write lock blocking them. Redis and disk stores delegate concurrency to their respective server or file-system mechanisms rather than in-process primitives.

## All feature combinations share the same Rust floor because rust-version is a crate-level value

cached is published on crates.io under the package name cached, with documentation at docs.rs/cached. The default feature set, proc_macro, ahash and time_stores, is active without any additional configuration. Adding Redis backing requires enabling the redis_store feature. The async feature is separate and must be enabled to use the async traits; it pulls in async-lock and exposes ConcurrentCachedAsync and CachedGetOrSetAsync.

The minimum required toolchain is Rust 1.92, and this applies to all feature combinations including synchronous-only use. The README explains why: rust-version is a single crate-level value that cannot be feature-gated. The async_core and async features specifically fail before that floor because CachedGetOrSetAsync default bodies hit a borrowck limitation tracked at rust-lang/rust#100013. Since the floor cannot be selectively raised for those paths alone, it governs the entire crate.

A project that pins to an older stable Rust should check the toolchain before adopting cached, because cargo will not silently select an older compatible version; compilation will fail instead.

Bringing the prelude into scope is the recommended starting point for using store types directly:

```rust
use cached::prelude::*;
```

This single import brings in CachedExt and ConcurrentCachedExt, the two blanket extension traits that provide short method aliases (get, set, remove, clear, len) on Cached and ConcurrentCached stores respectively.

## sync_writes has three modes; 'by_key' deadlocks when re-entrant call chains share a bucket

Three options control how concurrent cache misses interact when using #[cached]. The default is no write synchronization. Multiple threads that all miss on the same key each compute the backing function and store their results, with later writes overwriting earlier ones. For deterministic pure functions this is correct, but it means repeated computation on concurrent misses.

Setting sync_writes = "by_key" routes each miss through one of 64 per-key lock buckets, configurable with sync_writes_buckets = N. Two threads with the same key queue behind one bucket lock; only the first computes, and the second reads the stored result once the bucket lock releases. The risk is stated plainly in the README: "by_key" holds the per-key bucket lock across the entire function body. A recursive or re-entrant memoized function whose active call chain maps two different keys to the same bucket will deadlock, not merely contend. The README names this explicitly as a "deadlock risk when keys in the active call chain share a bucket."

Setting sync_writes = true (or the equivalent string "default") instructs the macro to hold the whole-cache write lock for the full duration of each miss computation, serializing both reads and writes on any concurrent thread until the memoized function returns. That removes the per-bucket deadlock risk but serializes throughput more aggressively.

For #[once], which caches a single value rather than keyed results, adding sync_writes = true serializes concurrent first calls. #[concurrent_cached] does not support sync_writes at all.

## Short method aliases and cache_-prefixed names live on separate blanket extension traits

Every cache operation has two spellings: short aliases (get, set, remove, clear and len) and prefixed forms (cache_get, cache_set, cache_remove, cache_clear and cache_size). Both call the same underlying implementation, and the short forms are the preferred spelling. The prefixed forms exist for situations where a short alias collides with a method of the same name on another in-scope trait.

For Cached stores, the short aliases come from CachedExt. For ConcurrentCached stores they come from ConcurrentCachedExt. Both are blanket implementations: any type that satisfies the core trait gets them automatically. The import use cached::prelude::*; brings both extension traits into scope at once. A narrower import, use cached::{Cached, CachedExt};, works when only one family of stores is needed. Custom store implementations only need to implement the cache_-prefixed required methods on the core trait; the short aliases arrive for free through the blanket extension trait.

Size introspection differs by store type. For Cached stores, len and is_empty are on CachedExt. For ConcurrentCached stores, the size methods are cache_size and cache_is_empty on ConcurrentCacheBase, a shared base trait that must be imported explicitly to call them on a generic bound. ConcurrentCacheBase::cache_size returns Result<Option<usize>, _>: the Result is present because IO-backed stores can fail, and the Option<usize> is None when the backend cannot report an exact count. Sharded in-memory stores keep their own infallible len and is_empty, which take priority at the call site.

## Two async traits divide the surface; async_-prefixed names prevent ambiguity with synchronous methods

The async surface splits between ConcurrentCachedAsync and CachedGetOrSetAsync, two traits with different scopes and different call conventions.

ConcurrentCachedAsync mirrors the synchronous ConcurrentCached surface with async equivalents: async_cache_get, async_cache_set, async_cache_remove and others. It covers concurrent stores that manage their own synchronization, specifically the in-memory sharded stores and the IO-backed redis and redb stores. ConcurrentCachedAsyncExt provides short aliases for those operations: async_get, async_set, async_remove, async_get_or_set_with, async_try_get_or_set_with and more.

CachedGetOrSetAsync is narrower. It only memoizes an async closure over a synchronous in-memory Cached store, through the async_cache_get_or_set_with family: async_cache_get_or_set_with, async_cache_try_get_or_set_with and their _mut variants. No alias trait exists for CachedGetOrSetAsync; the async_ prefix it already carries is sufficient to prevent naming conflicts with the sync methods.

The reason ConcurrentCachedAsyncExt keeps the async_ prefix rather than using bare get/set names is that stores implementing ConcurrentCachedAsync also implement the synchronous ConcurrentCached. A bare get would be ambiguous alongside ConcurrentCachedExt's get at the same call site, so the prefix resolves the collision at the cost of longer method names. Size and metric introspection on async stores uses cache_size and cache_is_empty from ConcurrentCacheBase directly, with no additional extension trait import required.

## serde is absent from the public feature list by design; it is an IO store implementation detail

One deliberate gap in the feature list is a public serde feature. The Cargo.toml comment explains the reasoning: the MessagePack codec uses serde and rmp-serde as an implementation detail of the IO-backed stores, which pull those dependencies in directly. The public traits SerializeCached and SerializeCachedAsync are ungated, meaning they are available regardless of any feature flag. Providing a standalone serde feature would have added two crate dependencies to every consumer that enabled it while exposing no new public API, so it was intentionally omitted.

This matters for custom store authors. SerializeCached and SerializeCachedAsync are unconditionally accessible, but serde itself arrives only as a transitive dependency when an IO store feature such as redis_store is enabled. A custom store that depends on serde directly must declare it as an explicit dependency in its own Cargo.toml rather than relying on cached to carry it.

The alternative to the macro-based approach is maintaining cache state without this library: a static Mutex<HashMap<K, V>>, explicit initialization, and a lock/check/compute/insert pattern at every memoized call site. That approach gives full control over cache policy and eviction semantics but requires the same boilerplate at each function, with no shared infrastructure for expiry, LRU eviction or distributed backing.

## Migration guides cover three breaking-change cycles with human-readable and agent-oriented variants

Three breaking-change cycles have accumulated since early versions, each documented with at least a human-readable step-by-step guide and, for the larger ones, an agent-oriented guide with automated tooling.

The first documented breaking-change cycle changed store names, removed declarative macros, renamed macro and builder attributes, and changed the Redis key format. The human guide is at docs/migrations/0.x-to-1.0-human.md and the automated guide is at docs/migrations/0.x-to-1.0.md.

The second breaking-change cycle added a new cache_remove_entry required method to the core trait, changed Result/Option caching behavior to be smart by default, and removed the result and option macro attributes. The guide is at docs/migrations/1.1-to-2.0-human.md.

The third cycle is documented in docs/migrations/2.0-to-3.0-human.md for a step-by-step walkthrough and docs/migrations/2.0-to-3.0.md for the full breaking-change list.

CHANGELOG.md at the repository root tracks changes across all versions. A codebase upgrading from any prior major version should consult the relevant guide before updating the dependency pin, because the API changes in each cycle are not backward-compatible and the human-readable guides name every changed type, method and attribute.

## Conclusion

cached suits Rust projects that need function memoization without managing cache state by hand, particularly where the backing store needs to be LRU-capped, time-expiring, or backed by Redis. Skip it when the project's toolchain cannot be updated to meet the crate's minimum: all feature combinations share the same floor, and cargo will not silently select an older compatible version. Before upgrading from a prior major version, read the relevant migration guide under docs/migrations/ in the repository, because the API changes across major versions are not backward-compatible.

## FAQ

### What is the cached crate?

cached is a Rust crate that provides cache store types and proc macros for function memoization. Annotating a function with #[cached] creates a mutex-wrapped backing store that records return values keyed by input, without requiring the caller to manage the cache directly.

### What Rust version does cached require?

The crate requires Rust 1.92 or newer, and that floor applies to every feature combination because rust-version is a single crate-level value in Cargo.toml that cannot be feature-gated.

### Is sync_writes = "by_key" safe for recursive functions?

No. Setting sync_writes = "by_key" holds a per-key bucket lock across the entire function body. A recursive or re-entrant memoized function whose active call chain contains two keys that hash to the same bucket will deadlock.

### What is the difference between #[cached] and #[concurrent_cached]?

#[cached] wraps the backing store in a mutex or rwlock managed by the library. #[concurrent_cached] relies on the store's own internal synchronization, such as per-shard parking_lot::RwLock for sharded stores, and does not support the sync_writes attribute.

### How do I enable Redis support in cached?

Enable the redis_store feature in the Cargo.toml dependency declaration for cached. For async Redis access, also add the async feature. The repository includes Redis examples under examples/ that cover both synchronous and async usage.

## Sources

- [Issues](https://github.com/jaemk/cached/issues)
- [jaemk/cached on GitHub](https://github.com/jaemk/cached)
- [License: MIT](https://github.com/jaemk/cached/blob/master/LICENSE)
- [README](https://github.com/jaemk/cached/blob/master/README.md)
- [Releases](https://github.com/jaemk/cached/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/jaemk-cached
