Migrate a cluster to HotKV
HotKV uses the same 16384 slots and the same key hashing as Redis Cluster and Valkey cluster, and your cluster clients keep working unchanged, so moving a cluster means moving its data and then pointing the application at the new addresses. This page covers the ways to do it, with what each one needs from the source, and the limits you should know before you start. For a single server, see Migrate from Redis or Valkey.
HotKV and Redis or Valkey nodes cannot be in one cluster, and a Redis or Valkey replica cannot follow a HotKV primary or the other way round (see What does not work). So a migration always builds a separate HotKV cluster and copies the data into it. Plan for one cutover.
The examples use a Redis 8.10.2 cluster and a Valkey 9.1.2 cluster of three primaries each,
holding example data: strings, hashes, lists, sets, sorted sets and streams, some with
a time to live, and a function library. The target is a HotKV cluster. Their
addresses are 127.0.0.1 with ports 7000 to 7002 (Redis), 7050 to 7052 (Valkey) and
7010 to 7025 (HotKV). The key counts and key names in the transcripts come from that
example data, not from yours.
Which method
Section titled “Which method”| Method | From Redis | From Valkey | Needs |
|---|---|---|---|
| Start HotKV on the shards’ files | Works | Does not work: HotKV does not read Valkey’s snapshot | Downtime for the files, and the same slot ranges |
Live copy with MIGRATE ... COPY |
Works | Does not work: HotKV refuses the payloads of Valkey 9.1 | The source nodes can reach the HotKV nodes |
| Live copy by value | Not run, the methods above do the job | Works for the types listed | Read access to the source |
| Dual writes | Works | Works | Your application writes to both clusters |
Use the shards’ files when you can accept a short stop of writes: it is the fastest and moves everything the files hold. Use a live copy to prepare the HotKV cluster while the old one serves, then refresh it and cut over. A Valkey cluster needs the copy by value.
Start HotKV on the shards’ files
Section titled “Start HotKV on the shards’ files”HotKV reads Redis snapshots (dump.rdb) up to the format of Redis 8.10: every data
type HotKV has, with its TTLs, and the function libraries. A key of a type HotKV does
not have, such as a module’s type or a Redis 8 vector set, makes HotKV refuse the file
and stop the start (see Migrate from Redis or Valkey).
For a cluster, give each HotKV node the file of one source primary, start it, and give
it the slots that primary owned.
-
Learn the slot ranges of the source:
$ redis-cli -p 7000 CLUSTER NODES | cut -d" " -f2,3,9127.0.0.1:7001@17001 master 5461-10922127.0.0.1:7000@17000 myself,master 0-5460127.0.0.1:7002@17002 master 10923-16383 -
Stop writes to the source, and save each primary. A stop with
SHUTDOWN SAVEwrites the file and stops the node; keep the node’s directory:$ redis-cli -p 7000 SHUTDOWN SAVE(The same for 7001 and 7002.) The file is
dump.rdbin the node’s directory. Everything written after the file was made is not in it. -
Put each
dump.rdbin the data directory of a new, empty HotKV node and start the node withcluster-enabled yes. HotKV loads the file when its directory has no HotKV snapshot yet, and leaves the Redis file as it is:$ for p in 7010 7011 7012; do hotkv --port $p --cluster-enabled yes --dir node-$p --daemonize yes --logfile hotkv.log; done$ grep -E "oad|RDB|rdb" node-7010/hotkv.log | head -n 6 | cut -c1-200[2026-01-15 10:00:00.334] [hotkv] [info] No snapshot of HotKV's yet (node-7010/dump.hkv), but Redis's node-7010/dump.rdb: loading it (HotKV saves to node-7010/dump.hkv and leaves it as it is)[2026-01-15 10:00:00.339] [hotkv] [info] Loaded the Redis snapshot node-7010/dump.rdb (written by Redis 8.10.2): 10414 keys[2026-01-15 10:00:00.339] [hotkv] [info] Loaded snapshot from node-7010/dump.rdb$ for p in 7010 7011 7012; do echo $p $(redis-cli -p $p DBSIZE); done7010 104147011 103667012 10421The key counts match the source,
10414,10366and10421. -
Give each node the slot range of the primary whose file it loaded, and join the nodes.
CLUSTER ADDSLOTSRANGEassigns a range, andCLUSTER MEETjoins a node (an IP address, not a name):$ redis-cli -p 7010 CLUSTER ADDSLOTSRANGE 0 5460OK$ redis-cli -p 7011 CLUSTER ADDSLOTSRANGE 5461 10922OK$ redis-cli -p 7012 CLUSTER ADDSLOTSRANGE 10923 16383OK$ redis-cli -p 7010 CLUSTER MEET 127.0.0.1 7011; redis-cli -p 7010 CLUSTER MEET 127.0.0.1 7012OKOK$ redis-cli -p 7010 CLUSTER INFO | grep -E "cluster_state|cluster_slots_ok|cluster_known_nodes"cluster_state:okcluster_slots_ok:16384cluster_known_nodes:3$ redis-cli --cluster check 127.0.0.1:7010 | grep -E "keys|OK|ERR|WARN"127.0.0.1:7010 (a96e1d84...) -> 10414 keys | 5461 slots | 0 slaves.127.0.0.1:7012 (68380f0b...) -> 10421 keys | 5461 slots | 0 slaves.127.0.0.1:7011 (da2b8bbe...) -> 10366 keys | 5462 slots | 0 slaves.[OK] 31201 keys in 3 masters.1.90 keys per slot on average.[OK] All nodes agree about slots configuration.[OK] All 16384 slots covered. -
Check your data, then add replicas as for any new cluster (see Scaling a cluster):
$ redis-cli -c -p 7010 GET user:12345; redis-cli -c -p 7010 HGETALL profile:7; redis-cli -c -p 7010 TTL session:7; redis-cli -c -p 7010 XLEN events:3value-12345namen7visits735271
The slot ranges must match: each node holds the keys of its source primary, and a
key that is in a slot the node does not own is not served from there. Take the ranges
from CLUSTER NODES as shown, whatever shape the source has. If your source has
replicas, start with the primaries’ files only and add the replicas afterwards.
Live copy with MIGRATE
Section titled “Live copy with MIGRATE”A live copy fills the HotKV cluster while the old cluster keeps serving. The script
below walks each source primary, and for each key sends MIGRATE ... COPY REPLACE to the
HotKV node that owns the key’s slot, so the key stays on the source and keeps its remaining
time to live. It needs pip install redis (in a virtual environment, see Cluster clients), and every source node must reach every HotKV node over the
network. Set TARGET_PASSWORD when the HotKV nodes have a password.
"""Copy every key of a Redis or Valkey cluster into a HotKV cluster, routing each key by slot.
Usage: python3 copy_keys.py SOURCE_HOST:PORT TARGET_HOST:PORTSet TARGET_PASSWORD when the HotKV nodes need a password."""import osimport sys
from redis.cluster import RedisCluster
src_host, src_port = sys.argv[1].rsplit(":", 1)dst_host, dst_port = sys.argv[2].rsplit(":", 1)password = os.environ.get("TARGET_PASSWORD")src = RedisCluster(host=src_host, port=int(src_port))dst = RedisCluster(host=dst_host, port=int(dst_port), password=password)auth = ["AUTH", password] if password else []
def owner(key): node = dst.nodes_manager.slots_cache[dst.keyslot(key)][0] return node.host, node.port
copied, failed, first_error = 0, 0, None
def run(pipe): global copied, failed, first_error for result in pipe.execute(raise_on_error=False): if isinstance(result, Exception): failed += 1 first_error = first_error or str(result) else: copied += 1
for primary in src.get_primaries(): conn = src.get_redis_connection(primary) pipe = conn.pipeline(transaction=False) for key in conn.scan_iter(count=1000): host, port = owner(key) # COPY keeps the key on the source, REPLACE overwrites a key already in HotKV pipe.execute_command("MIGRATE", host, port, key, 0, 5000, "COPY", "REPLACE", *auth) if len(pipe) >= 500: run(pipe) run(pipe)
print(f"copied {copied} keys, {failed} failed" + (f" (first error: {first_error})" if failed else ""))Run it against an empty HotKV cluster created in the usual way (see Quick
start). It sends one key per MIGRATE, because a Redis
or Valkey cluster refuses a MIGRATE of several keys that are in different slots with
CROSSSLOT:
$ python3 copy_keys.py 127.0.0.1:7000 127.0.0.1:7020copied 31201 keys, 0 failed$ redis-cli --cluster check 127.0.0.1:7020 | grep -E "keys|OK"127.0.0.1:7020 (1d8bad6d...) -> 10414 keys | 5461 slots | 1 slaves.127.0.0.1:7022 (20c941a9...) -> 10421 keys | 5461 slots | 1 slaves.127.0.0.1:7021 (a51ccce8...) -> 10366 keys | 5462 slots | 1 slaves.[OK] 31201 keys in 3 masters.1.90 keys per slot on average.[OK] All nodes agree about slots configuration.[OK] All 16384 slots covered.$ redis-cli -c -p 7020 GET user:12345; redis-cli -c -p 7020 HGETALL profile:7; redis-cli -c -p 7020 TTL session:7; redis-cli -c -p 7020 XLEN events:3value-12345namen7visits734611$ redis-cli -p 7020 INFO keyspace; redis-cli -p 7000 INFO keyspace# Keyspacedb0:keys=10414,expires=63,avg_ttl=3461281,subexpiry=0# Keyspacedb0:keys=10414,expires=63,avg_ttl=3466177,subexpiry=0The counts, the keys that have a time to live (63) and the average time to live match between the two clusters. The copy does not move changes: a key changed on the source afterwards keeps its old value in HotKV until you run the script again, which overwrites every key:
$ redis-cli -c -p 7000 SET user:12345 changed; redis-cli -c -p 7020 GET user:12345OKvalue-12345$ python3 copy_keys.py 127.0.0.1:7000 127.0.0.1:7020copied 31201 keys, 0 failed$ redis-cli -c -p 7020 GET user:12345changedA key deleted on the source stays in HotKV, so a final copy needs the writes stopped, or dual writes, to be exact. Run the copy once to fill the target, then again just before the cutover. The script copies database 0, as a cluster node serves.
Live copy by value
Section titled “Live copy by value”This script reads each key with the commands of its type and writes it to the HotKV cluster, so it does not depend on the dump format. It handles strings, hashes, lists, sets, sorted sets and streams, with their time to live. It does not copy consumer groups of streams, hash field expiry, or any other type: a key of another type is counted and skipped.
"""Copy the keys of a Valkey cluster into a HotKV cluster by value.
Handles strings, hashes, lists, sets, sorted sets and streams (without consumergroups), with their time to live. Usage: python3 copy_values.py SOURCE_HOST:PORT TARGET_HOST:PORTSet TARGET_PASSWORD when the HotKV nodes need a password."""import osimport sys
from redis.cluster import RedisCluster
src_host, src_port = sys.argv[1].rsplit(":", 1)dst_host, dst_port = sys.argv[2].rsplit(":", 1)src = RedisCluster(host=src_host, port=int(src_port))dst = RedisCluster(host=dst_host, port=int(dst_port), password=os.environ.get("TARGET_PASSWORD"))
copied, skipped = 0, {}for primary in src.get_primaries(): conn = src.get_redis_connection(primary) for key in conn.scan_iter(count=1000): kind = conn.type(key).decode() ttl = conn.pttl(key) if kind == "string": value = conn.get(key) dst.set(key, value) elif kind == "hash": dst.delete(key) dst.hset(key, mapping=conn.hgetall(key)) elif kind == "list": dst.delete(key) dst.rpush(key, *conn.lrange(key, 0, -1)) elif kind == "set": dst.delete(key) dst.sadd(key, *conn.smembers(key)) elif kind == "zset": dst.delete(key) dst.zadd(key, dict(conn.zrange(key, 0, -1, withscores=True))) elif kind == "stream": dst.delete(key) for entry_id, fields in conn.xrange(key): dst.xadd(key, fields, id=entry_id) else: skipped[kind] = skipped.get(kind, 0) + 1 continue if ttl > 0: dst.pexpire(key, ttl) copied += 1
print(f"copied {copied} keys, skipped {skipped}")$ python3 copy_values.py 127.0.0.1:7050 127.0.0.1:7080copied 31201 keys, skipped {}$ redis-cli --cluster check 127.0.0.1:7080 | grep -E "keys|OK"127.0.0.1:7080 (84c395fa...) -> 10414 keys | 5461 slots | 1 slaves.127.0.0.1:7081 (cb5efb5c...) -> 10366 keys | 5462 slots | 1 slaves.127.0.0.1:7082 (4764841c...) -> 10421 keys | 5461 slots | 1 slaves.[OK] 31201 keys in 3 masters....$ redis-cli -c -p 7080 XRANGE events:3 - +1791066501452-0kv$ redis-cli -c -p 7050 XRANGE events:3 - +1791066501452-0kvEvery key arrived, with its stream IDs. Like the previous script, it overwrites and does not delete, and one run is a snapshot of the moment it passed each key.
Cut over with dual writes
Section titled “Cut over with dual writes”To keep the two clusters in step while you copy and test, have the application write every change to both clusters. The old cluster stays the source of truth, so a write that fails on HotKV is not an outage and is fixed by the next copy. This script stands in for the application: it writes numbered keys to both.
"""Write to the old cluster and the new HotKV cluster, so that both hold every new write.
Usage: python3 dualwrite.py OLD_HOST:PORT NEW_HOST:PORT SECONDS"""import sysimport time
from redis.cluster import RedisCluster
old_host, old_port = sys.argv[1].rsplit(":", 1)new_host, new_port = sys.argv[2].rsplit(":", 1)old = RedisCluster(host=old_host, port=int(old_port), decode_responses=True)new = RedisCluster(host=new_host, port=int(new_port), decode_responses=True)
n, start = 0, time.time()while time.time() - start < float(sys.argv[3]): key = f"order:{n}" old.set(key, n) # the old cluster stays the source of truth until the cutover new.set(key, n) # a failed write here is logged and fixed by the next copy n += 1 time.sleep(0.005)print(f"wrote {n} keys to both clusters")Run it for 20 seconds while the copy script runs against the same clusters. After both have finished, compare the clusters: they should hold the same keys, and no string key should differ:
$ python3 dualwrite.py 127.0.0.1:7000 127.0.0.1:7020 20 &$ python3 copy_keys.py 127.0.0.1:7000 127.0.0.1:7020copied <n> keys, 0 failed$ python3 cmp.pykeys: old=<n> new=<n>; string keys that differ: 0The comparison script (cmp.py) counts the keys of both clusters and compares every
string key:
from redis.cluster import RedisClusterold = RedisCluster(host="127.0.0.1", port=7000, decode_responses=True)new = RedisCluster(host="127.0.0.1", port=7020, decode_responses=True)n = sum(old.get_redis_connection(p).dbsize() for p in old.get_primaries())m = sum(new.get_redis_connection(p).dbsize() for p in new.get_primaries())diff = 0for key in old.scan_iter(count=1000): if old.type(key) == "string" and old.get(key) != new.get(key): diff += 1print(f"keys: old={n} new={m}; string keys that differ: {diff}")The steps of a cutover with this method:
- Build the HotKV cluster, with replicas, the same
maxmemoryand ACL users as the old one (see below), and test it with your application. - Start dual writes in the application.
- Run the copy (by file,
MIGRATEor by value), and compare the two clusters. - Read from HotKV for a part of the traffic, and compare answers.
- Switch the reads to HotKV, then stop writing to the old cluster. Keep it for a while as the way back.
What does not work
Section titled “What does not work”A HotKV node cannot join a Redis or Valkey cluster, nor the other way round.
CLUSTER MEET between the two answers OK, but the handshake never completes: the other
node stays handshake and disconnected, and the clusters do not mix:
127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7020OK$ redis-cli -p 7000 CLUSTER NODES | cut -d" " -f2,3,8 | sort127.0.0.1:7000@17000 myself,master connected127.0.0.1:7001@17001 master connected127.0.0.1:7002@17002 master connected127.0.0.1:7020@17020 handshake disconnected127.0.0.1:7020> CLUSTER MEET 127.0.0.1 7001OK$ redis-cli -p 7020 CLUSTER NODES | cut -d" " -f2,3,8 | grep 7001127.0.0.1:7001@17001 master,handshake disconnectedA Redis replica cannot follow HotKV, and a HotKV replica cannot follow Redis. So you cannot make HotKV a replica of the old cluster to catch up and switch over:
127.0.0.1:7040> REPLICAOF 127.0.0.1 7042OK$ redis-cli -p 7040 INFO replication | grep -E "^(role|master_host|master_link_status|master_sync_in_progress)"role:slavemaster_host:127.0.0.1master_link_status:downmaster_sync_in_progress:0$ grep -o "Master does not support PSYNC or is in error state (reply: -ERR PSYNC here serves HotKV replicas only" redis.log | tail -n 1Master does not support PSYNC or is in error state (reply: -ERR PSYNC here serves HotKV replicas only127.0.0.1:7041> REPLICAOF 127.0.0.1 7043OK$ redis-cli -p 7041 INFO replication | grep -E "^(role|master_host|master_link_status|master_sync_in_progress)"role:slavemaster_host:127.0.0.1master_link_status:downmaster_sync_in_progress:0$ grep -o "cannot connect to primary [0-9.:]*: it refused" node-7041/hotkv.log | tail -n 1cannot connect to primary 127.0.0.1:7043: it refused(Node 7040 is Redis and follows HotKV on 7042; 7041 is HotKV and follows Redis on 7043.)
Anything else that asks HotKV to act as the primary of a Redis replica, such as
redis-cli --rdb or --replica, gets the same refusal of PSYNC.
What stays the same and what does not carry over
Section titled “What stays the same and what does not carry over”The slot of a key is the same: both use CRC16 of the key, or of the hash tag in braces. The slots of these keys, asked from HotKV on port 7010 (a Redis node and a Valkey node returned the same number for each of these ten keys), including the cases with empty or repeated braces:
$ for k in user:1 user:99999 {cart1}:a profile:7 abc "{a}x" "{}x" "a{}b" "k{a}{b}" 日本語; do b=$(redis-cli -p 7010 CLUSTER KEYSLOT "$k"); echo "$k $b"; doneuser:1 10778user:99999 11051{cart1}:a 3311profile:7 1098abc 7638{a}x 15495{}x 10595a{}b 13694k{a}{b} 15495日本語 12788What the data move does not carry, and what to do:
| Item | What happens |
|---|---|
| ACL users | Not in a snapshot or a copy. They are per node: recreate them with ACL SETUSER on every HotKV node, or give every node the same ACL file. |
| Functions | Loaded with the snapshot files. The live copies do not copy them: load them with FUNCTION LOAD on each node. |
| Cached scripts, client connections, pub/sub subscriptions | Not part of the data. Applications reconnect and reload scripts. |
| Modules | HotKV loads none. A key that holds a module’s type cannot be loaded or copied (see Migrate from Redis or Valkey). |
| Configuration | Carries only as you copy it: HotKV reads redis.conf syntax, and directives it does not use are logged and ignored. |
| Reported version | INFO reports redis_version:8.4.0 (the setting compat-redis-version), which is the version your clients see. |
HotKV nodes need their own cluster-enabled yes, maxmemory and persistence settings.
Size maxmemory by the data you copied: a node that is over the limit while a copy runs
answers OOM to the copy.
Next steps
Section titled “Next steps”- Production deployment to size and place the HotKV nodes.
- Clients for connecting your application to a HotKV cluster.
- Backup and restore for per-node files.
- Monitoring a cluster to compare the two clusters during the move.
Was this page helpful?
Thanks for the feedback.