# Migrate a cluster to HotKV

> Move the data of a Redis or Valkey cluster to a HotKV cluster with the shards' files, with a live copy, or with dual writes, and what does not carry over.

URL: https://docs.hotkv.com/clustering/migrate-to-hotkv/


**Availability:** Not on HotKV Cloud. On-Premise: no license needed.

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](https://docs.hotkv.com/get-started/migrate-from-redis/index.md).

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](#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

| Method | From Redis | From Valkey | Needs |
| --- | --- | --- | --- |
| [Start HotKV on the shards' files](#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`](#live-copy-with-migrate) | Works | Does not work: HotKV refuses the payloads of Valkey 9.1 | The source nodes can reach the HotKV nodes |
| [Live copy by value](#live-copy-by-value) | Not run, the methods above do the job | Works for the types listed | Read access to the source |
| [Dual writes](#cut-over-with-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

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](https://docs.hotkv.com/get-started/migrate-from-redis/index.md)).
For a cluster, give each HotKV node the file of one source primary, start it, and give
it the slots that primary owned.

1. Learn the slot ranges of the source:

   ```
   $ redis-cli -p 7000 CLUSTER NODES | cut -d" " -f2,3,9
   127.0.0.1:7001@17001 master 5461-10922
   127.0.0.1:7000@17000 myself,master 0-5460
   127.0.0.1:7002@17002 master 10923-16383
   ```

2. Stop writes to the source, and save each primary. A stop with `SHUTDOWN SAVE`
   writes 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.rdb` in the node's directory.
   Everything written after the file was made is not in it.

3. Put each `dump.rdb` in the data directory of a new, empty HotKV node and start the
   node with `cluster-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); done
   7010 10414
   7011 10366
   7012 10421
   ```

   The key counts match the source, `10414`, `10366` and `10421`.

4. Give each node the slot range of the primary whose file it loaded, and join the
   nodes. [`CLUSTER ADDSLOTSRANGE`](https://docs.hotkv.com/commands/cluster-addslotsrange/index.md) assigns a range,
   and [`CLUSTER MEET`](https://docs.hotkv.com/commands/cluster-meet/index.md) joins a node (an IP address, not a
   name):

   ```
   $ redis-cli -p 7010 CLUSTER ADDSLOTSRANGE 0 5460
   OK
   $ redis-cli -p 7011 CLUSTER ADDSLOTSRANGE 5461 10922
   OK
   $ redis-cli -p 7012 CLUSTER ADDSLOTSRANGE 10923 16383
   OK
   $ redis-cli -p 7010 CLUSTER MEET 127.0.0.1 7011; redis-cli -p 7010 CLUSTER MEET 127.0.0.1 7012
   OK
   OK
   $ redis-cli -p 7010 CLUSTER INFO | grep -E "cluster_state|cluster_slots_ok|cluster_known_nodes"
   cluster_state:ok
   cluster_slots_ok:16384
   cluster_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.
   ```

5. Check your data, then add replicas as for any new cluster (see [Scaling a
   cluster](https://docs.hotkv.com/clustering/scaling/index.md#add-a-node)):

   ```
   $ 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:3
   value-12345
   name
   n7
   visits
   7
   3527
   1
   ```

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.

> **Caution**
> A Valkey snapshot starts with `VALKEY`, not `REDIS`. HotKV starts on a directory with
> such a file and does not load it: the node is up with 0 keys.
> If `DBSIZE` is 0 after the start, check what the first bytes of the file are and use
> [a live copy by value](#live-copy-by-value) instead:
>
> ```
> $ head -c 9 node-7050/dump.rdb; echo
> VALKEY080
> ```

## 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](https://docs.hotkv.com/clustering/clients/index.md)), and every source node must reach every HotKV node over the
network. Set `TARGET_PASSWORD` when the HotKV nodes have a password.

```python
"""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:PORT
Set TARGET_PASSWORD when the HotKV nodes need a password.
"""
import os
import 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](https://docs.hotkv.com/clustering/quick-start/index.md)). 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:7020
copied 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:3
value-12345
name
n7
visits
7
3461
1
$ redis-cli -p 7020 INFO keyspace; redis-cli -p 7000 INFO keyspace
# Keyspace
db0:keys=10414,expires=63,avg_ttl=3461281,subexpiry=0
# Keyspace
db0:keys=10414,expires=63,avg_ttl=3466177,subexpiry=0
```

The 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:12345
OK
value-12345
$ python3 copy_keys.py 127.0.0.1:7000 127.0.0.1:7020
copied 31201 keys, 0 failed
$ redis-cli -c -p 7020 GET user:12345
changed
```

A 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.

> **Caution**
> Run against a Valkey 9.1 cluster, the same script fails for every key with `ERR DUMP
> payload version or checksum are wrong`. HotKV reads the dump payloads of Redis, up to
> Redis 8.10, but not those of Valkey 9.1. The source cluster is not changed (`COPY`). Use
> the copy by value below.

## 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.

```python
"""Copy the keys of a Valkey cluster into a HotKV cluster by value.

Handles strings, hashes, lists, sets, sorted sets and streams (without consumer
groups), with their time to live. Usage: python3 copy_values.py SOURCE_HOST:PORT TARGET_HOST:PORT
Set TARGET_PASSWORD when the HotKV nodes need a password.
"""
import os
import 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:7080
copied 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-0
k
v
$ redis-cli -c -p 7050 XRANGE events:3 - +
1791066501452-0
k
v
```

Every 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

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.

```python
"""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 sys
import 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:7020
copied <n> keys, 0 failed
$ python3 cmp.py
keys: old=<n> new=<n>; string keys that differ: 0
```

The comparison script (`cmp.py`) counts the keys of both clusters and compares every
string key:

```python
from redis.cluster import RedisCluster
old = 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 = 0
for key in old.scan_iter(count=1000):
    if old.type(key) == "string" and old.get(key) != new.get(key):
        diff += 1
print(f"keys: old={n} new={m}; string keys that differ: {diff}")
```

The steps of a cutover with this method:

1. Build the HotKV cluster, with replicas, the same `maxmemory` and ACL users as the old
   one (see below), and test it with your application.
2. Start dual writes in the application.
3. Run the copy (by file, `MIGRATE` or by value), and compare the two clusters.
4. Read from HotKV for a part of the traffic, and compare answers.
5. 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

**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 7020
OK
$ redis-cli -p 7000 CLUSTER NODES | cut -d" " -f2,3,8 | sort
127.0.0.1:7000@17000 myself,master connected
127.0.0.1:7001@17001 master connected
127.0.0.1:7002@17002 master connected
127.0.0.1:7020@17020 handshake disconnected
127.0.0.1:7020> CLUSTER MEET 127.0.0.1 7001
OK
$ redis-cli -p 7020 CLUSTER NODES | cut -d" " -f2,3,8 | grep 7001
127.0.0.1:7001@17001 master,handshake disconnected
```

**A 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 7042
OK
$ redis-cli -p 7040 INFO replication | grep -E "^(role|master_host|master_link_status|master_sync_in_progress)"
role:slave
master_host:127.0.0.1
master_link_status:down
master_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 1
Master does not support PSYNC or is in error state (reply: -ERR PSYNC here serves HotKV replicas only
127.0.0.1:7041> REPLICAOF 127.0.0.1 7043
OK
$ redis-cli -p 7041 INFO replication | grep -E "^(role|master_host|master_link_status|master_sync_in_progress)"
role:slave
master_host:127.0.0.1
master_link_status:down
master_sync_in_progress:0
$ grep -o "cannot connect to primary [0-9.:]*: it refused" node-7041/hotkv.log | tail -n 1
cannot 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

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"; done
user:1 10778
user:99999 11051
{cart1}:a 3311
profile:7 1098
abc 7638
{a}x 15495
{}x 10595
a{}b 13694
k{a}{b} 15495
日本語 12788
```

What 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](https://docs.hotkv.com/get-started/migrate-from-redis/index.md)). |
| 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

- [Production deployment](https://docs.hotkv.com/clustering/production/index.md) to size and place the HotKV nodes.
- [Clients](https://docs.hotkv.com/clustering/clients/index.md) for connecting your application to a HotKV cluster.
- [Backup and restore](https://docs.hotkv.com/clustering/backup-and-restore/index.md) for per-node files.
- [Monitoring a cluster](https://docs.hotkv.com/clustering/monitoring/index.md) to compare the two clusters during the move.