A Redis process can keep a substantial resident footprint after application keys are deleted. That observation deserves explanation, but it does not by itself prove a leak or allocator fragmentation. The useful question is which bytes remain allocated, which pages remain resident, and whether that footprint threatens the actual capacity budget.
Separate the measurements
INFO memory exposes used_memory for Redis-accounted allocations and used_memory_rss for the resident process footprint observed by the operating system. Neither is simply the sum of application payload lengths. Keys, objects, metadata, clients, and other overhead consume memory too. MEMORY USAGE provides per-key allocation estimates, including overhead; it does not measure that key’s independent contribution to process RSS.
Mem_fragmentation_ratio broadly compares resident memory with Redis-accounted allocations. Code, shared libraries, stacks, and allocator retention also contribute to the difference. The ratio can rise when the denominator falls after deletion, even if RSS barely changes. Inspect absolute bytes alongside ratios: a small empty instance can have a high ratio without consuming a dangerous amount of RAM.
Used_memory_peak helps explain a footprint left after an earlier workload peak. Used_memory_dataset is another accounting view, not a promise that an empty keyspace leaves zero process allocations. Measurements are not all sampled at exactly the instant INFO runs. Do not require an exact equality between a rounded ratio and independently observed values during active writes.
Check the allocator and version
Allocator-specific metrics can help distinguish fragmentation from retained pages and non-heap overhead, but their meaning depends on the build. Redis 8.10.1’s source computes allocator fragmentation from small-bin counters with a Lua adjustment; blindly reconstructing every version’s metric as active bytes divided by allocated bytes is unsafe. Check the versioned implementation when a general documentation formula disagrees.
This exercise deliberately builds with libc. That build lacks jemalloc’s detailed accounting; Redis substitutes some allocator fields from other measurements, and the fragmentation counter remains zero. An allocator_frag_ratio of one is therefore not proof that this process has no fragmentation. Always record mem_allocator before interpreting the more specialized fields or choosing an intervention.
When it breaks
A rising RSS curve alongside a growing dataset calls for workload and capacity analysis, not an automatic fragmentation diagnosis. A falling dataset with stable RSS suggests retained footprint, but still does not identify every retained page. Likewise, one snapshot cannot rule out a leak. Compare the same workload over time, value-size distribution, peak usage, and relevant process limits.
MEMORY PURGE attempts allocator reclamation where implemented. Redis documents it as a benign no-op for allocators other than jemalloc, so a successful reply is not evidence of recovered memory. It also does not remove application keys. Active defragmentation is a different mechanism and requires supported builds; enabling it can add CPU and latency costs that need measurement.
Restarting merely to lower a ratio can introduce downtime, cold-cache load, or data loss when persistence and recovery are inadequate. Establish a measured capacity problem and a rehearsed recovery path before treating restart as remediation. Maxmemory is not an RSS ceiling, and reducing key count does not guarantee immediate page return to the operating system.
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 using libc, and redis-py 8.1.0. The commands build Redis core and install the client in a temporary virtual environment. Downloads require the network; nothing is installed system-wide and 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". It creates a dedicated Redis process and private Unix socket under /tmp, disables TCP and persistence, and writes only its own 320 sample keys. Alternating values contain 16 or 64 KiB. Short pauses permit metric sampling, without assuming exact refresh timing. The process and temporary runtime directory are removed afterward.
from contextlib import contextmanager
import json
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:
def snapshot(stage):
time.sleep(0.2)
info = client.info("memory")
fields = ["used_memory", "used_memory_dataset", "used_memory_rss",
"used_memory_peak", "mem_fragmentation_ratio", "mem_allocator",
"allocator_frag_ratio", "allocator_frag_bytes"]
print(stage, json.dumps({"keys": client.dbsize(),
**{name: info[name] for name in fields}}, sort_keys=True))
return info
empty = snapshot("empty")
for index in range(320):
client.set(f"sample:{index}", "x" * (16384 if index % 2 == 0 else 65536))
assert client.dbsize() == 320
assert client.memory_usage("sample:0") >= 16384
loaded = snapshot("loaded")
assert loaded["used_memory"] > empty["used_memory"] + 10 * 1024 * 1024
for index in range(1, 320, 2):
assert client.delete(f"sample:{index}") == 1
assert client.dbsize() == 160
half = snapshot("large values deleted")
assert half["used_memory"] < loaded["used_memory"] - 8 * 1024 * 1024
for index in range(0, 320, 2):
assert client.delete(f"sample:{index}") == 1
assert client.dbsize() == 0
assert client.memory_usage("sample:0") is None
cleared = snapshot("all values deleted")
assert cleared["used_memory"] < half["used_memory"]
assert cleared["used_memory_peak"] >= loaded["used_memory"]
assert client.memory_purge()
snapshot("after MEMORY PURGE")
print("PASS: logical allocations fell; no RSS or purge-effect threshold is assumed")
One run reported about 16.7 MiB of used_memory after loading. Deleting all keys reduced that to about 1.04 MiB, while RSS remained around 20.8 MiB. The overall ratio consequently rose to roughly twenty. MEMORY USAGE confirmed the named key no longer existed; logical allocations fell, but resident memory did not track them proportionally.
The allocator-specific fragmentation ratio stayed at one throughout this libc run, and MEMORY PURGE returned success without a meaningful RSS drop. Counts and logical-allocation relationships are asserted; exact RSS values are intentionally observations, not pass thresholds. This experiment demonstrates accounting differences and the no-op limitation, not jemalloc defragmentation effectiveness, Linux memory-pressure behavior, or a leak diagnosis.
Further reading
Sources
- Redis INFO, process, allocation, peak, and allocator memory fields, checked 2026-09-08.
- Redis MEMORY USAGE, per-key allocation estimates and overhead, checked 2026-09-08.
- Redis MEMORY PURGE, jemalloc support and other allocators’ no-op behavior, checked 2026-09-08.
- Redis 8.10.1 object accounting, version-specific fragmentation calculation, checked 2026-09-08.
- Redis 8.10.1 memory sampling, cached measurements and substituted allocator fields, checked 2026-09-08.
- Redis 8.10.1 build instructions, core build and allocator selection, checked 2026-09-08.