Migration Guide¶
From Django's Built-in Cache Backend¶
# Before (Django's Redis backend)
"BACKEND": "django.core.cache.backends.redis.RedisCache"
# After (Valkey)
"BACKEND": "django_cachex.cache.ValkeyCache"
# Or (Redis)
"BACKEND": "django_cachex.cache.RedisCache"
All Django cache options work unchanged. Three behaviors differ on the Valkey and Redis backends:
incr()anddecr()on a missing key start it from 0, like RedisINCRBY, where Django'sRedisCacheraisesValueError. Code that relies on theValueErrorto detect an expired counter must checkhas_key()first. django-cachex'sLocMemCacheandDatabaseCachekeep Django's behavior.clear()deletes only this alias's keys, by pattern overKEY_PREFIXandVERSION, not the whole database.flush_db()runsFLUSHDB.- A key can hold spaces and control characters and be up to 512 MB long. Django's
RedisCachewarns withCacheKeyWarningabout such a key, or one longer than 250 characters, since memcached refuses them. django-cachex'sLocMemCachetakes the same keys as the Valkey and Redis backends, and itsDatabaseCachewarns like Django's.
From django-valkey¶
# Before
"BACKEND": "django_valkey.cache.ValkeyCache"
"OPTIONS": {"CLIENT_CLASS": "django_valkey.client.DefaultClient"}
# After
"BACKEND": "django_cachex.cache.ValkeyCache"
| django-valkey | django-cachex |
|---|---|
CLIENT_CLASS |
Removed. Use the backend class for the topology, such as RedisSentinelCache or ValkeySentinelCache for Sentinel. |
SERIALIZER |
serializer |
COMPRESSOR |
compressor |
CONNECTION_POOL_CLASS |
pool_class |
CONNECTION_POOL_KWARGS |
Flat keys in OPTIONS, forwarded to the pool's from_url() |
PARSER_CLASS |
parser_class |
SENTINELS / SENTINEL_KWARGS |
sentinels / sentinel_kwargs |
get_valkey_connection() |
cache.get_client(write=True) |
cache.lock(key, timeout=30) |
cache.lock(key, lease=30), see Differences |
django_valkey.serializers.json.JSONSerializer |
django_cachex.serializers.json.JsonSerializer |
django_valkey.serializers.msgpack.MSGPackSerializer |
django_cachex.serializers.msgpack.MsgpackSerializer |
django_valkey.compressors.zstd.ZStdCompressor |
django_cachex.compressors.zstd.ZstdCompressor |
Import paths change from django_valkey.* to django_cachex.*, with the class names above.
From django-redis¶
# Before
"BACKEND": "django_redis.cache.RedisCache"
"OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}
# After
"BACKEND": "django_cachex.cache.RedisCache"
| django-redis | django-cachex |
|---|---|
CLIENT_CLASS |
Removed. Use the backend class for the topology, such as RedisSentinelCache for Sentinel. |
SERIALIZER |
serializer |
COMPRESSOR |
compressor |
CONNECTION_POOL_CLASS |
pool_class |
CONNECTION_POOL_KWARGS |
Flat keys in OPTIONS, forwarded to the pool's from_url() |
PARSER_CLASS |
parser_class |
SENTINELS / SENTINEL_KWARGS |
sentinels / sentinel_kwargs |
get_redis_connection() |
cache.get_client(write=True) |
cache.lock(key, timeout=30) |
cache.lock(key, lease=30), see Differences |
django_redis.serializers.json.JSONSerializer |
django_cachex.serializers.json.JsonSerializer |
django_redis.serializers.msgpack.MSGPackSerializer |
django_cachex.serializers.msgpack.MsgpackSerializer |
django_redis.compressors.zstd.ZStdCompressor |
django_cachex.compressors.zstd.ZstdCompressor |
Import paths change from django_redis.* to django_cachex.*, with the class names above.
Differences from django-redis and django-valkey¶
incr(),decr()andclear()differ in the same way as from Django's built-in backend.ttl()returns-2for a missing key andNonefor a key without expiry.cache.lock()takes the lock's TTL aslease, and its keyword-onlytimeoutcaps how longacquire()waits. An unchangedcache.lock(key, timeout=30)therefore holds the lock without a TTL. A crashed holder then blocks every peer until someone deletes the key by hand.
From django-cachalot¶
The ORM cache is derived from django-cachalot 2.9.1. Replace the app and uninstall cachalot, because running both patches the ORM twice:
Settings¶
The settings move into one CACHEX_ORM dict, without the CACHALOT_ prefix:
# Before
CACHALOT_TIMEOUT = 3600
CACHALOT_UNCACHABLE_TABLES = frozenset(("django_migrations", "django_session"))
# After
CACHEX_ORM = {
"TIMEOUT": 3600,
"UNCACHABLE_TABLES": ("django_session",),
}
| django-cachalot | django-cachex |
|---|---|
CACHALOT_ENABLED, CACHALOT_CACHE, CACHALOT_DATABASES, CACHALOT_ONLY_CACHABLE_TABLES, CACHALOT_ADDITIONAL_TABLES, CACHALOT_FINAL_SQL_CHECK |
The same key without the prefix |
CACHALOT_UNCACHABLE_TABLES |
UNCACHABLE_TABLES. django_migrations is never cached, so it can be left out. |
CACHALOT_TIMEOUT |
TIMEOUT, which defaults to the cache's default timeout, not None |
CACHALOT_ONLY_CACHABLE_APPS, CACHALOT_UNCACHABLE_APPS |
Removed. List the apps' tables, many-to-many tables included, in ONLY_CACHABLE_TABLES or UNCACHABLE_TABLES. |
CACHALOT_CACHE_RANDOM, CACHALOT_CACHE_ITERATORS, CACHALOT_INVALIDATE_RAW |
Removed. Random queries and the results of iterator() are never cached, and raw SQL writes always invalidate. |
CACHALOT_QUERY_KEYGEN, CACHALOT_TABLE_KEYGEN |
QUERY_KEYGEN, TABLE_KEYGEN. They take keyword arguments, so a cachalot keygen needs adapting (see Cache keys). The default query keys already start with their table names, and __in values are sorted before any keygen runs. Keys also go through the cache alias's KEY_FUNCTION. If it tells tenants apart, a write to a table they share invalidates only the writing tenant's results, so list shared tables in UNCACHABLE_TABLES. |
CACHALOT_USE_UNSUPPORTED_DATABASE, CACHALOT_ADDITIONAL_SUPPORTED_DATABASES |
Removed. Only PostgreSQL and SQLite are cached. |
CACHE must name a django-cachex Redis or Valkey backend. Its serializer must keep Python types, as the default pickle serializer does. Other backends cache nothing, apart from LocMemCache for tests and single processes (see Caches and databases).
API¶
Import from django_cachex.orm.api instead of cachalot.api:
| django-cachalot | django-cachex |
|---|---|
invalidate() |
invalidate(), with the same arguments |
cachalot_disabled(all_queries=False) |
orm_cache_disabled(), without all_queries, which cachalot 2.9.1 ignored |
get_last_invalidation(*tables_or_models, cache_alias=None, db_alias=None) |
table_generations(*tables_or_models, db_alias="default"), returning generations instead of a timestamp and needing at least one table |
manage.py invalidate_cachalot |
manage.py invalidate_orm_cache, with the same arguments and options |
cachalot.signals.post_invalidation |
Removed. Cache values derived from tables under their table_generations() instead. |
cachalot.* system checks |
cachex_orm.* |
The get_last_invalidation template tag, the Jinja2 extension and the Django Debug Toolbar panel |
Removed |
Cache values under the generations instead of get_last_invalidation(). They are None when a value computed now must not be cached:
# Before
def order_totals():
key = f"totals:{get_last_invalidation(Order, OrderLine)}"
return cache.get_or_set(key, compute_totals, timeout=3600)
# After
def order_totals():
generations = table_generations(Order, OrderLine)
if generations is None:
return compute_totals()
key = "totals:" + ":".join(generations)
return cache.get_or_set(key, compute_totals, timeout=3600)
Behavior differences¶
- Writes need the cache. A write that cannot invalidate its tables before it runs raises
InvalidationError, aDatabaseError, unlessENABLEDis off (see Failures). - A write invalidates its tables right before the statement or
COMMITand again right after it, before it returns or runs itson_commit()hooks. Results read while it ran can be served only until then, about one round trip after the commit (see How invalidation works). Cachalot invalidates before the statement under autocommit, so a read racing the write can keep the old rows cached until the next write, and inatomic()only after theon_commit()hooks (see Races). - Subqueries count with their tables wherever they sit, and
Now()anywhere in a query keeps it from being cached. - A MySQL alias in
DATABASESis the errorcachex_orm.E006, and"supported_only"leaves out replicas, the aliases with aTEST["MIRROR"]. - With psycopg2 instead of psycopg 3, queries with JSON, binary or range parameters are not cached.
- A
migratethat applied nothing invalidates nothing. Cachalot invalidated every model after eachmigrate.
Rolling out¶
Cachalot and the ORM cache keep separate keys, so while both run, the writes of one do not invalidate what the other cached. A deployment that stops every process first can switch in one go. A rolling deployment takes three steps:
- Set
CACHALOT_ENABLED = Falseand deploy. - After every process runs with it, replace cachalot with
django_cachex.orm, with"ENABLED": FalseinCACHEX_ORM, and deploy. - After the last cachalot process stops, set
"ENABLED": Trueand deploy.
Cachalot stored its entries without expiry by default. If they live in a cache of their own, clear it. Otherwise they stay until the server evicts them, which it never does under a volatile-* or noeviction policy.