Redis Cluster Slot Allocation: Automatic vs Explicit Assignment

This page covers how Redis Cluster maps every key to one of 16,384 hash slots and the two ways to decide which primary owns each slot — letting redis-cli distribute them evenly for you, or assigning exact slot ranges yourself for deterministic, reproducible topologies.

Slot allocation is the foundation every other scaling operation stands on. Get the mapping right and routing is a single hop; get it wrong and clients chase MOVED redirects, one overloaded primary saturates while others idle, or a gap in coverage takes writes offline. The decision is not whether to shard — the 16,384-slot model is fixed — but who computes the assignment: the tooling, or you. That choice propagates directly into how you run zero-downtime slot migration and automated node provisioning and removal later.

How keys map to the 16,384 hash slots

Redis Cluster partitions the keyspace into exactly 16,384 hash slots, deliberately avoiding the ring-walking logic of consistent hashing in favour of a deterministic modulo: CRC16(key) % 16384. Every primary owns a subset of those slots, and the authoritative mapping is persisted per node in the file named by cluster-config-file (conventionally nodes.conf). When a client issues a command, it computes the slot locally, looks up the owning primary in its cached topology map, and connects directly — no proxy, no coordinator.

Multi-key operations only work when every key resolves to the same slot, which is where hash tags matter: {user:1001}:profile and {user:1001}:sessions both hash on the substring inside the braces, so they co-locate on one slot and one node. This co-location discipline is exactly the schema design covered in key tagging strategies for bulk updates, and it is the difference between an atomic multi-key write and a CROSSSLOT error. The broader picture of how primaries, replicas, and slots fit together is laid out in understanding Redis cache topology.

How a key resolves to its owning primary in Redis Cluster Left to right: a client command GET on key {user:1001}:profile, where the hash tag in braces pins the slot. CRC16 hashes the key, the result is taken modulo 16384 to produce slot 8342 on the 0 to 16383 range, that slot is looked up in the client's cached slot-to-node map (which lands in the 5461 to 10922 range owned by primary B), and the client connects directly in one hop to primary B at 10.0.1.11. A dashed crimson feedback edge runs from the primary back into the cached map, labelled MOVED updates the map when it is stale. client command GET {user:1001}:profile hash tag pins the slot CRC16(key) deterministic hash Slot 8342 0 16383 cached slot → node map 0–5460 → A 5461–10922 → B 10923–16383 → C Primary B 10.0.1.11 hash % 16384 look up 1 hop MOVED updates the map when it is stale

Two ways to assign slots, at a glance

Both approaches produce a working cluster where all 16,384 slots are covered; they differ in who owns correctness and how reproducible the result is. Automatic distribution optimises for getting a Redis cluster running fast; explicit assignment optimises for determinism, weighted placement, and infrastructure-as-code idempotency.

Dimension Automatic even distribution Explicit deterministic assignment
Determinism Placement varies run-to-run; node-to-slot mapping is opaque Exact, reproducible ranges — the same inputs always yield the same map
Rebalance behaviour Tool decides how many slots move and from where You compute the delta, so migrations are minimal and predictable
Heterogeneous nodes Ignores capacity — every primary gets an equal share Supports weighting, so a larger node can own proportionally more slots
Failure blast radius A bad auto-reshard can move far more data than intended Gaps or overlaps are possible if your math is wrong — you own coverage
Operational complexity Low — one command, no bookkeeping Higher — you must guarantee full 0–16383 coverage with no overlaps
Best fit Bootstrapping, dev clusters, homogeneous fleets IaC pipelines, weighted fleets, audited production topologies

Automatic even distribution — deep dive

The fastest path to a working cluster is to let redis-cli --cluster create compute the split. It divides 16,384 slots as evenly as possible across the primaries you list, pairs replicas by the --cluster-replicas factor, and writes the resulting map into each node's nodes.conf. Ongoing balance is maintained with --cluster rebalance, which inspects current ownership and moves the minimum number of slots to level the distribution.

# Create a 3-primary / 3-replica cluster; slots are auto-distributed evenly.
redis-cli --cluster create \
  10.0.1.10:6379 10.0.1.11:6379 10.0.1.12:6379 \
  10.0.1.20:6379 10.0.1.21:6379 10.0.1.22:6379 \
  --cluster-replicas 1

# Later, after adding a primary, let Redis level the distribution automatically.
redis-cli --cluster rebalance 10.0.1.10:6379 --cluster-use-empty-masters

The strength here is operational simplicity: one command, no slot arithmetic, and the tool guarantees full coverage. The weakness is opacity and non-determinism. Re-running create against a rebuilt fleet can land slots on different nodes, which makes the topology hard to diff in code review and hard to reconcile from an IaC state file. Automatic rebalance also treats every primary as identical — if one node has twice the memory, it still receives the same slot count, so you either under-use the large node or push the small ones toward OOM. Every primary must own at least one slot to accept writes; a zero-slot primary still gossips but serves no keyspace.

Two configuration parameters gate how safely automatic reshards behave under load:

  • cluster-node-timeout — set between 5000ms and 15000ms. Below 5000ms, transient network jitter triggers cascading failovers mid-reshard; above 15000ms, genuine outages take too long to fail over.
  • cluster-migration-barrier — defaults to 1. It is the minimum number of replicas a primary must keep before one of its replicas may migrate to cover an orphaned primary. This directly shapes how automated node provisioning and removal redistributes replicas during scaling.

Explicit deterministic assignment — deep dive

When the topology must be reproducible — the same slot ranges on every rebuild, verifiable in a pull request, and driven from Terraform or Ansible state — assign slots yourself. CLUSTER ADDSLOTSRANGE claims a contiguous range for the node you run it against, and CLUSTER DELSLOTSRANGE releases one. Because you compute the boundaries, the map is a pure function of your node inventory, and a rebuild reproduces it byte-for-byte.

# Deterministic 3-way split — each primary owns a fixed, reviewable range.
redis-cli -h 10.0.1.10 -p 6379 CLUSTER ADDSLOTSRANGE 0 5460
redis-cli -h 10.0.1.11 -p 6379 CLUSTER ADDSLOTSRANGE 5461 10922
redis-cli -h 10.0.1.12 -p 6379 CLUSTER ADDSLOTSRANGE 10923 16383

For heterogeneous fleets, weight the split to node capacity instead of dividing evenly. The following computes proportional contiguous ranges from a per-node weight and emits the exact ADDSLOTSRANGE calls, which an IaC controller can apply idempotently. Using the redis-py 5.x async client keeps the provisioning path non-blocking when a controller fans out across many nodes.

import asyncio
import redis.asyncio as redis

TOTAL_SLOTS = 16384

# Each node carries a weight proportional to its usable memory / CPU budget.
NODES = [
    {"host": "10.0.1.10", "port": 6379, "weight": 2.0},  # large node
    {"host": "10.0.1.11", "port": 6379, "weight": 1.0},
    {"host": "10.0.1.12", "port": 6379, "weight": 1.0},
]


def plan_ranges(nodes: list[dict]) -> list[tuple[dict, int, int]]:
    """Turn weights into contiguous, gap-free, non-overlapping slot ranges."""
    total_weight = sum(n["weight"] for n in nodes)
    plan, cursor = [], 0
    for i, node in enumerate(nodes):
        # The last node absorbs any rounding remainder so coverage is exactly 0..16383.
        if i == len(nodes) - 1:
            end = TOTAL_SLOTS - 1
        else:
            span = round(TOTAL_SLOTS * node["weight"] / total_weight)
            end = cursor + span - 1
        plan.append((node, cursor, end))
        cursor = end + 1
    return plan


async def apply_plan(plan: list[tuple[dict, int, int]]) -> None:
    for node, start, end in plan:
        client = redis.Redis(host=node["host"], port=node["port"], decode_responses=True)
        try:
            # ADDSLOTSRANGE is idempotent per node: re-running the same plan is safe.
            await client.execute_command("CLUSTER", "ADDSLOTSRANGE", start, end)
            print(f"{node['host']} owns slots {start}-{end} ({end - start + 1} slots)")
        finally:
            await client.aclose()


async def main() -> None:
    plan = plan_ranges(NODES)
    covered = sum(end - start + 1 for _, start, end in plan)
    assert covered == TOTAL_SLOTS, f"coverage gap: {covered} != {TOTAL_SLOTS}"
    await apply_plan(plan)


if __name__ == "__main__":
    asyncio.run(main())

The assert covered == TOTAL_SLOTS line is the load-bearing safety check: explicit assignment hands you the power to leave a slot unowned, and Redis will happily start with a coverage gap that only surfaces when a client hits the missing range. The trade-off for this control is that you now own that invariant. Once the Redis cluster is live, individual slots are moved with the CLUSTER SETSLOT ... NODE state machine rather than ADDSLOTSRANGE, which is the subject of the full step-by-step slot migration guide.

Whichever approach assigns the slots, production clients cache the resulting slot-to-node map and must handle two redirects when it goes stale during scaling or failover. A MOVED <slot> <ip>:<port> reply signals a permanent ownership change — the client updates its table and retries. An ASK <slot> <ip>:<port> reply signals a slot mid-migration — the client sends ASKING to the destination then retries the original command, but must not update its table, because the redirect is temporary. The redis-py 5.x cluster client handles both transparently:

import redis.asyncio.cluster as rc
from redis.backoff import ExponentialBackoff
from redis.retry import Retry

client = rc.RedisCluster(
    host="10.0.1.10",
    port=6379,
    decode_responses=True,
    retry=Retry(ExponentialBackoff(), 3),
    read_from_replicas=True,          # spread reads; writes still route to the owning primary
    max_connections=200,
)

# The client computes CRC16(key) % 16384, routes to the owner, and follows MOVED/ASK for you.
async def write_profile() -> None:
    await client.set("{user:1001}:profile", "active_data")

When to choose which

Tie the decision to concrete operational signals, not preference:

  1. Reproducibility requirement. If the topology must be defined in code, diffed in review, and rebuilt identically across environments, choose explicit assignment. A map you cannot reproduce is a map you cannot safely automate.
  2. Fleet homogeneity. If every primary has identical memory and CPU, automatic even distribution is correct and cheaper to operate. The moment node capacities diverge, weighted explicit ranges prevent the largest node from idling while the smallest evicts.
  3. Team operational maturity. Explicit assignment adds a coverage-and-overlap invariant your tooling must enforce on every change. If that guardrail is not in place, automatic distribution's built-in coverage guarantee is the safer default.
  4. Scale of change. For a one-off dev cluster, --cluster create wins on speed. For a fleet that grows and shrinks on telemetry triggers, deterministic ranges make each migration a minimal, predictable delta rather than an opaque bulk move.
  5. Audit and compliance. If you must prove which node held which keyspace at a point in time, explicit ranges recorded in version control give you that history for free.

A common production pattern is hybrid: bootstrap with --cluster create to get running, then codify the resulting ranges into an explicit plan so every subsequent change is deterministic.

Failure modes and diagnostics

1. MOVED redirect storm from a stale client map. After a reshard or failover, a client whose cached map is out of date sends each command to the wrong primary, eats a MOVED, and retries — doubling latency and connection churn until the map refreshes. The request flow that inflates tail latency looks like this:

A MOVED redirect costs a stale client two round trips Three lifelines: the client with a stale map, Primary A the former owner, and Primary B the new owner. The client first sends GET on {user:1001}:profile to Primary A, which no longer owns slot 8342 and replies MOVED 8342 to 10.0.1.11:6379. The client updates its cached slot-to-node map, then retries the same GET against Primary B, which returns the value. A note records that every stale-slot request pays two round trips until the map settles. Client stale map Primary A former owner of slot 8342 Primary B new owner GET {user:1001}:profile > C (MOVED) --> MOVED 8342 → 10.0.1.11:6379 update slot-to-node map retry: GET {user:1001}:profile > C (value) --> value Every stale-slot request pays two round trips until the map settles

Diagnose by confirming the topology actually changed and that the client should have refreshed:

redis-cli -c -h 10.0.1.10 -p 6379 CLUSTER SLOTS
redis-cli -c -h 10.0.1.10 -p 6379 CLUSTER NODES | grep master

If ownership is correct but a client keeps missing, the client's map cache is not refreshing on MOVED — verify the driver is cluster-aware (as above) rather than a plain single-node connection.

2. Uncovered slots after explicit assignment. A gap in your ADDSLOTSRANGE math leaves slots unowned; any key hashing into the gap returns CLUSTERDOWN Hash slot not served. This is the failure mode automatic distribution cannot produce. Detect it immediately:

redis-cli -h 10.0.1.10 -p 6379 CLUSTER INFO | grep -E "cluster_state|cluster_slots_assigned"
# cluster_state:ok and cluster_slots_assigned:16384 are both required.
redis-cli --cluster check 10.0.1.10:6379   # prints "[OK] All 16384 slots covered." when healthy

Fix by adding the missing range to the intended owner: CLUSTER ADDSLOTSRANGE <gap_start> <gap_end>.

3. Overlapping or conflicting ownership. If two nodes each claim the same slot (e.g. a half-applied plan or a stale configEpoch after a partition), gossip resolves the conflict unpredictably and some keys become unreachable. Configuration-epoch collisions are the usual root cause; check and resolve them:

redis-cli -h 10.0.1.10 -p 6379 CLUSTER NODES | awk '{print $1, $3, $7, $9}'   # id, flags, epoch, slots
redis-cli -h <affected-node> -p 6379 CLUSTER SET-CONFIG-EPOCH <unique-epoch>

Verification

Confirm the allocation is complete and evenly serving traffic before trusting the Redis cluster:

# 1. Full coverage and healthy state.
redis-cli --cluster check 10.0.1.10:6379

# 2. Exact slot map per node — assert no gaps and no overlaps.
redis-cli -c -h 10.0.1.10 -p 6379 CLUSTER SLOTS

# 3. Per-node keyspace balance — a wildly uneven count signals skew, not an allocation bug.
for host in 10.0.1.10 10.0.1.11 10.0.1.12; do
  echo -n "$host keys: "; redis-cli -h "$host" -p 6379 DBSIZE
done

Uniform slot distribution is a theoretical ideal; real workloads introduce skew through hot keys, large hash structures, or sequential time-series patterns, so one slot can saturate its owning node while others idle. Track allocation health continuously with redis_exporter and Prometheus — the key series are redis_cluster_slots_assigned (must equal 16,384), redis_cluster_slots_ok, and redis_cluster_known_nodes (gossip membership stability). Alert on missing coverage the moment it appears:

redis_cluster_slots_assigned != 16384

When skew rather than coverage is the problem, the fix is co-locating hot keys with hash tags, migrating the hot slot, or reweighting the split — not re-running allocation from scratch.


Up one level: Redis Cluster Scaling, Sharding & Automation