Shard Migration & Rebalancing Operations
Every partitioned system eventually has to move data it is actively serving: a shard grows past its scaling ceiling, a hot tenant needs its own hardware, or an unpartitioned table finally becomes too large to manage in place. This guide is the operations discipline for those moves β planning, executing, and verifying data movement between partitions and shards while production traffic continues. It covers the full lifecycle in three sub-topics: Zero-Downtime Resharding Workflows for splitting and merging live shards, Online Table Repartitioning for converting monolithic tables to partitioned layouts, and Shard Rebalancing & Data Movement for redistributing keys after topology changes.
The unifying model across all three is a five-phase pipeline: mirror new writes, backfill history, verify parity, cut over routing, then clean up. Everything else β tool choice, throttling, monitoring β is detail inside those phases.
Why Shards Move: Migration Triggers
Data movement is expensive and risky, so every migration should trace back to a concrete operational trigger. Four dominate in production:
- A shard approaches a hard ceiling. Disk, IOPS, connection count, or vacuum throughput on one node can no longer keep up. The economics of splitting versus scaling up are covered in the Scaling Limits & Cost Tradeoffs guide linked above; once vertical headroom is gone, a split is the only option.
- Key skew concentrates load. One tenant or region grows to dominate a shard that was balanced at design time. The fix is moving that keyspace to dedicated hardware β a targeted migration, not a full reshuffle.
- Topology changes. Adding nodes to absorb growth means a fraction of every existing shardβs keys must relocate. With consistent hashing this is bounded; with modulo routing it is a near-total rewrite.
- A monolithic table needs a partitioned layout. The table was never partitioned, and now
VACUUMtakes twelve hours and every index rebuild is an outage risk. Converting it online is its own discipline, covered in the Online Table Repartitioning sub-guide.
Quantify the trigger before committing. This query gives the per-shard baseline you will compare against after the move:
-- Run on each shard: size, dead-tuple pressure, and top keyspace consumers
SELECT inet_server_addr()::text AS shard,
pg_size_pretty(pg_database_size(current_database())) AS db_size,
(SELECT round(sum(n_dead_tup)::numeric /
NULLIF(sum(n_live_tup), 0) * 100, 1)
FROM pg_stat_user_tables) AS dead_pct;
-- Which keyspace is driving growth on this shard (last 30 days)
SELECT region, count(*) AS rows_30d,
pg_size_pretty(sum(pg_column_size(o.*))::bigint) AS approx_bytes
FROM orders o
WHERE placed_at >= now() - interval '30 days'
GROUP BY region
ORDER BY rows_30d DESC;
If one key value accounts for more than roughly 40 % of a shardβs recent writes, plan a targeted keyspace move. If growth is uniform, plan a topology expansion and rebalance instead β the two have very different blast radii.
Migration Strategy Taxonomy
Four mechanisms cover essentially every production migration on PostgreSQL and MySQL. The choice hinges on engine, whether the move crosses instances, and how much write pause you can tolerate.
| Strategy | Engines | Write impact | Best for | Key limitation |
|---|---|---|---|---|
| Logical replication (publication/subscription) | PostgreSQL 10+ | None during copy; seconds at cutover | Cross-instance shard splits and keyspace moves | No DDL or sequence replication; row filters need PG 15+ |
| Trigger-based dual-write | PostgreSQL, MySQL | Adds 1β3 ms per write | Same-instance repartitioning (old table β new partitioned table) | Trigger must stay enabled for the whole window; drift if bypassed |
| gh-ost / pt-online-schema-change | MySQL | None during copy; brief metadata lock at swap | In-place rebuilds, including adding PARTITION BY to a live table |
Binlog-based; foreign-key and trigger restrictions |
| Dump-and-load | Any | Full write freeze on the moving keyspace | Small shards (< 50 GB), maintenance windows, initial seeding | Downtime proportional to data size |
Logical replication
The default for cross-instance moves on PostgreSQL. The source publishes row changes; the target subscribes, performs an initial copy, then applies the change stream continuously. PostgreSQL 15 row filters let you publish only the keyspace that is moving:
-- Source shard (PostgreSQL 15+): publish only the migrating keyspace
ALTER TABLE orders REPLICA IDENTITY FULL; -- or ensure a PK covers updates/deletes
CREATE PUBLICATION pub_move_eu FOR TABLE orders WHERE (region = 'eu');
-- Target shard: subscribe; copy_data performs the backfill automatically
CREATE SUBSCRIPTION sub_move_eu
CONNECTION 'host=pg-shard-2.internal dbname=commerce user=replicator'
PUBLICATION pub_move_eu
WITH (copy_data = true, streaming = 'on');
On PostgreSQL 10β14 there are no row filters, so either replicate the whole table and delete the non-migrating rows on the target after cutover, or publish a partition child directly if the table is already partitioned by the moving dimension. The complete split procedure, including slot management and multi-target fan-out, is the subject of the Zero-Downtime Resharding Workflows sub-guide.
Trigger-based dual-write
For same-instance moves β classically, migrating a monolithic table into a new partitioned table β a trigger mirrors every write while a batch job backfills history:
-- New partitioned target lives alongside the old table
CREATE OR REPLACE FUNCTION mirror_orders_write() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO orders_partitioned VALUES (NEW.*)
ON CONFLICT (order_id, placed_at) DO NOTHING;
ELSIF TG_OP = 'UPDATE' THEN
UPDATE orders_partitioned SET (status, total_usd, updated_at) =
(NEW.status, NEW.total_usd, NEW.updated_at)
WHERE order_id = NEW.order_id AND placed_at = NEW.placed_at;
ELSIF TG_OP = 'DELETE' THEN
DELETE FROM orders_partitioned
WHERE order_id = OLD.order_id AND placed_at = OLD.placed_at;
END IF;
RETURN NULL;
END $$;
CREATE TRIGGER trg_mirror_orders
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION mirror_orders_write();
The ON CONFLICT DO NOTHING on insert makes the trigger idempotent against the backfill: whichever writes a given row first wins, and the other becomes a no-op. That idempotence is what lets the trigger and the batch copy run concurrently without coordination.
gh-ost / pt-online-schema-change
On MySQL, ALTER TABLE ... PARTITION BY rebuilds the table under a lock, so online tooling is mandatory at scale. gh-ost creates a ghost copy with the new layout, backfills it in chunks, and tails the binlog to stay current:
gh-ost \
--host=mysql-shard-1.internal --database=commerce --table=orders \
--alter="PARTITION BY RANGE COLUMNS (placed_at) (
PARTITION p2026q1 VALUES LESS THAN ('2026-04-01'),
PARTITION p2026q2 VALUES LESS THAN ('2026-07-01'),
PARTITION pmax VALUES LESS THAN (MAXVALUE))" \
--chunk-size=1000 \
--max-load="Threads_running=25" \
--critical-load="Threads_running=64" \
--throttle-control-replicas="mysql-shard-1-replica.internal" \
--max-lag-millis=1500 \
--postpone-cut-over-flag-file=/tmp/orders.postpone \
--execute
The --postpone-cut-over-flag-file is the operational safety valve: gh-ost finishes the copy and then idles, applying binlog events, until you delete the flag file β so the atomic table swap happens on your schedule, not the toolβs.
Dump-and-load
pg_dump | pg_restore (or mysqldump) remains legitimate when the moving keyspace is small enough that a write freeze of a few minutes is acceptable, and it is the standard way to seed a target before switching to replication for the delta:
pg_dump -h pg-shard-2.internal -d commerce \
--table=orders --data-only -Fc \
| pg_restore -h pg-shard-5.internal -d commerce \
--data-only --jobs=4 --no-owner
Never use dump-and-load alone for anything above ~50 GB or any keyspace with continuous writes; the freeze window grows linearly and the rollback story is poor.
Routing During the Migration Window
While data lives in two places, the routing layer is the component that decides correctness. The migration state machine belongs next to the shard map β the same structure your hash routing algorithms already consult β so every application instance sees a consistent phase.
# Migration-aware routing: the shard map carries per-keyspace migration state
PHASES = ("dual_write", "backfill", "verify", "cutover", "cleanup")
class MigrationAwareRouter:
def __init__(self, shard_map, migration_registry):
self.shard_map = shard_map # key -> shard resolution
self.migrations = migration_registry # shard/keyspace -> Migration
def route_write(self, key):
source = self.shard_map.resolve(key)
m = self.migrations.lookup(source, key)
if m is None:
return [source]
if m.phase in ("dual_write", "backfill", "verify"):
return [source, m.target] # source is authoritative; target mirrored
if m.phase in ("cutover", "cleanup"):
return [m.target] # target is now authoritative
return [source]
def route_read(self, key):
source = self.shard_map.resolve(key)
m = self.migrations.lookup(source, key)
if m and m.phase in ("cutover", "cleanup"):
return m.target
return source # reads stay on source until cutover
Three rules keep this correct:
- The source stays authoritative until cutover. Reads never touch the target during dual-write, because the backfill has not finished and the target is known-incomplete. Serving reads from both sides βfor testingβ is how phantom missing-row bugs enter production.
- Write the source first, mirror second. If the mirrored write fails, enqueue it for replay and alert β do not fail the user request, and do not silently drop it. A dead-letter queue of failed mirror writes is a first-class migration artifact.
- Phase transitions are one-way and centrally stored. Keep the phase in a
shard_maptable or an etcd key with a version number; application instances must refuse to act on a phase older than one they have already seen.
Note that during a shard split, queries that previously hit one shard may temporarily fan out across two. If your workload includes multi-key reads, review Cross-Partition Querying & Aggregation Strategies before the split β scatter-gather behaviour during the mixed window is where most latency regressions surface.
Operational Configuration
Migrations fail operationally more often than logically: a replication slot fills a disk, an unthrottled backfill starves OLTP traffic, or the target applies changes slower than the source produces them. Configure both sides before opening the stream.
# postgresql.conf β source shard
wal_level = logical
max_wal_senders = 10
max_replication_slots = 10
max_slot_wal_keep_size = 200GB # hard cap: a stalled subscriber cannot fill the disk
wal_sender_timeout = 60s
# postgresql.conf β target shard
max_logical_replication_workers = 8
max_sync_workers_per_subscription = 2 # parallel initial-copy workers
maintenance_work_mem = 2GB # faster index builds after the copy
autovacuum_vacuum_scale_factor = 0.02 # keep up with backfill churn
max_slot_wal_keep_size deserves emphasis. A replication slot pins WAL on the source until the subscriber consumes it; if the target stalls for a weekend, an uncapped slot will fill the sourceβs disk and take down the healthy shard. The cap sacrifices the migration (the slot is invalidated) to protect production β the correct trade in every case.
Encode the backfill policy as configuration rather than constants in a script, so the throttle can be tuned mid-flight without redeploying:
# migration-job.yaml β declarative migration run book
migration:
id: move-eu-orders-to-shard5
source: pg-shard-2.internal
target: pg-shard-5.internal
table: orders
keyspace: "region = 'eu'"
backfill:
batch_rows: 5000 # rows per INSERT ... SELECT batch
pause_ms: 50 # sleep between batches
max_replica_lag_seconds: 10 # auto-pause if source replicas fall behind
max_source_cpu_pct: 60 # auto-pause above this
verify:
checksum_buckets: 64
recheck_recent_hours: 24 # continuously re-verify the hot tail
cutover:
max_write_pause_ms: 3000 # abort cutover if the pause would exceed this
rollback_window_hours: 48 # reverse replication retained this long
The two auto-pause conditions matter most. Backfill traffic is the lowest-priority work in the system; it should yield to OLTP latency and to replica lag automatically, not when a human notices a dashboard.
Monitoring & Observability
A migration in flight needs its own dashboard. Wire these signals into the same alerting stack described in Partition Monitoring & Failover Automation before phase 1 begins, because the failure modes β slot bloat, apply lag, silent drift β all develop while everything looks fine.
Catalog queries
Initial copy progress, per table, on the target:
-- Subscription sync state: i = initializing, d = copying data,
-- f = finished copy, s = synchronized, r = ready (streaming)
SELECT srrelid::regclass AS table_name, srsubstate AS state
FROM pg_subscription_rel
ORDER BY srsubstate;
Slot retention on the source β the disk-full early warning:
SELECT slot_name,
active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn))
AS retained_wal
FROM pg_replication_slots
WHERE slot_name LIKE 'sub_move%';
Apply lag on the target, in bytes and seconds:
SELECT subname,
pg_size_pretty(pg_wal_lsn_diff(latest_end_lsn, received_lsn)) AS recv_gap,
extract(epoch FROM now() - latest_end_time) AS apply_lag_s
FROM pg_stat_subscription
WHERE subname = 'sub_move_eu';
PromQL alerts
# Replication slot retaining more than 50 GB of WAL on the source
max by (slot_name) (pg_replication_slot_retained_wal_bytes) > 50e9
# Subscription apply lag above 60 s for 10 minutes
max by (subname) (pg_subscription_apply_lag_seconds) > 60
# Backfill starving OLTP: p99 write latency on the source doubles vs 1 h ago
histogram_quantile(0.99, sum by (le) (rate(pg_query_duration_seconds_bucket{shard="pg-shard-2",op="write"}[5m])))
> 2 * histogram_quantile(0.99, sum by (le) (rate(pg_query_duration_seconds_bucket{shard="pg-shard-2",op="write"}[5m] offset 1h)))
# Dead-letter queue of failed mirror writes is non-empty
sum(migration_mirror_dlq_depth{migration="move-eu-orders-to-shard5"}) > 0
Set the paging thresholds below the hard limits: page at 50 GB retained WAL when max_slot_wal_keep_size is 200 GB, and at 60 s apply lag when your cutover requires lag β 0. The gap between the page and the ceiling is your time to react. The mirror-write dead-letter alert is non-negotiable β a single unreplayed mirror write is a parity violation that checksums will catch hours later at much higher diagnostic cost.
The Zero-Downtime Migration Workflow
The following numbered workflow moves the region = 'eu' keyspace from pg-shard-2 to a new pg-shard-5 using logical replication. It is the canonical shape; the sub-guides linked above adapt it for splitting hot shards and for same-instance repartitioning.
- Provision the target and copy the schema. Data replication does not carry DDL, so the schema (including indexes, constraints, and roles) is copied explicitly, and DDL is frozen on the source for the duration:
pg_dump -h pg-shard-2.internal -d commerce \
--schema-only --table=orders \
| psql -h pg-shard-5.internal -d commerce
# Declare the DDL freeze where your team will see it
echo "DDL FREEZE: orders (move-eu-orders-to-shard5) until cutover" \
>> /etc/motd
-
Open the publication and subscription (the
CREATE PUBLICATION/CREATE SUBSCRIPTIONpair from the taxonomy section above). The subscriptionβscopy_data = truestarts the backfill immediately usingmax_sync_workers_per_subscriptionparallel workers. -
Watch the backfill and let the throttle work. Poll sync state until every table reports
r, and keep an eye on source impact:
while true; do
state=$(psql -h pg-shard-5.internal -d commerce -Atc \
"SELECT string_agg(DISTINCT srsubstate, ',') FROM pg_subscription_rel")
echo "$(date -Is) sync_state=${state}"
[ "$state" = "r" ] && break
sleep 60
done
- Synchronise sequences. Logical replication never advances sequences on the target. Set them past the sourceβs current values with a safety margin:
-- On the target, after the copy reaches streaming state
SELECT setval('orders_order_id_seq',
(SELECT max(order_id) FROM orders) + 100000);
The + 100000 offset absorbs writes that land on the source between this statement and cutover. Gaps in a sequence are free; collisions are an outage.
- Verify parity with bucketed checksums. Row counts catch missing data but not corrupted or stale rows. Hash-bucket digests catch both and parallelise well:
-- Run identically on source and target; diff the outputs
SELECT mod(abs(hashtext(order_id::text)), 64) AS bucket,
count(*) AS rows,
md5(string_agg(md5(o::text), '' ORDER BY order_id)) AS digest
FROM orders o
WHERE region = 'eu'
GROUP BY 1
ORDER BY 1;
psql -h pg-shard-2.internal -d commerce -Atc "$CHECKSUM_SQL" > /tmp/src.sum
psql -h pg-shard-5.internal -d commerce -Atc "$CHECKSUM_SQL" > /tmp/dst.sum
diff /tmp/src.sum /tmp/dst.sum && echo "PARITY OK"
A mismatched bucket confines the investigation to 1/64th of the keyspace. Expect transient mismatches in buckets containing rows written in the last few seconds; a bucket that stays mismatched across two runs ten minutes apart is real drift. Re-verify the recent-data window continuously until cutover.
- Cut over with a bounded write pause. Pause the pooler, wait for the target to confirm it has applied everything, flip the routing map, resume. The pause is typically one to three seconds:
# 1. Pause new transactions to the moving keyspace
psql -h pgbouncer.internal -p 6432 -U admin pgbouncer -c "PAUSE commerce_shard2;"
# 2. Wait for the target to reach LSN parity with the source
SRC_LSN=$(psql -h pg-shard-2.internal -d commerce -Atc \
"SELECT pg_current_wal_lsn()")
until psql -h pg-shard-5.internal -d commerce -Atc \
"SELECT received_lsn >= '${SRC_LSN}'::pg_lsn FROM pg_stat_subscription
WHERE subname='sub_move_eu'" | grep -q t; do sleep 0.2; done
# 3. Flip the authoritative routing entry (versioned, single row)
psql -h pg-meta.internal -d topology -c \
"UPDATE shard_map SET shard='pg-shard-5', version=version+1
WHERE keyspace='eu';"
# 4. Resume traffic β now routed to the target
psql -h pgbouncer.internal -p 6432 -U admin pgbouncer -c "RESUME commerce_shard2;"
If step 2 does not converge within max_write_pause_ms, resume immediately and abort the cutover attempt β the migration stays in verify phase and nothing is lost. A cutover you can abandon cheaply is a cutover you can attempt calmly.
- Establish reverse replication for the rollback window. Immediately after cutover, publish the keyspace from the target back to the source, so a rollback is a routing flip rather than a data recovery:
-- On pg-shard-5 (now authoritative)
CREATE PUBLICATION pub_rollback_eu FOR TABLE orders WHERE (region = 'eu');
-- On pg-shard-2: subscribe with copy_data = false β history already matches
CREATE SUBSCRIPTION sub_rollback_eu
CONNECTION 'host=pg-shard-5.internal dbname=commerce user=replicator'
PUBLICATION pub_rollback_eu
WITH (copy_data = false);
- Clean up after the soak period. Once the rollback window (48 hours in the run book above) passes with clean error budgets, tear down in strict order β replication first, then data:
-- On the target: drop the forward subscription (drops its slot on the source)
DROP SUBSCRIPTION sub_move_eu;
-- On the source: drop reverse replication, then delete migrated rows in batches
DROP SUBSCRIPTION sub_rollback_eu;
DELETE FROM orders WHERE region = 'eu'
AND ctid IN (SELECT ctid FROM orders WHERE region = 'eu' LIMIT 10000);
-- repeat the batched DELETE until 0 rows, then reclaim space
VACUUM (ANALYZE) orders;
Rollback planning
Write the rollback procedure before phase 1, and rehearse it once on staging. It has exactly three moves: flip the shard_map entry back to the source (reverse replication has kept it current), re-enable the forward mirror if the migration will be retried, and file the drift report from whatever checksum bucket triggered the rollback. Define the rollback triggers numerically in advance β for example, any parity mismatch after cutover, target p99 latency above 1.5Γ the source baseline for 15 minutes, or the dead-letter queue exceeding zero β so the on-call engineer executes a decision that was already made rather than debating one at 03:00.
Rebalancing After Topology Changes
Splitting one hot shard is a scalpel; rebalancing after adding nodes is a conveyor belt β many small keyspace moves executed with the same five-phase pipeline, one keyspace at a time. With consistent hashing, adding one node to an N-node ring relocates roughly 1/(N+1) of all keys, and virtual nodes ensure the donated keys come evenly from every existing shard rather than one unlucky neighbour. The mechanics β computing the affected token ranges, sequencing the moves so no shard is simultaneously donor and recipient, and pacing the pipeline against replication headroom β are covered in depth in the Shard Rebalancing & Data Movement sub-guide.
Two rules from that guide are worth stating here because violating them is so common. First, never change the ring and the data at the same time: the new topology is published in a βpendingβ state that routing ignores, data moves under the migration pipeline, and only verified keyspaces flip to the new owner. Second, rebalance serially or with a small bounded concurrency (two to three moves in flight), because each move consumes WAL bandwidth, replication slots, and backfill I/O on both its donor and recipient β a fully parallel rebalance is indistinguishable from an incident.
Common Mistakes
| Mistake | Root cause | Mitigation |
|---|---|---|
| Unthrottled backfill starves OLTP traffic | Copy runs at full speed against the busiest shard in the fleet | Batch with sleeps; auto-pause on source CPU or replica lag; treat backfill as the lowest-priority workload |
| Sequences and DDL assumed to replicate | Logical replication streams row changes only | Freeze DDL for the window; setval sequences at cutover with a safety offset |
| Source data dropped right after cutover | Migration declared done when routing flips | Keep reverse replication and source rows for a fixed soak window (24β72 h); cleanup is phase 5, not phase 4 |
| Verification by row count only | Counts match even when rows are stale or corrupted | Bucketed hash digests on both sides; continuous re-verification of the hot tail during dual-write |
| Replication slot left behind by an aborted attempt | Slot pins WAL forever whether or not anyone is consuming it | Set max_slot_wal_keep_size; alert on retained WAL; make slot cleanup part of the abort procedure |
FAQ
How long should the dual-write window last?
As short as the backfill and verification allow β hours to a few days, never weeks. Every hour of dual-write is an hour in which a partial failure (a dropped mirror write, a disabled trigger, a paused subscription) can create divergence between source and target, and the verification burden grows with the window. Size the backfill throttle so the window closes within one on-call rotation, and set a hard calendar deadline: if the migration has not reached cutover by then, roll back, fix whatever slowed it, and restart cleanly rather than letting a half-migrated state become the status quo.
Does PostgreSQL logical replication copy DDL and sequences?
No. Logical replication streams row-level changes only. An ALTER TABLE on the source is not applied to the target β the subscription will start erroring if the schemas diverge β and sequences never advance on the target regardless of how many rows arrive. Operationally this means two rules: freeze DDL on the migrating table for the whole window, and copy sequence positions explicitly at cutover with setval, adding a generous offset (the workflow above uses 100,000) so writes that land during the final seconds cannot collide.
Do writes have to stop during cutover?
A brief, bounded pause is the safest pattern and the one this guide recommends: PAUSE the pooler, wait for the target to confirm it has applied the sourceβs final LSN, flip the routing map, RESUME. Done correctly this is one to three seconds β invisible behind normal retry logic. Fully pauseless cutover is achievable with idempotent dual-writes and conflict-free key generation, but it moves the correctness burden into application code paths that are exercised exactly once. Most teams should spend their complexity budget on rollback readiness instead of eliminating a two-second pause.
How much data moves when I add a node to a consistent-hash ring?
With consistent hashing, approximately 1/(N+1) of the total keyspace, where N is the node count before the addition β growing from 4 to 5 nodes relocates about 20 % of rows, drawn evenly from all existing shards when virtual nodes are configured. With naive modulo routing (hash(key) % N), changing N remaps nearly every key, which is effectively a full-fleet migration. This asymmetry is the main operational argument for consistent hashing in any topology expected to grow.
How do I verify parity on a table too large for a full checksum?
Bucket the table by a hash of the primary key β 64 buckets is a practical default β and compute an ordered aggregate digest per bucket on both sides. The bucket queries parallelise cleanly, a mismatch confines the investigation to 1/64th of the keyspace, and the technique works identically on PostgreSQL (hashtext + md5(string_agg(...))) and MySQL (CRC32 + grouped concatenation). During the dual-write window, continuously re-verify only the buckets touched by recent writes; run one full pass when replication lag reaches zero, immediately before cutover.
Related
- Zero-Downtime Resharding Workflows β splitting and merging live shards with logical replication, slot management, and multi-target fan-out
- Online Table Repartitioning β converting monolithic tables to partitioned layouts while they serve traffic
- Shard Rebalancing & Data Movement β sequencing keyspace moves after topology changes and pacing them against replication headroom
- Partition Monitoring & Failover Automation β the alerting stack that watches slot retention, apply lag, and skew before and after every move
- Hash Routing Algorithms β consistent hashing and virtual nodes, the routing substrate that bounds rebalancing cost