Changelog¶
0.13.0 (October 2026)¶
Improvements¶
- The admin decides per cache who can use its keys. Each alias in
CACHESgets a permission,django_cachex.access_<alias>, and listing, opening, adding, editing, deleting, clearing and flushing keys need it next to the existing permissions, on aTrackingCachealias together with that of its transport. Flush database, the slow log's arguments, and Clear and Flush on Django's ownRedisCacheand the memcached backends need it for every alias. Before,view_keyopened every cache, the session cache included, whose key names hold session ids. After upgrading, runmigrate: staff who are not superusers see no keys until they are granted the caches they need. See Permissions. - ORM cache keys show their tables, as in
orm:{default}:q:shop_customer.shop_order:<digest>:multifor a result andorm:{default}:g:shop_orderfor a generation. The newQUERY_KEYGENandTABLE_KEYGENsettings take a callable, or its dotted path, that builds them instead (see Cache keys). While processes of 0.12.1 or earlier and newer ones run against one cache, neither sees the other's writes, so results can be stale. Stop the old processes before the first new one starts, runinvalidate_orm_cacheafter the last old one has stopped, or keep the old table keys with thehashed_table_keyexample from the docs. - The ORM cache sorts the values of an
__infilter before it keys a query, so aprefetch_related()under another order of its parent rows, or over a set of strings in another process, hits the cache. It compiles a query on an uncachable table once, not twice. - The ORM cache finds the tables of a query with 10,000
__invalues in 0.2 ms instead of 8 ms.
Fixes¶
- The ORM cache's
invalidate()andinvalidate_orm_cachestopped at the first cache they could not reach, so the caches after it inCACHES, the ORM cache itself among them, kept serving stale results. They now invalidate every cache they can reach before raisingInvalidationErrorfor the ones that failed. - A
CACHEX_ORM["LEASE_TIMEOUT"]that is not a positive number of seconds went unreported:0gave writes leases that expire at once, andNonemade every write raiseInvalidationError. The newcachex_orm.E008check reports it. DatabaseCache.scan()skipped keys when others were deleted mid-iteration, so a loop that scans and deletes left about half of them. Its cursor is now a position in hash order, likeLocMemCache's. The 0.11.0 fix had missed it.- On valkey-glide,
xreadgroup()with id0raisedTypeErrorwhen a pending entry had been removed byXDELorXTRIM. That entry now comes back with an empty field dict, as on redis-py and valkey-py. - On valkey-glide, each sync blocking call (
blpop(),brpop(),blmove(),xread()andxreadgroup()withblock, or a pipeline holding one) built a client and kept about 2 KB of it for the life of the process, because glide registers every client in a fork hook. Those calls now reuse idle clients. - On Redis before 7.0,
set(nx=True, get=True)and its pipeline form raised the driver'ssyntax error. They now raiseNotSupportedError, as the requirements say. - On valkey-glide,
flush_db(),aflush_db()and the admin's Flush database button sentFLUSHDB SYNC, so a large flush blocked the server and raisedTimeoutErrorafterrequest_timeout(250 ms by default), though the flush went through. They now send a plainFLUSHDB, as redis-py and valkey-py do, and the server'slazyfree-lazy-user-flushdecides whether to free the keys in the background. - With stampede prevention on,
set(key, value, nx=True)wrote nothing while the old value sat in its stampede buffer, whereget()already returnsNone, so the refill after a miss failed and every request recomputed the value for up tobufferseconds. Withget=True,set()returned that old value, and withxx=Trueit overwrote it.set()now counts that key as absent with every flag, asadd()does:nx=Truewrites,xx=Truewrites nothing, andget=TruereturnsNone. - With stampede prevention on,
get()andget_many()returned a key's old value again in its last half second, once itsTTLread 0, though they had returnedNonefor the rest of its buffer andadd()counted it as absent. They now return a miss there too, and so doesTrackingCache. - With stampede prevention on and a
bufferof 0,add()andset()withnx,xxorgetcounted a key in its last half second as present:add()andset(nx=True)wrote nothing,set(xx=True)overwrote the value andset(get=True)returned it. They now count that key as absent, asget()does and as they do with a larger buffer, and so doaadd()andaset(). - A tuple in
OPTIONS["serializer"]orOPTIONS["compressor"]was taken as one codec instead of a fallback chain, so the first write or read raisedAttributeError. A tuple now works like a list. - A compressor
levelthat its library rejects, such asZlibCompressor(level=10)or a string, went unnoticed until the first write of a value overmin_lengthraisedCompressorError, possibly long after a deploy. APickleSerializerprotocolabovepickle.HIGHEST_PROTOCOLwent unnoticed until the first write raisedSerializerError. Both now raiseImproperlyConfiguredwhen the compressor or serializer is built, and the message names the accepted range. An lz4 level abovelz4.frame.COMPRESSIONLEVEL_MAX(16), which lz4 treated as 16, raises too. - The pipeline's
lpop(),rpop(),spop(),zpopmax()andzpopmin()with a negativecount,linsert()with a position other thanBEFOREorAFTER, andlpos()withrank=0or a negativecountormaxlenqueued the command anyway, soexecute()ran the commands queued before it and then raised the driver's error. They now raiseValueErrorwhen queued, like the cache methods. full_encode_preencoded anEncoded(...)arg as the wrapper, soget()returnedEncoded(value=...)instead of the value, or the JSON serializer raisedSerializerError. It now encodes the wrapped value.- The admin showed Add key to users with
add_keybut notchange_key, then gave them a key page with no way to add the first value.add_keynow creates a key that does not exist yet; editing an existing key still takeschange_key. - The admin gave a user with
add_keybut notview_keythe Add key form, and its Continue button answered with a 403. That user can now add the first value, which creates the key, and then lands on the admin index, as in Django's admin. Existing keys stay closed to them. The breadcrumbs and Back links of the Add key form and the key page led to the cache list and the key list, which answered that user with a 403 too. A list the user cannot open is now plain text in the breadcrumbs, and the Back link to it is left out. An error such as an unknown alias or an unreachable server also redirected to one of those lists, so the user got a 403 instead of the error message. It now redirects to the admin index when the user cannot open the list. The key list's breadcrumbs and its Cache Details button linked the cache list and the cache info page, which answered a user withoutview_cacheorchange_cachewith a 403. For that user, those crumbs are now plain text, and the button is left out. The cache list and the cache info page showed List Keys to a user with the alias permission but withoutview_keyorchange_key, and the key list answered that user with a 403. List Keys now also needs one of the two. - The admin's Clear tool said it removes only the current cache version, but on
LocMemCacheandDatabaseCacheit removes every version, and onDatabaseCachethe whole table, which other aliases may share. Its confirmation and message now say what goes. - The admin's key page read and rendered a string value in full, so a 50 MB value blocked Redis and made a 50 MB page. A string over 1 MiB now shows only its size, also when it is stored compressed in less; its TTL and Delete still work. On the Valkey and Redis backends, the page does not read a value stored in over 1 MiB.
- With stampede prevention on, the admin's TTL form shows 0 for a key inside its stampede buffer, and 0 removes the expiry, so saving the form untouched made a key that was about to expire persistent. Saving the form without changing the TTL now leaves the expiry alone.
- The admin's key list showed the Key column header as a sort link, but the list stays in SCAN order, so clicking it changed nothing. The header is now plain text.
- The admin's key search read
?,[,]and\as glob syntax, though the docs name only*as a wildcard. A search for[ab]listed every key holding anaor ab, and a search holding a?had to match the whole key name instead of a part of it. These characters now match themselves. - The admin's Pop forms took any count, and the message after a pop listed every item it removed, so a large count made a page as large as the items. A pop now takes at most 100 items, as many as the key page shows at once, and its message names the first three and counts the rest.
- One key name that is not valid UTF-8 made
scan(),keys(),iter_keys()and their async forms raiseUnicodeDecodeError, so the admin's key list showed an error instead of any key. They now decode that name with thesurrogateescapeerror handler, asos.listdir()does. On the Valkey and Redis backends and onTrackingCache,get(),set(),delete()and the other key commands take that name back and act on its key. The admin lists the name as bytes, as inb'bad\xff', and opens, edits and deletes the key. On valkey-glide,delete_pattern()andadelete_pattern()raisedUnicodeDecodeErrorwhen such a name matched, and now delete its key, as redis-py and valkey-py do.xread(),xreadgroup(), their async forms and their pipeline forms raisedUnicodeDecodeErrorfor a stream with such a name, and now return its entries under that name.keys(),iter_keys(),scan(),delete_pattern()and their async forms raisedUnicodeEncodeErrorfor a pattern that holds such a name, and now match its key. TrackingCache.delete_pattern()andadelete_pattern()matched the glob against the characters of a key name, while the server matches its bytes, socaf??deletedcaféon the server but left its local copy. Undercoherence="ttl",get()returned that copy untillocal_timeoutran out. They now match the bytes, as the server does.- The glob of
keys(),iter_keys(),scan()anddelete_pattern()onLocMemCacheandDatabaseCache, and ofTrackingCache.delete_pattern()for its local copies, ended a[...]class at its first]and read a-before it as a plain character, sok[a-]1matchedka1. Redis readsa-]as a range from]toaand runs a class that lost its closing]to the end of the pattern, sok[a-]1matchesk1andk_but notka1. They now read a class as Redis does. LocMemCache'szadd(),zincrby(),zrem(),zrank()andzrevrank()raisedValueErrorfor a member equal to a stored one with another string form, such as1orTruefor a stored1.0, and a failedzrem()left the member inzrange()but not inzcard(). They now act on the stored member.LocMemCache.delete()returnedTruefor an expired key, whichget()andhas_key()already treated as missing. It now returnsFalse, as Redis does, and still removes the key.LocMemCache'saget(),aadd(),atouch(),adelete(),aget_or_set(),adelete_many(),aclear()andaclose()went throughsync_to_asyncand a worker thread, although the docs say its async methods call the sync method directly. They now do.- After a fork, as under gunicorn
--preloador Celery prefork, the child's firstTrackingCachewrite,clear()orshutdown()could hang for good when a thread of the parent, such as its listener, held the local store's lock at the fork. Writes now switch the child to a fresh store first, as reads already did. - After a fork, the child's first
TrackingCacheread or write, or a new thread's first use of the cache there, could hang for good when another thread of the parent was setting up its ownTrackingCacheat the fork and held the lock that setup takes. The child now starts with a fresh lock. - After a fork, the child's first ORM query or write could hang for good when the ORM cache was a
TrackingCacheand another thread of the parent was starting one at the fork, holding the lock that each one takes first. The child now starts with a fresh lock. - After a fork, on the redis-py and valkey-py backends, the child's
close(), which Django sends after each request, its async calls, and the first call of a new cache instance there, as in a new thread, could hang for good when another thread of the parent held a lock around the shared connection pools at the fork. OnRedisClusterCacheandValkeyClusterCache, the first call of a new cache instance could hang the same way on the lock around the shared cluster clients. The child now starts with fresh locks. - After a fork, on valkey-glide, the child's
close(), which Django sends after each request, its async calls, its blocking calls such asblpop(),eval_script(), releasing or extending alock(), and its first call to a server the parent had not connected to, could hang for good when another thread of the parent held one of the adapter's locks at the fork, as a thread does while it connects. The child now starts with fresh locks. - The redis-py and valkey-py backends, Sentinel and cluster included, opened another connection pool once an object in
OPTIONSchanged state, such as a credential provider caching a renewed token or aRetrythat the asyncio cluster client adds errors to, and the old pool stayed open. Such an object now keys the same pool for as long as it lives. - With
retry_on_timeout=True, the redis-py and valkey-py backends, Sentinel included, let the driver addTimeoutErrorto the list inOPTIONS["retry_on_error"](or insentinel_kwargs) for each new connection, so the next cache instance, as in each new thread, opened another connection pool and the old ones stayed open. The drivers now get their own copies of the lists, dicts and sets inOPTIONS, which stay as given. - On the redis-py and valkey-py backends, Sentinel included, an adapter subclass with its own
_client_classor_async_client_classshared the connection pool of a cache with the sameLOCATIONandOPTIONS, and with it the client of whichever connected first, so one of the two got the other's client class. Each client class now gets its own pool. - On the redis-py and valkey-py cluster backends, a thread connecting to a slow or unreachable cluster held a process-wide lock through node discovery, so every other cluster alias waited for it, and each command rebuilt the key of the shared cluster client. Discovery now runs outside the lock, and a cache instance looks its client up once.
- On valkey-glide, a thread or task connecting to a slow or unreachable server held a lock that all aliases share, process-wide for sync calls and per event loop for async ones, so every other alias that had not connected yet waited until it gave up. Each configuration now connects under a lock of its own.
- On redis-py 7, with
OPTIONS["socket_timeout"] = Noneon valkey-py, and in valkey-py's sync Sentinel lookups, a connect had no timeout, so an unreachable host held each call for the kernel's TCP timeout of about two minutes. A connect now gives up after 5 seconds unlesssocket_connect_timeoutorsocket_timeoutsays otherwise inOPTIONS, theLOCATIONquery orsentinel_kwargs. In aLOCATIONlist of a primary and its replicas, the query of one URL counts for that URL only. - On
ValkeyGlideClusterCache, theImproperlyConfigurederror for seed URLs that disagree on TLS, username or password called theLOCATIONlist a primary and its replicas. It now says that valkey-glide applies one set of these settings to every URL in the list. KeyType, whichtype()returns, was missing from the names thatdjango_cachexexports, sofrom django_cachex import KeyTyperaisedImportError. It now works.- On the Valkey and Redis backends,
semaphore()andasemaphore()raisedTypeErrorfor a name thatscan()returned for a key name that is not valid UTF-8. They now take that name, asget()and the other key commands do. - On
LocMemCacheandDatabaseCache,hincrbyfloat()stored a whole result such as5200.0as a float, so a laterhincrby()on the field raisedValueErrorandhget()returned5200.0. Redis stores it as"5200", whichHINCRBYtakes andhget()returns as5200. Both backends now store a whole result as an integer. - On
LocMemCacheandDatabaseCache,scan()with acountbelow 1 returned no keys and cursor 0, so a scan loop stopped as if no key matched. The server rejectsCOUNT 0. They now raiseValueError.
Documentation¶
- The ORM cache guide says that under django-tenants' per-tenant
search_pathor a row-level security policy, one tenant can be served another's cached rows, and shows aQUERY_KEYGENthat adds the tenant to the query key. - The quickstart and the configuration reference say that the redis-py backends reject
valkey://andvalkeys://URLs, with aValueErroron the first cache call. - The API reference, the async guide and the
TrackingCachedocs no longer claim async twins forinfo(),slowlog_get()andslowlog_len(), which have none. - The
TrackingCacheguide says which calls open the listener connection. Reads andinfo()do, writes do not. - The configuration reference says that concurrent
DatabaseCachewrites on SQLite can fail withdatabase is lockedunder Django's default deferred transactions, and that"transaction_mode": "IMMEDIATE"in the database'sOPTIONSmakes them wait for the lock. - The configuration reference lists exclusive score bounds such as
"(5"among whatLocMemCacheandDatabaseCacheraiseNotSupportedErrorfor. - The configuration reference said every other
OPTIONSkey goes to the driver. It now says that the Valkey/Redis backends rejectdecode_responsesand thatMAX_ENTRIESandCULL_FREQUENCYdo nothing there. - The stampede prevention docs say that
{}turns it off, that unknown dict keys are dropped with a warning, so a dict of misspelled keys means the defaults, and that bad field values raiseTypeErrororValueError, notImproperlyConfigured. - The stampede prevention docs say that counters and data structures get the buffer too, and how to keep it off them.
- The admin guide's Backend Abilities table said that
LocMemCacheandDatabaseCachehave no conflict detection on edit. It now says they detect type changes only. - The admin's help for a string key said that its input must be valid JSON. It now says that input which is not JSON is stored as a string.
- The API reference said that a pipelined
zadd(..., incr=True)sendsZINCRBY, which takes none of thenx,xx,gtandltflags. It now says that the pipeline sendsZADD ... INCR. - The configuration reference says that on the redis-py and valkey-py backends a database in the
LOCATIONURL overridesOPTIONS["db"], whereas on valkey-glide backendsOPTIONS["db"]overrides the URL. - The configuration reference says that on
LocMemCacheandDatabaseCache,1,Trueand1.0are one set or sorted set member and onelrem()value, and that sorted set members with equal scores sort bystr(member).
0.12.1 (October 2026)¶
Fixes¶
- The redis-py and valkey-py backends, Sentinel included, share sync connection pools across the process, as they do async ones. Django builds a cache instance per thread and per asyncio task, so each new thread opened new connections, and under ASGI so did each request that made sync cache calls.
OPTIONS["max_connections"]now caps one pool that every thread shares, along with every alias with the sameLOCATIONand connection options. redis-py 8 defaults it to 100, so a process with more than 100 cache commands in flight at once getsToo many connectionsunless it raises the cap or usesBlockingConnectionPool.
0.12.0 (October 2026)¶
Improvements¶
- The admin's Flush action, Clear tool and danger zone are off by default, for superusers too. Set
CACHEX_ADMIN = {"ALLOW_FLUSH": True}to turn them back on; they still need thechange_cachepermission. See Flushing Caches.
0.11.1 (September 2026)¶
Fixes¶
flush_db()andaflush_db()raiseNotSupportedErroronLocMemCache,DatabaseCacheandTrackingCache, likeclear_all_versions(), instead ofAttributeError.
Documentation¶
- The API reference lists
info(),slowlog_get(),slowlog_len(), and theversion_srcandversion_dstarguments ofrename()andrenamenx(). - The semaphore docs say that
release()after an expired lease logs a warning, and that the semaphore keys surviveclear()andclear_all_versions(). - The docs say that a pipelined
get()skips the stampede check, thatscan()raisesNotSupportedErroron the redis-py and valkey-py cluster backends, and which options a clusterLOCATIONlist takes from its first URL. - The serializers guide warns that anyone who can write to the cache server can run code through pickle. The admin guide says that Flush on Django's own
RedisCacherunsFLUSHDB.
0.11.0 (September 2026)¶
Breaking changes¶
- redis-py 7.2 is the oldest supported release (
redis>=7.2,<9), up from 6.0. Older releases mishandle async pipeline errors and async cluster scripts. - On valkey-glide,
xread(),xreadgroup()and their async and pipelined forms return{}instead ofNonefor an empty read. Test withif not result. execute()onRespPipelineProtocolandRespAsyncPipelineProtocoltakes a keyword-onlyraise_on_error=True, which custom adapters must accept. WithFalse, errors are returned as results.
Features¶
django_cachex.ormis an opt-in ORM query cache derived from django-cachalot 2.9.1. With the app inINSTALLED_APPS, it caches PostgreSQL and SQLite query results in a Redis, Valkey,TrackingCacheorLocMemCachealias and invalidates the tables each write touches. A query running while a write commits cannot store a stale result, subqueries count with their tables, and queries usingNow()are not cached. The API isinvalidate(),orm_cache_disabled(),table_generations()and theinvalidate_orm_cachecommand. See ORM Cache, and Migration for moving from cachalot and for the cachalot settings it drops.
Improvements¶
- Admin hash and set pages on the RESP backends transfer only the page shown; stream pages read at most about half the stream.
Fixes¶
- On valkey-glide,
blpop(),brpop(),blmove(), andxread()andxreadgroup()withblockget a short-lived client per call, direct, async or pipelined, instead of stalling every other command. The client waits for the block plusrequest_timeout(250 ms by default); a block of0is not cut short. zpopmin()andzpopmax()withoutcountraisedValueErrorunderOPTIONS["protocol"] = 3on redis-py and valkey-py, directly and in a pipeline.- For a missing key,
Pipeline.rename()raisesKeyNotFoundErrorfromexecute()andPipeline.renamenx()returnsFalse, instead of the driver's error. - With stampede prevention active,
add()andaadd()overwrite a key whose TTL entered the stampede buffer, whichget()reports as missing. get_or_set()andaget_or_set()withstampede_prevention=Falsestill added the stampede buffer to the stored TTL.RedisClusterCacheandValkeyClusterCacheseed discovery from everyLOCATIONURL, so they connect while the first node is down. Credentials, TLS and other options still come from the first URL.decode_list_postkeeps a nil element asNoneinstead of raisingTypeError. Serializers and compressors given non-bytes raiseSerializerErrororCompressorErrorinstead ofTypeError.- The default
scan()ofLocMemCacheandDatabaseCacheskipped keys when others were deleted mid-iteration. Keys now come in hash order, sorted per page. LocMemCacheandDatabaseCachefollow Redis more closely:persist()without a TTL returnsFalse,hdel()andzrem()count duplicates once,zadd()andzincrby()reject NaN scores withValueError, a negativestartinzrangebyscore()orzrevrangebyscore()returns[], andLocMemCache.sadd()with an unhashable member raises before adding the others.DatabaseCache.sdiff()andsinter()of a single key returned an internal set type thatset()stored as a set-typed key.DatabaseCachecollection writes that trigger aMAX_ENTRIEScull no longer risk deadlocking with a concurrent cull.- A
TrackingCachelistener that connects aftershutdown()is closed instead of replacing its successor and clearing the local store. Undercoherence="ttl", a forked instance drops the parent's local store. - An in-process
Semaphore.acquire()interrupted while waiting (KeyboardInterrupt, Celery's soft time limit) leaves the queue instead of blocking every lateracquire(). RespSemaphore.acquire()andaacquire()reject a NaNtimeoutwithValueErrorinstead of waiting forever, and a second interrupt or cancellation while abandoning one no longer makes the nextacquire()raiseSemaphoreError.- The admin renders stream entries with an
itemsfield, and key-list paging links keep anitemsquery parameter. - The admin hash page keeps leading and trailing spaces in field names instead of stripping them.
- Renaming a sorted-set member in the admin onto an existing member is refused instead of overwriting that member's score and dropping the old one. On the RESP backends, the rename is also refused when the score changed since the page loaded.
- The admin's Flush database text wrongly said a cluster flush only reaches the connected primary, and a quote in a translation broke the confirmation button.
- The wheel and sdist ship
LICENSE.django-redis(BSD-3-Clause) for the serializer, compressor and exception code derived from django-redis. The README and docs home page say which parts it covers.
Documentation¶
clear()was documented as safe on a shared database; it deletes every key under itsKEY_PREFIXandVERSION, so apps need distinct prefixes. The docstring and API reference now say so.- The README and docs promised automatic key prefixing and value encoding in
eval_script(), which only applies them through apre_hooksuch askeys_only_pre. The Lua guide's example now passes one. - The docs list the multi-key commands redis-py and valkey-py cluster pipelines refuse:
rename,renamenx,smove,sdiff,sinter,sunionand thestorevariants. - The configuration guide lists
sscan(),sscan_iter()andclear_all_versions()among the methodsLocMemCacheandDatabaseCachedo not support. - The LocMemCache vs fakeredis page is gone, the
TrackingCacheguide is half as long, and other pages drop filler.
Tooling¶
- CI builds the docs with
mkdocs build --stricton pull requests. - CI runs the cache tests over RESP3 and against the oldest supported client libraries (redis-py 7.2.0, valkey-py 6.1.0).
- The release workflow runs the ORM cache tests on SQLite with
LocMemCacheand on PostgreSQL with Redis before tagging.
0.10.0 (September 2026)¶
Breaking changes¶
StreamCacheand thedjango_cachex.cache.streammodule are removed;TrackingCachecovers the same use case with a coherence guarantee.- The redis-py and valkey-py cluster backends reject
OPTIONS["parser_class"],["pool_class"]and["async_pool_class"]withImproperlyConfiguredatcaches[alias]instead of silently dropping them. Remove them; the C parser comes from installinghiredisorlibvalkey. OPTIONS["decode_responses"] = TrueraisesImproperlyConfiguredon every RESP backend; it broke every read.TrackingCacherejectsKEY_FUNCTION,VERSION,TIMEOUTand an explicitKEY_PREFIX: ""on its own alias, directly or inOPTIONS; set them on the transport alias.lock()andalock()on the redis-py and valkey-py backends return a wrapper that raisesdjango_cachex.lock.LockErrorandLockNotOwnedError, subclasses ofValueError, with the driver error as__cause__; onlyexcept redis.exceptions.LockErrorneeds changing. The wrapper forwards other attributes, including assignment, and supportscopy.copy().- Pipeline parameter names match
RespCache:smove(src, dst, member)(wassource,destination),sunionstore(dest, keys)(wasdestination),member(wasvalue) inzscore(),zrank(),zrevrank()andzincrby(), and*members(was*values) insadd()andzrem().lmove()no longer defaultswherefromandwheretoto"LEFT"and"RIGHT". Pipeline.get(key, default=None, version=None)gaineddefault, sopipe.get("k", 2)now means default 2, not version 2; passversion=by keyword.add()andset()withnx,xxorgetandtimeout=0now usePXATon every RESP backend, closing a window where the key had no TTL.PXATneeds Redis 6.2, now the documented minimum.- Sentinel backends reject a multi-entry
LOCATIONlist withImproperlyConfigured; Sentinel nodes go inOPTIONS["sentinels"].OPTIONS["async_pool_class"]must be the driver's asyncSentinelConnectionPoolor a subclass, and is now used. OPTIONS["stampede_prevention"]accepts onlybool,dict,StampedeConfigorNone; other values, such as the string"False", raiseImproperlyConfiguredinstead of enabling it. AStampedeConfigis used as is.TrackingCachealiases sharing oneLOCATIONmust agree ontransport,coherence,prefixes,local_timeout,MAX_ENTRIES,poll_timeout,health_check_intervalandreconnect_delay; a mismatch raisesImproperlyConfigured. The four timing options must be positive finite numbers.- Cluster
incr_version()anddecr_version()reject aKEY_FUNCTIONthat puts the version inside the{...}hash tag withNotSupportedError, notCROSSSLOT; the message names both methods. xautoclaim(..., justid=True)raisesNotSupportedErrorin pipelines and on the redis-py / valkey-py cluster backends, where it returned""as the cursor; call it withoutjustidthere.django_cachex.admin.viewsexportscache_detail_view,key_add_viewandkey_detail_view; the underscore-prefixed names are gone.django_cachex.adapters.redis_py._REDIS_AVAILABLEis no longer in that module's__all__.- The valkey-glide pipeline raises
AttributeErrorfor unknown attributes instead of queuing them as commands; useexecute_command(*args). - For custom adapters:
RespAdapterProtocolgainedmemory_usage(),amemory_usage()andget_async_client(),keys()declarespattern="*",xadd()returnsstr | None, and the pipeline protocol gainedmemory_usage(),zadd(incr=)andzrange(desc=).
Features¶
Encodedandencoded_preforeval_script(): wrap only the ARGV entries to encode, a middle ground betweenkeys_only_preandfull_encode_pre. AnEncodedreaching the adapter unwrapped, inkeysor nested raisesTypeError.memory_usage(key, version=None, *, samples=None)andamemory_usage()return a key'sMEMORY USAGEin bytes (Noneif missing), on the RESP backends, pipelines andTrackingCache.LocMemCache,DatabaseCacheand the newBaseCachexdefaults raiseNotSupportedError.largest_keys(pattern="*", count=10, version=None, *, samples=None, itersize=None)andalargest_keys()return thecountlargest keys matchingpatternas(key, bytes)pairs, largest first.count=0returns[]and a negative count raisesValueError.Pipelinegainedset(..., get=True),zadd(..., incr=True)andzrange(..., desc=True), decoding as on the cache.django_cachex.script_sha()is exported from the package root.LocMemCachesemaphores gainedextend()andaextend(), returning whether the claim is still held.LocMemCacheandDatabaseCacheimplementzrevrangebyscore()andazrevrangebyscore()with Redis semantics:max_scorethenmin_score, highest first,LIMITapplied to the descending order,withscoressupported.
Improvements¶
eval_script()andaeval_script()sendEVALSHA, loading the script onNOSCRIPT; valkey-glide does the same withScriptobjects, including its lock release and extend scripts. Pipelines still sendEVAL.- Configuration errors surface at
caches[alias], not on the first command: a blank or missingLOCATIONraisesImproperlyConfiguredwith an example URL, and a missing driver, a badpool_classor a rejected SentinelLOCATIONfail there too. TrackingCacheundercoherence="ttl"rolls the stampede dice on the key's remaining server TTL, not thelocal_timeoutcap.has_key()no longer rolls, andget_or_set()reads its own write back without rolling.TrackingCache.delete_pattern()evicts local copies even under a customKEY_FUNCTIONwithoutREVERSE_KEY_FUNCTION, anddelete_many()no longer runs the key function under the store lock.TrackingCachelogs one traceback per listener outage, then a one-line warning per later attempt, instead of a traceback every second.- Cluster
delete_many(),delete_pattern()andset_many(timeout=0)send oneUNLINKper batch. - Sentinel async pool lookups compute their registry key once per server; the registry is only mutated under its lock.
- The
CLIENT TRACKINGlistener no longer reconnects silently without tracking;TrackingCacherebuilds it instead. aclose()on the redis-py and valkey-py adapters keeps the remaining pools registered when one fails to disconnect, and its docstring covers aliases sharing a pool.- The admin key list fetches TTL, type and size per
SCANbatch on redis-py and valkey-py, and hash and set pages fetch less (sets in server order). Values and edit fingerprints are read atomically, the TTL form only appears on backends withexpire()andpersist(), and quoted driver errors mask URL credentials. - A transport's
NotSupportedErrorpasses throughTrackingCacheunchanged, with the server's reason. - valkey-glide: a multi-URL
LOCATIONwhose URLs differ only in a valueOPTIONS["db"],["username"]or["password"]overrides is accepted. - CI runs
ruff check --no-fix; plainruff checkfixed the checkout and passed. - CI tests the Django version each matrix cell names, runs free-threaded tests with
PYTHON_GIL=0, fails whenglidecannot be imported, and lints with the full pre-commit hook set. LocMemCache.zremrangebyscore()runs in O(log N + k) instead of scanning every member.clear_all_versions()andaclear_all_versions()default toNotSupportedErroronBaseCachex, soLocMemCache,DatabaseCacheandTrackingCacheraise it instead ofAttributeError.- CI tests the installed wheel, not the checkout, and runs the cache tests on Redis 6.2 and Valkey 7.2.
expiretime()is documented as needing Redis 7.0+ (every Valkey release has it), and theNotSupportedErroran older server raises names that release.
Fixes¶
- The PyPI
Changeloglink pointed at the nonexistentreference/changelog/; it now points atlatest/reference/changelog/. - Nil stream entries no longer crash
pipe.execute()forxclaim(),xautoclaim(),xread()andxreadgroup(); they decode to(id, {}). zadd()with an empty mapping returns0, andxadd()with no fields raisesValueError, instead of the driver'sDataError.KEY_PREFIXglob escaping covers backslashes; a\madekeys(),iter_keys(),scan(),delete_pattern(),clear_all_versions()andmake_pattern()match a different prefix or nothing.NotSupportedErrorandKeyNotFoundErrorsurvivepickle,copy.copy()andcopy.deepcopy()with their message andoperation,backend,detailandkeyintact.- The admin key pages no longer return HTTP 500 for an unreachable backend or overwrite a key whose type changed since the page loaded. Key add rejects types outside the creatable set, and the Help link on an unmodelled type shows the generic text instead of nothing.
- valkey-glide: a
LOCATIONit cannot dial, such as a unix-socket URL, raisesImproperlyConfiguredinstead of connecting tolocalhost:6379.xtrim()withoutmaxlenorminidraisesValueError, the pipelinedxread()returnsNonefor an empty read,aget_many()raisesCachexErrorwhen its TTL batch fails instead of serving every key as fresh, andaclose()no longer leaves per-loop registry entries behind. DatabaseCache.incr(),decr()and the async twins no longer lose concurrent increments or reset the key's timeout; a collection key raisesWrongTypeError.incr_version(key, 0)anddecr_version(key, 0)onLocMemCacheandDatabaseCacheleave the key in place instead of deleting it.LocMemCache.ttl()rounds like RedisTTL; a key just written withtimeout=300read299.hset()with an odd-lengthitemslist onLocMemCacheandDatabaseCacheno longer leaves a half-written hash.zadd()onLocMemCacheandDatabaseCacherejects conflictingnx/xx/gt/ltcombinations withValueErrorinstead of silently accepting them.DatabaseCache.info()with a missing cache table on PostgreSQL no longer poisons the caller's open transaction.- The semaphore state hash no longer carries an unused
capacityfield. OPTIONS["username"]andOPTIONS["password"]overrideLOCATIONURL credentials, in userinfo or query string, on the redis-py and valkey-py standalone, Sentinel and cluster backends, as documented.Pipeline.zadd()with an empty mapping andPipeline.hmget()with no fields return0and[]instead of raisingDataErroror failing the batch.- Sentinel
aclose()removes the current loop's Sentinel manager under the async registry lock. - valkey-glide: conflicting
zadd()flags,set()with bothnxandxx, andzrangebyscore()orzrevrangebyscore()with only one ofstartandnumraiseValueError, in pipelines too, instead of being silently mishandled. Dynamically formatted Lua sources no longer leak memory. - A multi-server
LOCATIONwith a trailing separator or blank entries no longer produces an empty server URL. - On the RESP backends, invalid
spop(count=...),lpos(rank=..., count=..., maxlen=...)andlinsert(where=...)arguments raiseValueError, notResponseError. - A
KeyboardInterruptorasyncio.CancelledErrorlanding while a semaphoreacquire()reply is in flight no longer leaks the claim. Semaphore.extend()andaextend()on the RESP backends returnFalseafterrelease()or when never acquired, instead of raisingSemaphoreError.- The admin masks
?password=and&password=query parameters in cache URLs, and the hash field-name input is read-only without the change permission. - The
RespClusterCache.lock()andalock()docs no longer blameEVALSHAfor theirNotSupportedError. LocMemCacheandDatabaseCacheon Django 6.0:aincr(),adecr(),ahas_key(),aget_many(),aincr_version()andadecr_version()(andadelete_many()onDatabaseCache) use the sync implementation.aincr()no longer resets the TTL, andahas_key(),aget_many()andaincr_version()no longer raiseWrongTypeErroron collection keys.TrackingCache.has_key()andahas_key()no longer count as hits ininfo()["tracking"]["hits"]or refresh the entry's LRU position.- The admin cache changelist no longer returns HTTP 500 when an alias's backend constructor raises; the row shows the masked error, and that alias's other pages redirect to the changelist with a message.
- The admin hash detail page says "No fields on this page." instead of "Hash is empty." when a concurrent delete emptied only the page.
- The admin key add form only offers types the backend can write (no
StreamonLocMemCacheandDatabaseCache, onlyStringonTrackingCache), and stock Django backends get no "Add key" link. - Removing a collection's last member in the admin keeps the user on the key in create mode instead of showing a "does not exist" error.
- The admin key list on a
TrackingCachealias no longer logs a traceback per container key; their size column stays empty. - The list form of
LOCATIONis cleaned like the string form: entries are stripped, blanks dropped, non-string entries raiseImproperlyConfigured, and a list with no usable entry raises the same error as an empty string. decode_responseswith any value in aLOCATIONquery string is rejected likeOPTIONS["decode_responses"].lpop(),rpop(),zpopmin(),zpopmax()and the async twins on the RESP backends raiseValueErrorfor a negativecount.zadd()with conflicting flags andzrangebyscore()/zrevrangebyscore()with only one ofstartandnumraiseValueError, notDataError, on redis-py, valkey-py and every pipeline at queue time.cache.lock()andalock()reject aleasebelow one millisecond withValueErrorinstead of failing every acquire.RespSemaphore.acquire()andaacquire(): a second cancellation, interrupt or error during an interrupted acquire's cleanup no longer leaks the claim or leaves the instance held.Semaphore.extend(),aextend()and the RESP semaphoreleaserejectNaNand infinity withValueErrorinstead of an unrelatedValueErrororOverflowError; the localextend()silently returnedTrue.Pipeline.zadd(incr=True)raisesValueErrorat queue time on every driver unless the mapping holds exactly one member; redis-py and valkey-py raisedDataError, and valkey-glide failed mid-batch.Pipeline.xadd()with emptyfieldsraisesValueErrorat queue time, likecache.xadd(), instead of a driver-specific error.TrackingCache.aget_or_set()awaits anasync defdefault instead of serializing the coroutine object.TrackingCacherejects Django's legacy lowercasetimeoutkey on its own alias orOPTIONSwithImproperlyConfiguredinstead of silently ignoring it.LocMemCacheandDatabaseCachezrangebyscore()/zrevrangebyscore()with only one ofstartandnumraiseValueErrorinstead of ignoring the window.LocMemCacheandDatabaseCachehset(items=...)raise the RESP backends'ValueErrorfor an odd-length list.DatabaseCache.zpopmin()andzpopmax()withcount=0return[]without rewriting the row.- The wheel CI job failed with
No module named 'tests'. DatabaseCache.keys()anddelete_pattern()no longer assume Django'sprefix:version:keylayout; under a customKEY_FUNCTION,keys()returned mangled keys anddelete_pattern()deleted nothing.- A
TrackingCacheinstance created before a fork (gunicorn--preload, Celery prefork, warmed at import time) drops the inherited store in the child and starts its own listener without logginglistener thread died, restartingin every worker.
0.9.0 (September 2026)¶
Breaking changes¶
InvalidationListenerProtocol.client_ids, the(subscriber, tracker)pair, is nowclient_id, a singleint. The listener holds one connection.
Improvements¶
- The
CLIENT TRACKING BCASTlistener behindTrackingCacheruns over one RESP3 connection instead of two RESP2 ones.
0.8.0 (September 2026)¶
Breaking changes¶
TieredCacheis removed;TrackingCachewithOPTIONS["coherence"] = "ttl"replaces it. Pointtransportat the L2 alias, renamel1_timeouttolocal_timeout, moveMAX_ENTRIESto theTrackingCachealias and drop the L1 alias. The transport must be a cachex Valkey/Redis backend; a stock Django L2 has no replacement.info(),slowlog_get()andslowlog_len()raiseNotSupportedErroron backends that lack them instead of an empty result.LocMemCache,DatabaseCache,StreamCacheandTrackingCacheimplementinfo(); only the Valkey/Redis backends have a slow log, andTrackingCachedelegates it to its transport.Pipeline.zcount(),zrangebyscore(),zrevrangebyscore()andzremrangebyscore()takemin_scoreandmax_scoreinstead ofminandmax, andstartandnumare keyword-only onzrangebyscore()andzrevrangebyscore(), matchingRespCache. Callers passing the bounds positionally are unaffected.- The valkey-glide adapter's keywords match
RespAdapterProtocol:zcount(key, min_score=..., max_score=...)(wasmn/mx),sdiffstore(dest=...)(wasdst),xack(*entry_ids)(was*ids).zadd()takes keyword-onlynx,xx,ch,gtandltinstead of silently ignoring unknown**kwargs, and the sorted-set range methods take keyword-onlywithscores,desc,startandnum. - The valkey-glide pipeline's stream keywords match the protocol:
xclaim(message_ids=...),xgroup_create(id=...),xgroup_setid(id=...),xdel(*entry_ids),pexpire(milliseconds=...), andxpending_range()takes keyword-onlymin,max,count,consumernameandidle. ValkeyGlidePipelineAdapterno longer has the undocumentedmget()andmset().- A
LOCATIONlist whose URLs disagree on TLS scheme, username, password or database raisesImproperlyConfiguredon the valkey-glide backends instead of silently using the first URL's settings.
Features¶
TrackingCache: a local read cache over an existing redis-py or valkey-py alias, kept coherent byCLIENT TRACKINGbroadcasts. Nothing is cached while its listener is down; cluster and valkey-glide transports are rejected. WithOPTIONS["coherence"] = "ttl"no listener runs,local_timeoutalone bounds staleness, and any transport works. See Composite backends.- Adapters gained
invalidation_listener(prefixes), aCLIENT TRACKING BCASTsubscription; the cluster and valkey-glide adapters raiseNotSupportedError.
Improvements¶
- Hash field expiration on the redis-py, valkey-py and valkey-glide adapters, with async twins and pipeline support:
hexpire(),hpexpire(),hexpireat(),hpexpireat(),httl(),hpttl(),hexpiretime()andhpersist()set, read and remove per-field TTLs, andhsetex()/hgetex()write or read fields while setting their TTL. They need Redis 7.4+ or Valkey 9.0+ (hsetex()/hgetex(): Redis 8.0+ or Valkey 9.0+) and raiseNotSupportedErroron older servers. - Key deletion sends
UNLINKinstead ofDELon every RESP adapter:delete(),delete_many(),delete_pattern(),set(timeout=0), the pipelinedelete()and the cluster per-slot paths. NotSupportedErrorgains adetailattribute, and the RESP adapters, cluster included, raise it instead of a driverResponseErrorfor a command the server lacks, withoperationset to the command name.TrackingCachedelegatesslowlog_get()andslowlog_len()to its transport, alongsideinfo().DatabaseCache.incr_version(),decr_version()and the async twins move the key like RedisRENAME: a collection key no longer raisesWrongTypeError, and the key keeps its remaining TTL instead of the cache default.StampedeConfigvalidates its arguments on construction (buffera non-negativeint,betaanddeltafinite non-negative numbers), so a badstampede_preventionoption fails at configuration instead of on every timed write.
Fixes¶
- A collection command with no members is a no-op on every backend, direct, async and pipelined, instead of a driver error:
sadd(),srem(),hdel(),hset(),lpush(),rpush(),zrem(),xdel()andxack()return0,smismember()andzmscore()an empty list. OnLocMemCacheandDatabaseCache,lpush()andrpush()on an existing list used to return its length. On valkey-glide this replaces the 0.7.0ValueErrorforhset()with an empty mapping. hset(key, items=[...])rejects an odd-lengthitemslist withValueErrorinstead of sending mis-paired arguments.- Sentinel backends keep Sentinel discovery and failover when
LOCATIONuses a TLS scheme (rediss://orvalkeys://). xread()andxreadgroup()decode correctly underOPTIONS = {"protocol": 3}, both directly and on a pipeline, where they raisedValueError.- Stream reads decode a nil entry (from Redis 6
XCLAIM) to an empty field dict instead of raising. aclose()disconnects only the calling alias's pools, and on cluster its own client, on the running event loop. It used to disconnect every alias's pools, dropping other aliases' in-flight connections.- Sentinel
aclose()no longer leaks discovery clients when a different adapter instance runs it, as under asgiref. - Pipelined
sadd()rejects an unhashable member likesadd()does, instead of storing it and making every later read of the key raiseTypeError. - Pipelined hash field commands (
hexpire(),hpexpire(),hexpireat(),hpexpireat(),httl(),hpttl(),hexpiretime(),hpersist(),hgetex()) called with no fields return[]instead of failing the whole batch. keys(),scan(),iter_keys()anddelete_pattern()onDatabaseCachematch case-sensitively on SQLite.DatabaseCacheno longer raisesOverflowErrorstoring atimeout=Nonekey underUSE_TZ = Truewith a databaseTIME_ZONEeast of UTC.DatabaseCachereports the right TTL and expiry when the databaseTIME_ZONEdiffers from UTC.- An empty pattern matches only the empty key on
LocMemCacheandDatabaseCache. It used to match every key, sodelete_pattern("")cleared the cache. LocMemCachesorted-set values can be pickled, andcopy.deepcopyno longer duplicates their internal ordering index.LocMemCache.info()["memory"]counts the ordering index of sorted sets, so a cache holding large sorted sets reports roughly twice the size it did.- A character-class range written backwards, such as
keys("[z-a]"), matches the keys Redis matches instead of raising a regex error. lpos()rejects a negativecountormaxlenwith Redis'sCOUNT can't be negativeandMAXLEN can't be negativeinstead of searching from the wrong end.linsert()onLocMemCacheandDatabaseCacheraisesValueError("syntax error")for awhereother than"BEFORE"or"AFTER"instead of inserting before the pivot.spop()with a negativecountraises Redis'svalue is out of range, must be positiveon the native backends instead of an internalrandom.samplemessage.TrackingCacheno longer rolls the stampede dice twice for a locally held value. A local hit that triggers recompute returns the default without refetching from the transport, so early recompute followsbetaanddelta.TrackingCachepings its invalidation connection everyhealth_check_intervalseconds, not only while idle, so a dropped connection no longer goes unnoticed.- The first
TrackingCachelistener connect fromaget(),aget_many()orahas_key()runs in a worker thread instead of stalling the event loop. TrackingCache.incr_version(),decr_version()and the async twins delegate the rename to the transport and forget local entries for both versions, so a transport alias withVERSIONis read at the right version.TrackingCache.delete_pattern()evicts local entries by Redis glob rules ([^0]is a negation), matching what the transport deletes.TrackingCacherejects a non-iterableOPTIONS["prefixes"]withImproperlyConfiguredinstead of a bareTypeError.- A
TrackingCachelistener whose shutdown was abandoned no longer clears the local store that its live replacement keeps coherent. StreamCachekeeps the local value when a broadcast is dropped for lack of publish budget or on a closed executor, and skips the superseded own entry instead of overwriting it. Other pods keep their last value until the key's next write or expiry.StreamCacheno longer leaks own-entry marks when anXADDfails or when the stream trims an own entry away.StreamCachelogs the consumer traceback once per transport outage and repeats only the one-line warning while the outage lasts.ValkeyGlideCacheandValkeyGlideClusterCachehonor everyLOCATIONURL, not only the first; standalone uses the extra URLs as replicas withread_from=PREFER_REPLICA. Repeated URLs collapse to one address.- valkey-glide:
zadd(),azadd()and the pipeline'szadd()without flags no longer store a serializedbytesmember as its repr. - valkey-glide:
xadd()andxtrim()raiseValueErrorwhenmaxlenandminidare given together instead of silently droppingminid. - valkey-glide: an atomic batch the server discarded raises
CachexErrorinstead of returning an empty result list. - The cache admin masks connection passwords in
LOCATIONand backend errors; users with onlyview_cachecould read them. - A type-specific admin write on a backend without
type()(any stock Django backend) returned a 500; the admin now refuses it with a message and keeps Delete and Set TTL available. - The admin reads values with stampede prevention bypassed; a key past its logical expiry showed
null, and Update wroteNoneback. - The admin key list's
type=unknownfilter matched nothing; it now lists the keys it names. - The admin key list showed Clear and Add key to users without
change_cacheandadd_key; each now needs its permission. - Deleting an already-gone key in the admin reported success; the key page now warns "Key not found, nothing was deleted." and the bulk action counts misses separately.
- The admin cache detail page hides the Slow Log section on backends without a slow log instead of showing an error.
- An explicit
timeout=Nonepassed to a semaphore'sacquire()oraacquire()blocks indefinitely instead of falling back to the semaphore'stimeout. extend()andaextend()raiseValueErrorwhenadditional_secondsis zero or negative instead of returningTrue;extend(-30)silently shortened the claim.
0.7.1 (September 2026)¶
Breaking changes¶
renamenx()andarenamenx()returnFalsefor a missing source key instead of raisingValueError.
Improvements¶
rename()andarename()raiseKeyNotFoundErrorfor a missing source key, and both methods document it. The exception subclassesCachexErrorandValueErrorand carries the key as.key.
0.7.0 (September 2026)¶
Breaking changes¶
LocMemCache.ttl(),DatabaseCache.ttl(),StreamCache.ttl()and theirpttl()twins returnNonefor a key with no expiry instead of-1.-2still means the key is gone.DatabaseCache.get()raisesWrongTypeErroron a key holding a list, set, hash or sorted set instead of returning the raw tagged container, andget_many()omits it.Pipeline.zadd()no longer takesincrandPipeline.zrange()no longer takesdesc, matchingRespCache.RespCache.adecr()is gone as an override. It duplicatedBaseCache.adecr()line for line, which is what callers get now.RespAdapterProtocolno longer declaresget_async_client(); the redis-py and valkey-py adapters keep the method.Pipeline.set()reports annx/xxmiss asFalseinstead of the driver'sNone.Pipeline.type()returnsNonefor a missing key instead of"none", andKeyType.UNKNOWNfor an unmodeled server type instead of raising, matchingcache.type().aclose()disconnects and drops the running loop's async pools, so the next await opens fresh ones; on cluster it closes the loop's cluster client, on Sentinel also its Sentinel manager and discovery clients. Call it when a loop is finished, not between requests.close()keeps sync pools connected but now sweeps the async registries.pool_classon a Sentinel backend, previously ignored, selects the Sentinel-managed pool and must beSentinelConnectionPoolor a subclass, or startup raisesImproperlyConfigured.TieredCacheraisesImproperlyConfiguredwhenOPTIONS["l1_timeout"]is unset and the L1 tier hasTIMEOUT = None. Setl1_timeouton the tiered alias orTIMEOUTon the L1 tier.StreamCache.set()returnsNone, matching Django'sBaseCacheand the other backends. It previously returnedTrue.ValkeyGlideAdapterandValkeyGlideClusterAdapterraiseImproperlyConfiguredfor an emptyLOCATIONinstead of anIndexError.xpending()on the valkey-glide backend returns the other backends' summary and range dicts, and rejects a filter without a count. Code that unpacked the raw list replies reads the dict keys instead.hset()with an empty mapping raisesValueErroron the valkey-glide backend, direct, async and pipelined. The pipeline used to queue nothing.
Improvements¶
DatabaseCache.scan(key_type=...)pushes the type filter into the query instead of checking keys one by one.DatabaseCache.sinter,sdiffandsunionread every operand in one query instead of one query per key.LocMemCachesorted-set range and count queries bisect instead of scanning, andLocMemCache.get/has_keyno longer unpickle a value they only test for presence.- The admin shows a key whose type it cannot render, including an unmodeled type, read-only: the type is named, the value and operations hidden, and hand-crafted edits refused, but delete and TTL changes go through. Such types are never offered when adding a key.
- The admin key detail page hides mutation controls from users without
change_keyordelete_keyinstead of showing forms that fail with a 403. - The admin add-key page takes only a key name and a data type; the first operation on the key detail page creates the key.
Fixes¶
- Sorted-set score edits in the admin no longer fail on backends without server-side scripting (
LocMemCache,DatabaseCache); the conflict check is offered only where it can run. - The admin key detail page no longer shows "Could not load value" for a stream with all entries deleted.
- Admin breadcrumbs render styled on Django 6.0, and the Help and List Keys links render there again.
- A cache whose
BACKENDcannot be imported shows a message on every admin page instead of a 500 on the cache detail, key detail and add-key pages. - The admin danger zone (clear all versions, FLUSHDB) appears only on backends that implement it; elsewhere it raised
AttributeError. - A failed first operation while creating a key in the admin keeps you on the create page instead of bouncing you to the key list with "key does not exist".
- The native backends read Redis's glob dialect, not
fnmatch's, inkeys,scananddelete_pattern: negation is[^a], not[!a],!is a member,\escapes, and case no longer folds on Windows. LocMemCache.zpopmin/zpopmaxandDatabaseCache.lpop/rpop/zpopmin/zpopmaxreject a negative count;zpopmax(key, -2)popped all but the last two members.LocMemCache.zrangebyscorehonors the Redis rule that a negativenummeans "to the end";LIMIT 0 -1dropped the last member.LocMemCache.zaddandzincrbycoerce string scores tofloatlike Redis; a stored"1.5"sorted as a string and made the next numeric write raise.zaddwith an invalid score rejects the command instead of applying it halfway.lposrejectsrank=0on both native backends with Redis's own message, and a negative rank appliesmaxlenfrom the tail instead of the head.srandmemberwith a negative count returns exactly|count|members, repeats allowed. Both native backends reachedrandom.sampleand raisedValueError.LocMemCache.ascan()works instead of raisingNotSupportedError, so the admin's async key browser can page a LocMem cache.DatabaseCachereports the key in itsWRONGTYPEmessages, matchingLocMemCache.- Exclusive score bounds (
(1) raiseNotSupportedErrornaming the backend onLocMemCacheas well, rather than a bareValueError. StreamCachepolls the stream instead of parking inXREAD BLOCKon a valkey-glide transport; with two pods in one process, publishes timed out and mutations went missing on the other pod.StreamCacheshares one consumer thread, publisher thread and pod identity perLOCATIONwithin a process. Each ASGI request and WSGI thread used to start an uncollectable consumer and publisher with its own pod id, so sibling consumers treated the process's own writes as remote.StreamCachepods writing the same key inside the propagation window converge on the last write in stream order instead of diverging permanently. A pod's ownclearcoming back spares keys it wrote after clearing.- Restarting a
StreamCacheaftershutdown()no longer leaves two consumers advancing the same stream cursor. StreamCache.delete_pattern()publishes one broadcast for the whole match instead of one per key, so a large pattern no longer exhausts the publish budget.- The RESP semaphore's
{name}:stateand{name}:claimshashes carry a guard TTL of twice the longest lease, refreshed by acquire, extend and release, so a dead holder no longer strands them. - Async connection pools of closed event loops are released: every pool lookup and every
close()drops them.async_to_synccallers leaked one pool and one TCP connection per call, andclose()andaclose()were documented no-ops. type()andatype()returnKeyType.UNKNOWNfor a Redis module key (ReJSON-RL,TSDB-TYPE) instead of raisingValueError, which broke any scan reaching one.IntEnumandIntegerChoicesmembers with values 48 to 57 round-trip through msgpack and ormsgpack as plain ints instead of reading back as 0 to 9; pickle still returns the enum member.sadd()rejects a member the configured serializer turns unhashable, such as a tuple under json, orjson, msgpack or ormsgpack, instead of failing later insmembers().expireat()andpexpireat()with a past deadline delete the key on a stampede cache instead of keeping it forbufferseconds.Pipeline.set()withnxorxxand an immediate expiry no longer deletes a key it could not write, andnxwithxxis rejected.- Pipelined
expire(),pexpire(),expireat()andpexpireat()add the stampede buffer and pipelinedttl(),pttl()andexpiretime()subtract it; all seven take keyword-onlystampede_prevention. - Pipelined
xpending()accepts the client's arguments:countalone is allowed, and a range withoutcountraisesValueErrorinstead of returning the summary. SerializerErrorandCompressorErrorcarry a message naming the codec, the payload and the underlying cause. They were raised bare.- valkey-glide: a username without a password (a nopass ACL user) no longer fails client construction; credentials are built only with a password.
- valkey-glide:
hmget()with no fields returns an empty list instead of sending a malformed command to the server. - valkey-glide:
lpop()andrpop()with a count returnNonefor a missing key. - valkey-glide: pipelined
xpending()andxpending_range()decode their replies like the direct calls instead of returning raw driver output. - valkey-glide:
xinfo_stream(full=True)decodes the group and consumer entries nested inside its list values. - valkey-glide: async clients of closed event loops are closed; one
asyncio.run()per request leaked a client and its connections each time. - valkey-glide:
type()returnsKeyType.UNKNOWNfor an unmodeled server type andNonefor a missing key instead of raising. - valkey-glide: pipelined
set()takespx,exat,pxat,keepttlandget, rejects conflicting expiry flags, and returns the old value forget=True. - valkey-glide: stream, list and server methods use the adapter protocol's parameter names (
entry_id,startandend,slowlog_get(count)), so keyword calls bind. - valkey-glide: a blocking lock acquire no longer sleeps past its
blocking_timeout. - valkey-glide: cluster pipelines build a
ClusterBatch, the batch type the cluster client is declared to execute.
Documentation¶
set_with_flags(get=True)documents what it returns: the driver's raw previous value, which the cache layer decodes.- The documented server requirement said "Valkey 7.0+", but Valkey's first release was 7.2; the README, the docs home page and the installation page now say "Valkey 7.2+ or Redis 6.0+".
- The distributed-locking recipe passed
timeouttolock.acquire(), which raisedTypeErroron the redis-py and valkey-py backends; it now setstimeoutoncache.lock(). - The example projects' READMEs had the wrong Valkey port for the full example,
cd examplefor a directory namedsimple,admin/adminwhererun.shcreatesadmin/password, a../.venvpath one level short, and a cache table without thecluster,sentinel,syncandstream_transportaliases. The full example'srun.shannounced a nonexistentSyncCachebackend instead ofStreamCache.
Tooling¶
- The release workflow runs
tests/admin/as well astests/cache/before tagging. - Dropped the
scripts/**ruff per-file-ignores entry; the directory it covered was removed in 4acfe2e. - The cache test matrix is parametrized by topology (
default,cluster,sentinel) instead of an independent client class and sentinel flag;client_classandsentinel_modederive from the active topology. - Container fixtures pass their addresses directly instead of through the environment, use
LogMessageWaitStrategyinstead of the deprecatedwait_for_logs, and pick db numbers withcrc32, nothash(). - Tests that toggled the never-read
DJANGO_REDIS_SCAN_ITERSIZEandDJANGO_REDIS_CLOSE_CONNECTIONsettings drive the realitersizeargument and a real close; pytest-django'ssettingsfixture replaces the vendoredSettingsWrapper. - The valkey-glide adapter no longer needs its module-wide
ignore_errorsmypy override or its file-wideruff: noqa: ERA001.
0.6.0 (August 2026)¶
Breaking changes¶
DatabaseCachestores collections as tagged subclasses (_List,_Set,_Hash,_ZSet), likeLocMemCache. Compound operations on untagged values, including rows older versions wrote, raiseWrongTypeError. Clear the cache table or re-write those keys before upgrading.type()returnsNonefor a missing key; theBaseCachexdefault answeredSTRING.scan(key_type=...)filters; theBaseCachexdefault,StreamCacheandDatabaseCacheignored it, so the admin's Type filter returned unfiltered results.get_client()andget_async_client()return one shared client per connection pool on the redis-py and valkey-py adapters; construction dropped from 55 µs to 0.06 µs per call. Mutating it, for example withset_response_callback, affects other calls.StreamCachebroadcastsdelete_manyas a list; a key containing\x00made remote pods delete the wrong keys. Old and new pods disagree on this message: drain or rotatestream_keyduring the rollout.Pipeline.set()applies the backend'sTIMEOUT, nottimeout=None(no expiry), and normalizes negative and float timeouts likecache.set()instead of raising.- With
stampede_preventionon,expire(),pexpire(),expireat()andpexpireat()add the stampede buffer andttl(),pttl()andexpiretime()subtract it. Each takes a keyword-onlystampede_preventionargument for the raw value. sadd()rejects an unhashable member withTypeErrorand adds nothing; such members broke every later read.RespCache,RespClusterCacheandRespSentinelCacheraiseImproperlyConfiguredwhen used as aBACKENDdirectly; they bind no driver and exist to be subclassed.xpending(), its pipeline form and the async twins raiseValueErrorforstart,end,consumeroridlewithoutcount; they returned the unfiltered summary.
Features¶
- Async admin surface on
TieredCache:akeys,aiter_keys,ascan,attl,apttl,atype,apersist,aexpireandadelete_pattern. ascan()onDatabaseCache, which had the syncscanbut inherited theNotSupportedErrordefault for the async twin.get_many()andincr_version()onLocMemCache, both collection-aware and both reading under a single lock acquisition.expire()andpexpire()accept atimedeltaon the valkey-glide adapter.
Fixes¶
hget/hgetall/hvalsread a field written byHINCRBYFLOATinstead of raisingSerializerError. A float written byhsetis still not incrementable, as documented onencode()andhincrbyfloat.aget_or_set()awaits a default that returns an awaitable, not only a coroutine function.incr_version()works on a cluster with a hash-taggedKEY_PREFIX;KEY_PREFIX="{app}"was rejected.CULL_FREQUENCYandMAX_ENTRIESare no longer forwarded to the driver's connection pool.semaphore()reports a missingleaseas aValueErrornaming the argument.LocMemCache.get()andincr()no longer raiseKeyErrorunder concurrent writes.- A
LocMemCachecollection rewrite atMAX_ENTRIESno longer strips the key's TTL or evicts a sibling; culling runs only on a first write. lpush/rpushwith no values return 0 instead of creating an empty, immortal key.lpop/rpopreject a negative count;rpop(key, -2)popped from the head.TieredCachemutates L2 before invalidating L1 indelete,delete_many,incr,decr,expire,delete_pattern,clearand the async twins; concurrent reads left L1 stale.TieredCacheasync methods call L1's async twins; the sync ones raisedSynchronousOnlyOperationon a guarded L1.TieredCache.delete_many()andclear()no longer report failure against a non-RESP L2.TieredCacheno longer masks anAttributeErrorraised inside an L2 method asNotSupportedError; only a missing method reports it.TieredCacherejects self-referencing and duplicate tier aliases at construction instead of raisingRecursionErroron the firstget().- RESP semaphores no longer admit far past capacity after a release on an evicted state hash.
- The semaphore queue score comes from the server; a host with a slow clock starved other hosts' waiters.
- Growing a local semaphore's capacity wakes the parked waiters it now fits.
- Releasing a local semaphore no longer raises
RuntimeErrorwhen a waiter's event loop has closed. - The local semaphore registry drops names nothing references. It held every name forever, so
cache.semaphore(f"job:{id}")grew it without bound. - The RESP semaphore deletes its
{name}:statehash on the last release instead of leaking it. release()logs a warning when the claim was already reaped, meaning the work ran past its lease unprotected.- The capacity-change warning points at the caller of a directly constructed
Semaphore(...). - Pipelines on the redis-py and valkey-py adapters raise
WrongTypeErrorforWRONGTYPE, not the driver'sResponseError. - Count-form
lpop/rpopreport a missing key asNone, likeLocMemCache, instead of[]. hmget(key)with no fields returns[]instead of sending an invalid command.- RESP3 dict replies (
OPTIONS {"protocol": 3}) no longer break stream result decoding withValueError. - Sentinel connections inherit the driver's socket timeouts; the old
sentinel_kwargsdefault of{}let a blackholing sentinel block discovery. - Connection pools are keyed stably; an option like a
Retryinstance opened a new pool per cache instance. - An empty
LOCATIONserver list raisesImproperlyConfigurednaming the backend instead of failing on first use. INFOparses on a valkey-glide cluster, pinned to a random node; the admin's memory and keyspace panels were blank.set_many()on valkey-glide cannot leave a key without a TTL when a batch breaks partway.- The valkey-glide lock's
__enter__and__aenter__raiseLockError, notRuntimeError;extend()refuses a lease-less lock instead of making it self-release. aclose()on the valkey-glide adapter closes the per-loop client instead of leaking its connection.- The valkey-glide pipeline raises on an empty
hsetmapping; skipping it shifted every later result. xclaim(justid=True)returnsstrIDs from a pipeline, matching the non-pipeline path.ttl,pttlandexpiretimenormalize -1 toNonein pipelines, andrenamereturns aboolacross drivers.DatabaseCachedeletes a collection row whenlrem,srem,hdel,zremorltrimremoves the last member.DatabaseCacherejects(exclusive score bounds instead of raising a bareValueError, and reads a negativenumas "to the end".- On
DatabaseCache, aKEY_PREFIXwith a glob character no longer matches sibling prefixes:KEY_PREFIX="svc?1"madekeys("*")returnsvcX1rows. DatabaseCache.info()reports a realexpirescount; compound operations use one write connection under a routing router; pattern deletes run initersizechunks.StreamCache.get_or_set()follows Django's semantics: a storedNoneis a hit, and aNonedefault is stored.StreamCache.info()["last_read_age"]stops growing on an idle stream.StreamCacheraisesNotSupportedErrorrather thanAttributeErrorfor the cachex operations it does not implement, andexpire()accepts a float.StreamCachejoins its consumer thread with a bound at interpreter exit;close()stays a no-op.- Key patterns on
LocMemCacheandStreamCachematch case-sensitively on Windows, like Redis globs. scan(count=0)on theBaseCachexdefault,DatabaseCacheandStreamCacheno longer turns the count into 100. It returns no keys and cursor 0, which ends a scan loop.OrmsgpackSerializeraccepts non-string mapping keys, likeMsgpackSerializerwith itsstrict_map_key=False.LocMemCache.info()sizes modules, classes and functions opaquely instead of walking the import graph under the cache's lock; a value holdingsyscost 11 ms and 2.4 MB.- A LocMem or Database
BACKENDno longer imports redis-py or valkey-py; names resolve on first access. - The key admin no longer localizes scores, TTLs, list indices or page numbers, so edits work under
deorUSE_THOUSAND_SEPARATOR. - Creating a key in the admin requires
add_key; the create form and actions on missing keys checked onlychange_key. - An admin TTL of
"00","+0"or"-0"makes the key persistent instead of deleting it. - The admin's
lremremoves one occurrence by default, not every occurrence. - The admin warns when a
ZADDchanged nothing, counting score updates viaCH. - The admin key detail no longer raises on a value the serializer rejects.
- Admin sorted set members that deserialize to a list or dict are read-only; submitting them raised
TypeError. - The admin's
xtrimis exact, and the Help link preserves the current page and type filter. - The cache and key admins' per-object history and delete routes return 404.
Documentation¶
- The
OPTIONSreference says which backends honor each key, with a valkey-glide section coveringdb,use_tls,username,password,request_timeoutandclient_name; glide ignoresssl_*. - The valkey-glide description names
glide_sync.GlideClientfor the sync surface andglide.GlideClientfor thea*methods. cache.lock()'s documented signature matches the code, including that the second positional argument isversion, not the lease.- The admin permission list matches the views:
change_cachegates cache-wide actions,change_keyevery key detail mutation including TTL and persist. - The README and
docs/index.mdsay streams are RESP-only;LocMemCacheandDatabaseCachecarry only the hash, list, set and sorted set operations. - README screenshots load on PyPI, which does not resolve repository-relative image paths.
semaphore()documents its keys outside the cache's namespace ({name}:state,:claims,:queue), whichclear(),keys()and the admin skip.sscan()andsscan_iter()document thatmatchruns server-side against the serialized member unless it is a plain string.incr()documents where it diverges fromBaseCache.incrand fromLocMemCache.
0.5.1 (August 2026)¶
Improvements¶
TieredCache.get_manybatches its L2 TTL lookups into one pipeline where L2 supports it.- The sdist no longer ships example and benchmark files.
- Free-threaded CPython is verified in CI again: the cache suite runs on 3.14t.
Fixes¶
django_cachex.adapters._pipeline_parsersremoved; it was left over from the dropped Rust driver.versionandPackageNotFoundErrorno longer leak intodjango_cachex's namespace.- The admin key-size lookup logs its failures instead of silently showing a blank size.
- Admin breadcrumbs render with Django 6.1's markup instead of drawing unstyled.
- Admin object tools (Help, List Keys) sit next to the page title again instead of below it.
Documentation¶
- The
usernameconnection option is documented: anOPTIONSkey on every adapter that takes precedence over the URL.
0.5.0 (August 2026)¶
Breaking changes¶
- The
redis-rsbackends are gone:RedisRsCache,RedisRsSentinelCache,RedisRsClusterCache,django_cachex.adapters.redis_rs, theredis-rsextra and thedjango-cachex-redis-rspackage. The driver never reached PyPI; switch toValkeyCache,RedisCacheorValkeyGlideCache. A standalone redis-rs-py binding is in progress. django_cachex.Lockanddjango_cachex.AsyncLockremoved; they wrapped the Rust driver's lock commands.cache.lock(),LockErrorandLockNotOwnedErrorare unchanged.
0.4.2 (August 2026)¶
Improvements¶
- Every key admin value input is the same textarea, so push, add and set-field forms accept multi-line JSON.
- Set and sorted set members can be edited; an interrupted rename leaves a duplicate, not a lost member.
- Hash field names can be edited atomically; a rename is refused if the value changed since page load or the name exists.
Fixes¶
- Container entries that are not JSON-serializable are read-only; submitting their
repr()stored the repr string over the real value. xaddparses its value like every other handler, so a stream entry can hold a number, list or dict.- The string editor strips surrounding whitespace, so a stray newline no longer changes the stored value.
0.4.1 (August 2026)¶
Fixes¶
- The admin's value textarea no longer overflows its container.
- Admin warnings and field errors are readable in dark mode.
- Semaphore
release()andextend()no longer act on a token installed by a racing re-acquire on the same instance. - RESP semaphores no longer wedge or admit past capacity when Redis evicts their bookkeeping.
0.4.0 (August 2026)¶
Historical record
Entries below describe 0.4.0 as released. Since 0.5.0 the package is pure
Python again, without the Rust extension, the redis-rs backends or binary
wheels, so the cibuildwheel and cp314t wheel entries are outdated.
Breaking changes¶
- Python 3.14+ required. Dropped support for 3.12 and 3.13. The package now ships on cp314 and cp314t (free-threaded) wheels.
- Django 6.0+ required. Dropped support for Django 5.2.
LocMemCachedata structures use tagged subclasses (_List,_Set,_Hash,_ZSet), and cross-type access raisesWrongTypeErrorinstead of silently coercing.LocMemCachebypasses pickle for tagged collections and drops copy-on-read and copy-on-write:cache.get()returns the live structure, not a detached snapshot.StreamCachewire format changed to the transport's serializer and compressor instead of raw pickle; new pods cannot read older pods' entries, so drain or rotatestream_key.hmsetremoved. Usehset(key, mapping=...)orhset(key, items=...)(flat key-value list, matching redis-py/valkey-py).django_cachex.unfoldremoved: the django-unfold admin theme, the[unfold]extra andexamples/unfold/are gone. Usedjango_cachex.admin.- Lock parameters renamed, with no deprecation shim:
cache.lock(timeout=...)is nowcache.lock(lease=...)(TTL) andlock.acquire(blocking_timeout=...)is nowlock.acquire(timeout=...)(max wait).blocking_timeoutraisesTypeError; the constructor'stimeout=means max wait, not TTL. ZStdCompressorrenamed toZstdCompressor(django_cachex.compressors.zstd.ZstdCompressor). UpdateOPTIONS["compressor"]strings.LzmaCompressorconstructorpreset=renamed tolevel=, which every compressor now accepts.PickleSerializerno longer raisesImproperlyConfiguredforprotocol > pickle.HIGHEST_PROTOCOL; the firstdumpsraisesSerializerErrorinstead, with pickle'sValueErroras__cause__.CachexCompatremoved, along with the admin's "wrapped" support tier. Usedjango_cachex.cache.LocMemCache/DatabaseCache(drop-in replacements) for full admin support; non-cachex backends show as "limited" (configuration only).- Cluster
LOCATIONwith a non-zero database number raises (RedisClusterException/ValkeyClusterException) on the redis-py and valkey-py cluster backends. Cluster never honored it; drop it fromLOCATION.ValkeyGlideClusterCacheandRedisRsClusterCachestill ignore it.
Features¶
- Rust I/O driver (experimental) in the separate
django-cachex-redis-rspackage, via theredis-rsextra:RedisRsCache,RedisRsClusterCacheandRedisRsSentinelCache, which raiseImportErroron first use without the extra. valkey-glideadapter (experimental) via thevalkey-glideextra:ValkeyGlideCacheandValkeyGlideClusterCache. No Sentinel.WrongTypeErrorexception. LocMem, redis-py, valkey-py, valkey-glide and the Rust adapter raisedjango_cachex.WrongTypeError(aTypeErrorsubclass) forWRONGTYPE.- Async ext methods on LocMem and Database. The full async data-structure surface works instead of raising
NotSupportedError. StreamCachebackend. Local in-memory reads, with writes broadcast over a Redis Stream to every pod. Read-heavy, write-light, eventually consistent.TieredCachebackend. Composes twoCACHESentries as L1 (e.g. LocMem) and L2 (e.g. Redis), with TTL propagation and pull-through reads.- Cache-stampede prevention. TTL-based XFetch via
OPTIONS["stampede_prevention"](orstampede_prevention=per call). Configurable buffer/beta/delta. LocMemCacheandDatabaseCacheextensions: drop-in replacements for the Django builtins with data-structure ops, TTL helpers and admin support.LocMemCachecompound ops are serialized (#62).orjsonandormsgpackserializer extras.- Free-threaded CPython (3.14t) support. A cp314t wheel is built, and the Rust driver runs with the GIL disabled.
- PyPI wheels via cibuildwheel. Wheels for Linux x86_64, Linux aarch64, macOS arm64, and Windows amd64, on cp314 and cp314t.
- Async pool sharing. Per-task
Cacheinstances share one async connection pool (#83), avoiding the thundering-herd reconnect on cold start. - Pipeline parity. Stream ops, CAS ops, missing key ops (
persist,pttl,expireatand others), context manager,zpopmin/zpopmaxdefaultcount=1. - Compressors gain a uniform
level=parameter (gzip, lz4 and zstd join zlib and lzma), defaulting to each library's own default. - Serializer/compressor wrappers consolidated. Subclasses implement
_dumps/_loadsor_compress/_decompress; the base classes handleSerializerError/CompressorErrortranslation and int passthrough. - Weighted semaphores.
cache.semaphore(name, capacity, *, weight=1, lease=..., timeout=...)andcache.asemaphore(...)onLocMemCacheand the RESP backends, including cluster, with lease-based crash reclaim on RESP. Sync and async share state per cache instance.
Performance¶
LocMemCachesorted sets are O(log N), down from O(N log N) per write. Addssortedcontainers>=2.4as a runtime dependency.LocMemCacheskips pickle for tagged collections and mutates list/set/hash/zset/stream types in place.
Fixes¶
LocMemCache.lpush,sadd,hset,hincrby,zaddand similar methods no longer lose updates under concurrent threads (#62).delete_patternbatches deletes to bound peak memory on broad patterns.clear()is now prefix/version-scoped instead ofFLUSHDB. The old behavior is available asflush_db().- Compressor
compressanddecompressmethods catch all exceptions and re-raise asCompressorError. - Cluster correctness: script loading on replicas, set_many
timeout=0. - Reading values small enough to have skipped compression (at or below the compressor's
min_length) no longer crashes. - Admin cache/key changelists are compatible with Django 6.1.
- Semaphore waiters abandoned by crashed or cancelled callers are reaped instead of blocking the queue.
- valkey-glide: TLS (
rediss/valkeysoruse_tls/ssl), credentials from the URL orOPTIONS, the database index (standalone only),request_timeoutandclient_namereach the client, not only host and port.zaddforwardsgt/lt; pipelines support stream commands. TieredCache.setforwardsnx/xxto L2. A stock Django L2 no longer raisesTypeError:nxfalls back toadd(),xx/getraiseNotSupportedError, and a plain set drops the flags.set(..., timeout=0)deletes the key across all backends, matching Django's cache contract.LocMemCachealiases sharing aLOCATIONshare one store, including tagged collections and semaphore budgets.- Admin: backend capability probes fail gracefully, and key URLs are quoted so keys with special characters open correctly.
- CI runs the test matrix against Django 6.1 in addition to 6.0.
- Dependabot automerge waits for every workflow run on the PR head to succeed before merging.
reverse_key()handles aKEY_PREFIXcontaining colons, sokeys(),iter_keys(),scan()and the blocking list pops return user keys.DatabaseCachecompound ops (rpush,sadd,zadd,hset, ...) merge with a concurrent writer's row instead of overwriting it.LocMemCacheandDatabaseCachehincrby/hincrbyfloatreject non-numeric stored values with the same error as the server instead of truncating them.TieredCacherejectsKEY_PREFIXin the standard top-level slot as well as inOPTIONS; it was silently ignored before.- Sentinel: async connection pools are keyed by sentinel fleet, so aliases sharing a service name no longer share a pool.
- Semaphores: concurrent
acquire()on oneRespSemaphoreinstance can no longer double-claim and leak a slot until the lease expires. - Admin: editing a key keeps its TTL and persistence on every backend instead of resetting to the default timeout.
StreamCachebroadcasts in write order, so consumers converge on the writer's final value, andkeys()is scoped to the cache's prefix and version.- A pipeline reused after
execute()raises decodes the next batch correctly;AsyncPipelinerejects a syncwithbefore the block runs. - The redis-py and valkey-py cluster backends now honor the URL's TLS scheme, credentials and query parameters. Per-task async Sentinel adapters share one pool.
encode()passes through exactintvalues only, sointsubclasses (IntEnum,IntFlag) keep their type.touch()/atouch()apply the stampede buffer and accept a per-callstampede_prevention=; a touch pushed every reader into a recompute.DatabaseCachekey scans no longer match unrelated rows when aKEY_PREFIXor pattern contains%,_or a backslash.DatabaseCache.zadd/zincrbyreject a non-numeric score withValueErrorinstead of storing a value that breaks later range queries.MAX_ENTRIESculling covers the whole store:LocMemCachecounts and evicts tagged collections, andDatabaseCachecompound ops cull on insert likeset().LocMemCachecollection edge cases:keys()scopes to the requested version and skips expired entries,incr()on a collection raisesWrongTypeError, notKeyError, andsadd/hset/zaddadding nothing leave no empty key.rpop(count=0)onLocMemCacheandDatabaseCache, andzpopmax(count=0)onLocMemCache, return an empty list instead of draining the whole collection.
0.3.0 (February 2026)¶
expiretime()andset(get=True)support: New cache methods for retrieving absolute expiry timestamps and atomic get-and-set operations.- Atomic CAS operations in admin: Key detail edits use compare-and-swap via Lua-computed SHA1 fingerprints to prevent concurrent edit conflicts.
- Key detail pagination: Collection types (list, hash, set, zset, stream) are paginated at 100 items per page with
?page=Nnavigation. - Keys in admin sidebar: The key list is now a sidebar entry with a cache filter for switching between configured caches.
- Simplified Lua script execution:
eval_script()replaces theregister_script/LuaScriptregistry with directEVALcalls; redis-py handles script caching. - Async data structure methods: All hash, list, set, and sorted set operations now have async counterparts on
RespCache(e.g.ahset,alpush,asadd,azadd). - Stream operations: Full sync and async support for Redis streams (
xadd,xread,xrange,xlen,xdel,xtrim,xinfo_stream,xgroup_create,xreadgroup,xack,xpending,xclaim,xautoclaim, and more). - Safe
clear():clear()now usesdelete_pattern("*")to only remove keys for the current cache version and prefix, instead ofFLUSHDB. Useflush_db()for the old behavior. - Danger zone in admin: Cache detail view has a "Danger Zone" section with "Clear all versions" and "Flush database" actions. Key list view has a "Clear" button for safe prefix-scoped clearing.
hsetitems param:hset()now accepts anitemsparameter (flat key-value list), matching the redis-py/valkey-py signature.hmsetis removed.delete_patternbatched deletes: Deletes are now batched to prevent OOM on broad patterns.- Multi-key params standardized: Set operations such as
sdiff,sinterandsunionacceptKeyT | Sequence[KeyT]consistently.
0.2.0 (February 2026)¶
- Django permissions enforced: The admin now uses Django's built-in permission system for granular access control. Staff users need explicit permissions; superusers are unaffected.
0.1.0 (February 2026)¶
Initial stable release of django-cachex.
Features¶
- Valkey and Redis support in one package.
- Session backend support via Django's cache sessions.
- Pluggable clients: Default, Sentinel, Cluster.
- Pluggable serializers: Pickle, JSON, MsgPack.
- Pluggable compressors: Zlib, Gzip, LZMA, LZ4, Zstandard.
- Multi-serializer/compressor fallback for safe migrations.
- Connection pooling with configurable options.
- Primary/replica replication support.
- Valkey/Redis Sentinel support for high availability.
- Valkey/Redis Cluster support with automatic slot handling.
- Distributed locks compatible with
threading.Lock. - TTL operations:
ttl(),pttl(),expire(),persist(). - Pattern operations:
keys(),iter_keys(),delete_pattern(). - Pipelines for batched operations.
- Lua script interface with automatic key prefixing and value encoding/decoding.
- Django Cache Admin for cache inspection and management:
- Browse, search, edit, and delete cache keys.
- View server info, memory statistics, and slowlog.
- Key type filter sidebar.
- Support for Django builtin backends (LocMemCache, DatabaseCache, FileBasedCache) via wrappers.
- Django Unfold theme support (
django_cachex.unfold). - Async support for all extended methods.
Data Structure Operations¶
- Hash operations:
hset,hdel,hexists,hget,hgetall,hincrby,hincrbyfloat,hkeys,hlen,hmget,hmset,hsetnx,hvals - Sorted set operations:
zadd,zcard,zcount,zincrby,zrange,zrevrange,zrangebyscore,zrevrangebyscore,zrank,zrevrank,zrem,zremrangebyrank,zremrangebyscore,zscore,zmscore,zpopmin,zpopmax - List operations:
llen,lpush,rpush,lpop,rpop,lindex,lrange,lset,ltrim,lrem,lpos,linsert,lmove,blpop,brpop,blmove - Set operations:
sadd,srem,smembers,sismember,smismember,scard,spop,srandmember,smove,sdiff,sdiffstore,sinter,sinterstore,sunion,sunionstore,sscan,sscan_iter
Requirements¶
- Python 3.12+
- Django 5.2+
- valkey-py 6.1+ or redis-py 6+
Pre-release History¶
0.1.0b6 (February 2026)¶
New Features¶
- Key type filter: Filter keys by type (string, list, set, hash, zset, stream) in the admin key list sidebar
- LocMemCache data structure operations: List, set, and hash operations now work with LocMemCache wrappers
- LocMemCache type detection: Automatically detects stored Python types (list, set, dict) and maps them to Redis equivalents
KeyTypeStrEnum: Centralized enum for Redis key types, replacing scattered string literals
Improvements¶
- Admin refactoring: replaced service layer with helpers module, simplified views, restructured templates
- Unified admin views between classic Django admin and Unfold theme
- Added
_cachex_supportClassVar toCacheProtocolfor standardized support level detection - Mixin-based class patching for cache wrappers (replacing intermediate extension classes)
- Dead code cleanup across the codebase
Bug Fixes¶
- Unfold templates match the classic admin
key_typevariable usage in the unfold key detail template- mypy and ty type-checking errors
!rformat spec forKeyTin error messages
0.1.0b5 (February 2026)¶
New Features¶
- Expanded cache backend support: The admin interface now supports Django's builtin cache backends through wrapper classes
LocMemCache: Full support including key listing, TTL inspection, and memory statisticsDatabaseCache: Key listing, TTL inspection, and database statisticsFileBasedCache: File listing (as MD5 hashes) and disk usage statisticsMemcached: Basic stats when available- Django's
RedisCache: Basic support (full features require django-cachex backends)
Improvements¶
- Standardized
info()output format across all wrapped cache backends - Added TTL support (
ttl(),expire(),persist()) for LocMemCache - Cache admin UX: unsupported operations fail gracefully instead of hiding UI elements
Bug Fixes¶
- LocMemCache keys no longer show "not found" when clicked in admin
- The key search form preserves the cache query parameter
- Editing works for wrapped cache backends
0.1.0b4 (January 2026)¶
New Features¶
- Django Cache Admin: Built-in admin interface for cache management
- Browse all configured caches
- Search keys with wildcard patterns
- View and edit cache values (strings, hashes, lists, sets, sorted sets)
- Inspect TTL and modify expiration
- View server info and memory statistics
- Flush individual caches
-
Bulk delete keys
-
Django Unfold Theme Support: Alternative admin styling for django-unfold users
- Use
django_cachex.unfoldinstead ofdjango_cachex.admin -
Consistent styling with Unfold's modern admin theme
-
Example Projects: Added example projects demonstrating various configurations
examples/simple/- Basic setup with ValkeyCache and LocMemCacheexamples/full/- Multiple backends including Sentinel and Clusterexamples/unfold/- Django Unfold theme integration
0.1.0b3 (January 2026)¶
New Features¶
- Lua Script Interface: High-level API for registering and executing Lua scripts with automatic key prefixing and value encoding/decoding
cache.register_script()to register scripts with pre/post processing hookscache.eval_script()andcache.aeval_script()for sync/async executionpipe.eval_script()for pipeline support- Pre-built helpers:
keys_only_pre,full_encode_pre,decode_single_post,decode_list_post ScriptHelpersclass exposesmake_key,encode,decodefor custom hooks- Automatic SHA caching with NOSCRIPT fallback