Skip to content

Advanced Usage

TTL and Expiry

from datetime import datetime, timedelta

from django.core.cache import cache

cache.set("foo", "bar", timeout=25)
cache.ttl("foo")  # 25
cache.pttl("foo")  # 25000
cache.ttl("missing")  # -2 (key doesn't exist)

cache.expire("foo", timeout=5)  # ttl() returns 5
cache.pexpire("foo", timeout=5500)  # pttl() returns 5500
cache.expireat("foo", datetime.now() + timedelta(hours=1))  # ttl() returns ~3600
cache.pexpireat("foo", datetime.now() + timedelta(milliseconds=900, hours=1))  # pttl() returns ~3600900
cache.persist("foo")  # ttl() returns None (no expiration)

ttl() returns the seconds until expiry, None for a key without expiry (set with timeout=None), and -2 for a missing or expired key.

Atomic Operations and Locks

cache.set("key", "value1", nx=True)  # True
cache.set("key", "value2", nx=True)  # False, the key keeps "value1"

cache.set("counter", 0)
cache.incr("counter")  # 1
cache.incr("counter", delta=5)  # 6
cache.decr("counter")  # 5

# lock() returns a distributed lock with the threading.Lock interface
with cache.lock("somekey"):
    do_some_thing()

Lock Interface lists the lock options.

Bulk Operations

# Get all matching keys (not recommended for large datasets)
cache.keys("foo_*")  # ["foo_1", "foo_2"]

# For large datasets, iterate with server-side cursors
for key in cache.iter_keys("foo_*"):
    print(key)

cache.delete_pattern("foo_*")
# When many keys match, a larger itersize needs fewer round trips
cache.delete_pattern("foo_*", itersize=100_000)

The pattern is a case-sensitive Redis glob on every backend. "*" matches every key under the cache's prefix and version. See Key patterns.

Data Structures

Hashes

from django.core.cache import cache

# Set a single field
cache.hset("user:1", "name", "Alice")

# Set multiple fields at once
cache.hset("user:1", mapping={"email": "alice@example.com", "age": 30})

# Get a single field
name = cache.hget("user:1", "name")  # "Alice"

# Get multiple fields
values = cache.hmget("user:1", "name", "email")  # ["Alice", "alice@example.com"]

# Get all fields and values
user = cache.hgetall("user:1")  # {"name": "Alice", "email": "...", "age": 30}

# Increment a numeric field
cache.hincrby("user:1", "age", 1)  # 31
cache.hincrbyfloat("user:1", "score", 0.5)  # For floating point

# Check if field exists
cache.hexists("user:1", "name")  # True

# Delete fields
cache.hdel("user:1", "age")

# Get count of fields
cache.hlen("user:1")  # 3

# Get all values
cache.hvals("user:1")  # ["Alice", "alice@example.com", 0.5]

Hash Field Expiration

Hash fields can have their own TTL on Redis 7.4+ or Valkey 9.0+. hsetex and hgetex need Redis 8.0+ or Valkey 9.0+. An older server raises NotSupportedError.

from datetime import datetime, timedelta

cache.hset("session:42", mapping={"token": "abc", "csrf": "xyz", "theme": "dark"})

# Expire fields; one reply code per field
cache.hexpire("session:42", 300, "token", "csrf")  # [1, 1]
cache.httl("session:42", "token", "theme", "nope")  # [300, None, -2]

# Only lengthen an existing TTL (nx/xx/gt/lt mirror EXPIRE's options)
cache.hexpire("session:42", timedelta(hours=1), "token", gt=True)  # [1]

# Absolute deadlines and millisecond precision
cache.hexpireat("session:42", datetime.now() + timedelta(days=1), "csrf")
cache.hpexpire("session:42", 1500, "theme")
cache.hpttl("session:42", "theme")  # [1500]

# Set fields and their TTL in one round trip; fnx/fxx guard the write
cache.hsetex("session:42", "token", "def", timeout=300)  # True
cache.hsetex("session:42", mapping={"a": 1, "b": 2}, timeout=60, fnx=True)  # False if any exist
cache.hsetex("session:42", "token", "ghi", keepttl=True)  # rewrite, keep its TTL

# Read fields and refresh (or drop) their TTL in one round trip
cache.hgetex("session:42", "token", timeout=600)  # ["ghi"]
cache.hgetex("session:42", "token", persist=True)  # ["ghi"], TTL removed

cache.hpersist("session:42", "csrf")  # [1]

Rewriting a field with hset, or with hsetex without keepttl=True, clears that field's TTL. hsetex() treats timeout the way set() does. The default uses the backend's TIMEOUT, None means no expiry, and timeout=0 deletes the fields immediately.

Sorted Sets

from django.core.cache import cache

# Add members with scores
cache.zadd("leaderboard", {"alice": 100, "bob": 85, "charlie": 92})

# Get rank (0-indexed, ascending by score)
cache.zrank("leaderboard", "alice")  # 2 (highest score = last)
cache.zrevrank("leaderboard", "alice")  # 0 (highest score = first)

# Get score
cache.zscore("leaderboard", "bob")  # 85.0

# Get multiple scores
cache.zmscore("leaderboard", "alice", "bob")  # [100.0, 85.0]

# Increment score
cache.zincrby("leaderboard", 10, "bob")  # 95.0

# Get range by rank (ascending)
cache.zrange("leaderboard", 0, -1)  # All members sorted by score

# Get range by rank with scores
cache.zrange("leaderboard", 0, -1, withscores=True)

# Get range by score
cache.zrangebyscore("leaderboard", 80, 100)

# Count members in score range
cache.zcount("leaderboard", 80, 100)  # 3

# Remove members
cache.zrem("leaderboard", "charlie")

# Remove by rank range
cache.zremrangebyrank("leaderboard", 0, 1)  # Remove lowest 2

# Get total count
cache.zcard("leaderboard")

Lists

from django.core.cache import cache

# Push elements
cache.lpush("queue", "first")  # Prepend (left)
cache.rpush("queue", "last")  # Append (right)

# Pop elements
cache.lpop("queue")  # Remove and return first
cache.rpop("queue")  # Remove and return last

# Get element by index
cache.lindex("queue", 0)  # First element

# Get range of elements
cache.lrange("queue", 0, -1)  # All elements

# Set element at index
cache.lset("queue", 0, "new_first")

# Trim to range
cache.ltrim("queue", 0, 99)  # Keep first 100 elements

# Get length
cache.llen("queue")

# Find element position
cache.lpos("queue", "target")  # Returns index or None

# Move element between lists atomically
cache.lmove("source", "dest", "LEFT", "RIGHT")  # LPOP source, RPUSH dest

Raw Client Access

get_client() returns the underlying client. Its type depends on the backend, as Raw Client Access lists.

client = cache.get_client()
client.publish("channel", "message")

Lua Scripts

eval_script() runs a Lua script and sends its keys and args as given. A pre_hook transforms the keys and args before the script runs, and a post_hook transforms the result. keys_only_pre, for example, adds the cache's key prefix and version. With post_hook=None, the default, the result comes back unchanged. aeval_script() is the async twin. Lua Script Methods covers EVALSHA and the hook signatures.

from django.core.cache import cache
from django_cachex import (
    Encoded,  # Marks an ARGV entry for encoded_pre
    encoded_pre,  # Prefix keys, encode only the args wrapped in Encoded(...)
    keys_only_pre,  # Prefix keys, leave args unchanged
    full_encode_pre,  # Prefix keys AND encode args (serialize values)
    decode_single_post,  # Decode a single returned value
    decode_list_post,  # Decode a list of returned values
)

# keys_only_pre applies the cache's key prefix and version
count = cache.eval_script(
    """
    local current = redis.call('INCR', KEYS[1])
    if current == 1 then
        redis.call('EXPIRE', KEYS[1], ARGV[1])
    end
    return current
    """,
    keys=["user:123:requests"],
    args=[60],
    pre_hook=keys_only_pre,
)

# full_encode_pre serializes every arg, decode_single_post decodes the result
old_session = cache.eval_script(
    """
    local old = redis.call('GET', KEYS[1])
    redis.call('SET', KEYS[1], ARGV[1])
    return old
    """,
    keys=["session:abc"],
    args=[{"user_id": 123, "permissions": ["read", "write"]}],
    pre_hook=full_encode_pre,
    post_hook=decode_single_post,
)

Mixed Values and Scalars

Values that a later get() reads back must go through the serializer and compressor. Scalars that Lua consumes with tonumber or a string compare must not. Wrap the values in Encoded, at any ARGV position, and use encoded_pre:

from django_cachex import Encoded, encoded_pre

SETEXPIRE = """
local value = ARGV[1]
local ex    = tonumber(ARGV[2])
local nx    = ARGV[3] == "1"
if nx and redis.call('EXISTS', KEYS[1]) == 1 then
    return 0
end
redis.call('SET', KEYS[1], value, 'EX', ex)
return 1
"""

cache.eval_script(
    SETEXPIRE,
    keys=["session:abc"],
    args=[Encoded({"user_id": 123}), 300, "1"],
    pre_hook=encoded_pre,
)

Custom Processing Hooks

Custom hooks receive a ScriptHelpers instance:

from django_cachex import ScriptHelpers


def my_pre(helpers: ScriptHelpers, keys, args):
    # First arg is a secondary key, rest are values
    processed_args = [helpers.make_key(args[0], helpers.version)]
    processed_args.extend(helpers.encode_values(args[1:]))
    return helpers.make_keys(keys), processed_args


def my_post(helpers: ScriptHelpers, result):
    # Result is [count, list_of_values]
    return {
        "count": result[0],
        "values": helpers.decode_values(result[1]) if result[1] else [],
    }


result = cache.eval_script(
    "...",
    keys=["primary"],
    args=["secondary", value_a, value_b],
    pre_hook=my_pre,
    post_hook=my_post,
)

Pipeline Support

from django_cachex import keys_only_pre

pipe = cache.pipeline()
pipe.set("key1", "value1")
pipe.eval_script(
    "return redis.call('INCR', KEYS[1])",
    keys=["user:1"],
    pre_hook=keys_only_pre,
)
results = pipe.execute()  # [True, 1]