What maxmemory and maxmemory-policy actually control
maxmemory sets a limit on how much memory Redis will use for its dataset. maxmemory-policy tells Redis what to do when it reaches that limit. When maxmemory is set, Redis checks memory usage as it processes commands. If usage is over the limit, it either evicts keys according to the policy until it is back under, or, if the policy is noeviction or no eligible keys exist, rejects commands that could allocate more memory with an OOM error. Reads and deletes still succeed.
What the limit measures
The comparison is against Redis's own accounting of allocated memory (used_memory), minus some buffers such as replication output. It is not a cap on the process's resident set size. RSS also includes:
- allocator fragmentation
- client output buffers
- copy-on-write pages that pile up while a forked child writes an RDB snapshot or rewrites the AOF
A node with maxmemory set to 75% of available RAM can still be killed by the kernel. Section 4 covers how to size for that gap.
Why the policy matters
Choosing a policy means choosing which data Redis may delete without telling you:
- Cache: losing a cold key costs a recomputation.
- Lock store, queue or session store: losing a key means a duplicate job, a lost message or a logged-out user.
If your Redis holds anything other than pure, rebuildable cache data, the eviction policy is part of your correctness model, not a tuning knob. Leaving maxmemory unset on a 64-bit build means no limit at all. You are then relying on the OS to choose what fails.
client write (SET, LPUSH, ...)
|
v
+--------------------------------+
| Redis, on each command: |
| used_memory > maxmemory ? |
+--------------------------------+
| no | yes
v v
+-------------+ +-----------------------------+
| run command | | maxmemory-policy |
+-------------+ +-----------------------------+
| evicting policy | noeviction, or
| + eligible keys | no eligible keys
v v
+------------------+ +--------------------------+
| evict keys until | | reject write: OOM error |
| back under limit | | reads and DELs still run |
+------------------+ +--------------------------+
|
v
+--------------------------------------------------+
| cache: recompute | lock/queue/session: data lost |
+--------------------------------------------------+
Process RSS seen by kernel (maxmemory only limits used_memory):
+---------------+---------------+---------------+---------------------+
| used_memory | allocator | client output | fork COW pages |
| (vs maxmemory)| fragmentation | buffers | (RDB / AOF rewrite) |
+---------------+---------------+---------------+---------------------+
Redis compares used_memory to maxmemory as it processes commands and either evicts keys per maxmemory-policy or rejects writes with an OOM error, while process RSS also includes fragmentation, client output buffers and fork copy-on-write pages.
The policy families and what each assumes about your data
Each policy encodes an assumption about which keys you can afford to lose. Under memory pressure Redis evicts until it is back under the limit. If nothing is eligible, it rejects writes that would grow memory and returns an OOM error.
| Policy | Eligible keys | Victim chosen by | Assumes |
|---|---|---|---|
noeviction |
None | — | Every key matters; failing writes is better than losing data |
allkeys-lru |
All | Least recently accessed | Recency predicts future reads |
allkeys-lfu |
All | Least frequently accessed | Popularity is stable over time |
allkeys-random |
All | Random | Access is roughly uniform |
volatile-lru / -lfu / -random |
Keys with a TTL | As above | Only keys with a TTL are disposable |
volatile-ttl |
Keys with a TTL | Shortest remaining TTL | Keys closest to expiry are worth least |
The volatile-* policies only consider keys that carry an expiry. If your application never sets TTLs, no key is ever eligible, and the instance behaves exactly like noeviction: writes fail once the limit is reached.
LRU and LFU are approximations. Redis does not keep a global ordering of keys. It samples a handful of candidates and evicts the best one among them. maxmemory-samples sets the sample size. Larger values track true LRU/LFU more closely but cost more CPU on every eviction.
The two policies fail in different ways:
- LRU and scans: a one-off scan, such as a batch job reading every key, marks cold keys as recently used and pushes out the genuinely hot ones.
- LFU and shifting hot sets: LFU resists scans, but its access counters decay slowly (governed by
lfu-decay-time). When the hot set shifts, yesterday's popular keys can hold memory longer than they deserve.
When to use each policy and when to avoid it
Start from what the data is, not from which algorithm sounds best. A pure cache, where every key can be rebuilt from a database or upstream service, belongs on allkeys-lru or allkeys-lfu. Pick LFU when a stable hot set competes with scans and one-off reads. Queues, locks, sessions and any source of truth belong on noeviction. Losing a lock or a queued job is a correctness bug, while a rejected write is visible and retryable. Use volatile-* only when cache keys and persistent keys must share one instance. Under these policies, a TTL marks a key as disposable.
| Policy | Use when | Avoid when |
|---|---|---|
allkeys-lru / allkeys-lfu |
Every key is rebuildable | Any key is the only copy. Eviction is silent, and evicting hot keys can trigger a cache stampede on the backing store |
noeviction |
Losing data is worse than failing writes | Clients don't handle OOM errors. Writes fail at the limit while reads continue, which can hide the problem |
volatile-* |
Mixed cache and persistent keys on one instance | TTLs are applied inconsistently. With no expiring keys to evict, writes fail as they do under noeviction, and a cache key without a TTL never leaves |
The main alternative to a mixed workload is separate instances: an allkeys-lfu cache and a noeviction store. This doubles the deployments, monitoring and failover paths you operate, and memory can't be shared between the two. In return, each instance has one policy you can reason about, and a runaway cache can't evict or block your locks. For most teams this is the safer default, and the extra operational cost is worth it.
Sizing maxmemory with explicit headroom
maxmemory is compared against the memory Redis believes it has allocated. The kernel and the container runtime track resident memory (RSS), and several things open a gap between the two:
- Fragmentation. The allocator holds pages that are only partly used, so RSS can stay well above
used_memoryafter churn. - Copy-on-write during fork. An RDB snapshot or AOF rewrite forks the process. Every page the parent modifies while the child runs gets duplicated. A write-heavy workload can duplicate a large share of the dataset.
- Replica output buffers and the replication backlog. A slow or resyncing replica can hold a lot of unsent data.
- Client output and query buffers. Large replies, pub/sub subscribers and pipelined writes spike at the same moments the rest of the system is under load.
The illustrative estimate below assumes an 8 GiB container limit, a fragmentation ratio of 1.15 and copy-on-write touching 25% of the data during a fork. These are assumptions for the example, not measurements.
| Category | Assumed reservation | Scales with |
|---|---|---|
| Fragmentation | 0.15 × maxmemory | Dataset |
| Copy-on-write | 0.25 × maxmemory | Dataset, write rate |
| Replication buffers and backlog | 512 MiB | Replica count, lag |
| Client buffers | 256 MiB | Connection behavior |
With these numbers, 1.4 × maxmemory + 0.75 GiB must fit in 8 GiB. That gives a ceiling of about 5.18 GiB, so you would set 5 GiB, roughly 62% of the limit.
That much headroom costs memory you pay for and don't fill. Setting maxmemory too high costs you a kernel OOM kill in the middle of a snapshot. No universal percentage exists because three of the four categories depend on your write rate, persistence mode and client behavior. Measure those for your workload instead of copying another team's ratio.
# redis.conf fragment for the illustrative 8 GiB container in this section.
# Redis parses 'gb'/'mb' as binary units (1gb = 1024^3 bytes), matching GiB/MiB.
# Dataset ceiling: 1.4 x 5 GiB + 0.75 GiB = 7.75 GiB, below the 8 GiB limit.
# Fragmentation (0.15x) and copy-on-write (0.25x) are covered only by
# leaving this headroom. No directive caps them.
maxmemory 5gb
maxmemory-policy allkeys-lru
# Replication reservation (512 MiB total in the estimate):
# 256 MiB for the backlog, plus up to 256 MiB of output buffer for one replica.
# Add another 256 MiB to the reservation for each additional replica.
repl-backlog-size 256mb
# Disconnect a replica whose buffer hits 256mb, or stays above 64mb for 60s.
client-output-buffer-limit replica 256mb 64mb 60
# Client reservation (256 MiB in the estimate).
# This limit applies to each pub/sub client separately, so many slow
# subscribers can still go past the reservation. Size it against your
# subscriber count.
client-output-buffer-limit pubsub 32mb 8mb 60
# Redis 7.0+: cap the combined memory of all normal client connections.
maxmemory-clients 256mb
# Check the assumptions against production, for example with
# redis-cli INFO memory and redis-cli INFO persistence:
# mem_fragmentation_ratio -> replaces the assumed 1.15
# rdb_last_cow_size / aof_last_cow_size -> replaces the assumed 25% copy-on-write
# mem_clients_normal / mem_clients_slaves -> actual buffer usage
A redis.conf fragment for the illustrative 8 GiB estimate: it sets maxmemory to 5gb, caps replication and client buffers to match the reserved amounts, and lists the INFO fields that show whether the fragmentation and copy-on-write assumptions hold for your workload.
Two kinds of redis OOM and how to keep the second from happening
Redis refusing writes vs. the process being killed
The first kind of OOM comes from Redis itself. When used_memory reaches maxmemory and the policy cannot free space, either because the policy is noeviction or because a volatile-* policy finds no keys with a TTL, Redis rejects commands that could grow the dataset. Clients receive an error beginning with OOM command not allowed. Reads, deletes and other commands that do not add memory still work. The server keeps running, the existing data stays intact, and the failure is visible to the application at the exact point where it happened.
The second kind comes from outside Redis. The kernel OOM killer or the container runtime's memory limit terminates the process with SIGKILL. Redis gets no chance to flush anything. Any writes since the last RDB snapshot or AOF fsync are lost. The restart is cold: Redis has to load the dataset from disk before it can serve traffic, and that load gets slower as the dataset grows.
maxmemory is what converts the second kind into the first. If it is unset (on 64-bit builds the default of 0 means no limit), or if it is set close to the container limit without the headroom from the previous section, Redis never reaches its own threshold. The operating system enforces a limit first, and it does so with SIGKILL.
Handling OOM errors in application code
Match on the OOM error prefix and handle it separately from connection errors. Do not retry immediately in a loop. Every retry is another write against a full instance, and a burst of retries adds client load without freeing any memory. Choose the response according to what the write was for:
- Cache writes: skip the write and serve from the source of truth.
- Queue or session writes: apply backpressure to callers, or fail the request with a clear error.
In all cases, emit a metric each time an OOM error occurs. A sustained OOM rate means the instance is undersized or the chosen policy does not fit the workload.
import redis
r = redis.Redis(host="redis.internal", port=6379, socket_timeout=0.5)
class Backpressure(Exception):
"""Raised to tell callers to slow down; maps to e.g. HTTP 503 + Retry-After."""
def is_oom(err: redis.exceptions.ResponseError) -> bool:
# Redis reports maxmemory refusals as an error starting with "OOM".
return str(err).startswith("OOM")
def cache_set(key: str, value: bytes, ttl_s: int = 300) -> None:
try:
r.set(key, value, ex=ttl_s)
except redis.exceptions.ResponseError as err:
if is_oom(err):
# Cache is best-effort: skip the write, callers read from the
# source of truth. No retry, since retrying adds load and frees nothing.
return
raise
except (redis.exceptions.ConnectionError, redis.exceptions.TimeoutError):
# A different failure (server unreachable, possibly killed).
# Handle it separately from OOM.
return
def enqueue_job(queue: str, payload: bytes) -> None:
try:
r.lpush(queue, payload)
except redis.exceptions.ResponseError as err:
if is_oom(err):
# The job must not be dropped silently. Push the failure back
# to the caller instead of looping on a full instance.
raise Backpressure("queue store is full; retry later") from err
raise
A redis-py client matches the OOM error prefix separately from connection errors. It drops best-effort cache writes and turns refused queue writes into backpressure for callers, without retrying in a loop.
Implementing, changing and observing settings in production
Set maxmemory and maxmemory-policy in redis.conf so a restart comes up with the intended limits. Both can also be changed at runtime with CONFIG SET, and the change takes effect immediately. A runtime change is lost on restart unless you persist it, either with CONFIG REWRITE (which only works if the server was started with a config file) or by updating the value in your config management. Choose one of these as the source of truth. Otherwise the next deploy will silently revert a hotfix.
Be careful when lowering maxmemory on a full instance. If the new limit is below used_memory, Redis starts evicting right away, and it does that work inside the command path. You will see a latency spike and a sudden loss of keys. Under noeviction, writes start failing instead. Lower the limit in small steps and watch latency after each one.
Watch these INFO memory and INFO stats fields:
used_memoryandused_memory_rss: memory as the allocator counts it, compared with what the OS has actually given the process.mem_fragmentation_ratio: RSS divided by used memory.maxmemory_policy: confirms the setting is actually in effect.evicted_keys: a cumulative counter, so alert on its rate rather than its absolute value.
Also alert on the RSS-to-limit ratio, because the OS kills the process based on RSS, not on used_memory. Track the hit rate (keyspace_hits versus keyspace_misses) alongside evictions. Eviction keeps memory flat, so a steadily falling hit rate may be the only sign that the instance is undersized.
Replicas do not evict on their own by default. They apply the deletions the master propagates. In cluster mode, each node enforces its own limit. If slots or hot keys are unevenly distributed, one node can be evicting heavily while the others have plenty of room, so monitor each node separately.
#!/usr/bin/env bash
# Change eviction settings at runtime, persist them, then check memory health.
set -euo pipefail
R="redis-cli -h redis-cache-01.internal -p 6379" # placeholder host
NEW_LIMIT=$((3 * 1024 * 1024 * 1024)) # 3 GiB target
# Read one field from INFO (strip the \r that INFO puts at line ends).
field() { $R INFO "$1" | tr -d '\r' | awk -F: -v k="$2" '$1==k {print $2}'; }
# Refuse to drop the limit below current usage in one step: that would
# trigger mass eviction inside the command path and spike latency.
used=$(field memory used_memory)
if (( NEW_LIMIT < used )); then
echo "used_memory=$used exceeds target $NEW_LIMIT; lower in smaller steps" >&2
exit 1
fi
$R CONFIG SET maxmemory "$NEW_LIMIT"
$R CONFIG SET maxmemory-policy allkeys-lru
# Persist to redis.conf (only works if Redis was started with a config file).
# Skip this if config management owns redis.conf; update that instead.
$R CONFIG REWRITE
# Health snapshot: compare RSS to the limit, since the OS acts on RSS.
rss=$(field memory used_memory_rss)
echo "maxmemory_policy=$(field memory maxmemory_policy)"
echo "mem_fragmentation_ratio=$(field memory mem_fragmentation_ratio)"
echo "rss_to_limit=$(awk -v r="$rss" -v m="$NEW_LIMIT" 'BEGIN {printf "%.2f", r/m}')"
# evicted_keys is cumulative: sample twice and alert on the difference.
e1=$(field stats evicted_keys); sleep 60; e2=$(field stats evicted_keys)
hits=$(field stats keyspace_hits); miss=$(field stats keyspace_misses)
echo "evictions_per_min=$((e2 - e1))"
echo "hit_rate=$(awk -v h="$hits" -v m="$miss" 'BEGIN {if (h+m>0) printf "%.3f", h/(h+m); else print "n/a"}')"
A redis-cli script that guards against lowering maxmemory below current usage, applies and persists the limit and policy with CONFIG SET and CONFIG REWRITE, and reports the RSS-to-limit ratio, eviction rate and hit rate from INFO.
Testing eviction behavior before production and the impact on engineering time
A staging test worth running
Use a staging instance that matches production's maxmemory and policy. Load it with a realistic mix of keys, values and TTLs, ideally sampled from production key patterns. Then keep writing until it passes the limit. Measure three things while it is over the limit:
- Which keys were evicted. Compare key counts per prefix before and after, and watch
evicted_keysinINFO stats. If session or queue keys disappear when you expected only cache keys to go, the policy does not fit the data. - Latency during eviction. Run
redis-cli --latencyalongside the load. Eviction happens on the write path, so heavy write bursts can show up as tail latency on commands that look unrelated. - Client-visible errors. Under
noeviction, writes fail with an OOM error. Confirm that your client library surfaces that error and that your retry logic does not make the load worse.
Why this saves engineering time
Choosing a policy per dataset, measuring headroom and alerting on a rising eviction rate turn a vague incident like "the cache is acting weird" into a specific alert. Splitting instances costs more infrastructure. Sharing one instance couples failures: a cache spike can evict durable state.
Illustrative estimate (assumed numbers, not a benchmark):
Break-even = (extra instance cost per month × 12) ÷ (hours per coupled incident × engineer hourly cost)
With an assumed $150/month instance, 6 engineer-hours per incident and $100/hour:
(150 × 12) ÷ (6 × 100) = 1,800 ÷ 600 = 3 incidents per year
Under these assumptions, splitting the instance pays for itself if it prevents three coupled incidents a year.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to redis eviction policy, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.