Redis expiration answers when a key should stop being available, not how much memory the server may consume. A deadline belongs to the key, and later commands can preserve, replace, or remove it. Reliable TTL use therefore requires testing update paths as well as the initial insertion. A successful write alone says nothing about the deadline remaining afterward.

A deadline is not a sliding timer

EXPIRE accepts seconds; PEXPIRE accepts milliseconds. Redis stores expiration as an absolute Unix timestamp, so elapsed time does not pause while the server is stopped. Stable system clocks matter, especially when restoring data on another machine. This is not a monotonic-duration promise independent of wall-clock changes. The exercise never changes the system clock.

GET does not refresh expiration. In-place changes such as INCR preserve the key’s existing timeout, whereas an ordinary successful SET replaces the value and discards the old timeout. SET with KEEPTTL preserves it. Explicitly issuing another expiration command changes the deadline again. Applications wanting sliding sessions must deliberately implement that policy rather than assuming each read extends access.

Set the value and its initial lifetime together with SET EX or SET PX when both belong to one operation. Separate SET and EXPIRE calls leave a failure window in which the value exists without expiration. A client crash between them can create a persistent entry. This is a write-protocol defect, not a slow expiration cycle.

Inspect the actual state

TTL returns remaining seconds, with -1 for an existing key without expiration and -2 for a missing key. PTTL offers millisecond resolution. PEXPIRETIME reports the absolute deadline, which makes preserving a deadline easier to test than comparing two countdown values that legitimately decrease between commands. A TTL result is a snapshot, not a reservation that guarantees the key survives until then.

EXPIRE NX only adds a deadline when none exists. GT only accepts a later deadline; a persistent key is treated as having an infinite lifetime for that comparison. PERSIST explicitly removes expiration. Check these commands’ results rather than treating every accepted command as a successful state change. A non-positive relative expiry deletes the key immediately; it is not another spelling of persistence.

When it breaks

Passive expiration checks deadlines during access, while active expiration removes expired keys that are not accessed. Delayed physical cleanup is not permission for an ordinary primary GET to keep serving an expired value. Nor does removing an entry guarantee that its allocator pages immediately return to the operating system. Visibility, data-structure cleanup, and process RSS are different observations.

A TTL is not a hard memory budget. Arrival rate, value sizes, unexpired data, command overhead, and allocator behavior still determine consumption. Eviction can also remove a key before its deadline. Do not use cache presence as the only business authorization check, or infer replication and failover guarantees from a single-instance experiment. Define the relevant deadline and consistency boundary for those workflows separately.

Check it quickly

Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. These commands build Redis core using libc and install only the client into a temporary virtual environment. Nothing is installed system-wide; optional bundled modules are not built.

redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0

Run the Python block with "$redis_lab/venv/bin/python" in the same shell. It starts a disposable Redis over a private Unix socket beneath /tmp, with TCP, snapshot scheduling, and append-only persistence disabled. Requests cannot reach a default localhost Redis. The program terminates its own process and removes only its temporary runtime directory. Downloads require network access; the experiment does not.

from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time

import redis
from redis.backoff import NoBackoff
from redis.retry import Retry


@contextmanager
def instance(policy="noeviction"):
    with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
        endpoint = str(Path(directory) / "redis.sock")
        process = subprocess.Popen([
            os.environ["REDIS_SERVER"], "--port", "0",
            "--unixsocket", endpoint, "--unixsocketperm", "700",
            "--save", "", "--appendonly", "no", "--dir", directory,
            "--maxmemory-policy", policy,
        ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
        client = redis.Redis(unix_socket_path=endpoint, decode_responses=True,
                             socket_timeout=2, retry=Retry(NoBackoff(), 0))
        try:
            deadline = time.monotonic() + 5
            while True:
                assert process.poll() is None, "Redis exited during startup"
                try:
                    if client.ping():
                        break
                except redis.ConnectionError:
                    if time.monotonic() >= deadline:
                        raise
                    time.sleep(0.02)
            yield client
        finally:
            client.close()
            if process.poll() is None:
                process.terminate()
            try:
                _, errors = process.communicate(timeout=5)
            except subprocess.TimeoutExpired:
                process.kill()
                process.communicate()
                raise
            assert process.returncode == 0, errors.decode()


with instance() as client:
    assert client.ttl("missing") == -2
    client.set("session", "A", px=60000)
    deadline = client.pexpiretime("session")
    assert client.get("session") == "A"
    assert client.pexpiretime("session") == deadline
    client.set("session", "B", keepttl=True)
    assert client.pexpiretime("session") == deadline
    print("GET and SET KEEPTTL preserve the absolute deadline")

    client.set("session", "C")
    assert client.ttl("session") == -1
    assert client.expire("session", 60, nx=True)
    assert not client.expire("session", 120, nx=True)
    assert client.expire("session", 120, gt=True)
    assert not client.expire("session", 30, gt=True)
    assert client.persist("session")
    assert client.ttl("session") == -1
    print("plain SET clears TTL; NX, GT and PERSIST follow their conditions")

    client.set("counter", "1", px=60000)
    deadline = client.pexpiretime("counter")
    assert client.incr("counter") == 2
    assert client.pexpiretime("counter") == deadline
    assert client.expire("counter", 0)
    assert client.get("counter") is None
    print("INCR preserves TTL; EXPIRE zero deletes immediately")

    client.pexpire("session", 100)
    limit = time.monotonic() + 3
    while client.get("session") is not None:
        assert time.monotonic() < limit, "key did not expire"
        time.sleep(0.01)
    assert client.ttl("session") == -2
    print("after expiry: GET is None and TTL is -2")

The absolute deadline survives GET and SET KEEPTTL, but plain SET leaves TTL -1. The conditional expiry assertions distinguish successful updates from rejected ones, and PERSIST returns the key to the persistent state. INCR retains its original deadline; EXPIRE zero removes that separate counter immediately.

Finally, a 100-millisecond timeout leads to GET returning None and TTL -2. A bounded polling loop observes actual disappearance without demanding an exact scheduler latency. These results verify command semantics and ordinary expired reads, not memory reclamation timing, active-cycle throughput, replication behavior, failover, or secure deletion of previously copied data.

Further reading

Sources

  • Redis EXPIRE, deadline updates, conditional options, clocks, and active/passive expiration, checked 2026-09-08.
  • Redis SET, atomic value/lifetime assignment and KEEPTTL, checked 2026-09-08.
  • Redis TTL, remaining lifetime and the -1/-2 results, checked 2026-09-08.
  • Redis 8.10.1 build instructions, core-only compilation and allocator selection, checked 2026-09-08.