How to migrate a MySQL database between servers without data loss
Migrate a MySQL database between Linux servers using mysqldump, secure transfer and integrity verification — no uncontrolled downtime and no lost records.
Migrating a MySQL database between servers looks trivial until the first attempt in production. A mysqldump run at the wrong time can capture half of a transaction, leave data inconsistent or lock the database for minutes. Worse: in a rush, it is common to forget triggers, stored procedures, events or permissions — and only find out when the application breaks on the new server.
This tutorial is for sysadmins and developers moving a MySQL database between two Linux servers — whether for a hardware upgrade, provider change or infrastructure consolidation. We cover the classic path (dump + transfer + restore) and when it pays off to move to asynchronous replication to eliminate downtime almost entirely.
Estimated execution time: 30-60 minutes for databases up to 10 GB using a traditional dump. Above that, plan a larger window or consider replication. The entire procedure is executed over SSH; assume you have root or sudo access on both servers.
Prerequisites
SSH access with sudo on both servers (source and destination), MySQL or MariaDB installed on both, free space on the destination equivalent to at least 2× the current database size, and firewall open between the hosts (port 3306 if you will use replication, port 22 for transfer via SCP).
Confirm the versions before proceeding. A dump from a newer version restored on an older version rarely works — syntax and data types diverge.
8.0.36 8.0.36 or higher >= 2x database size 22 Check the exact version on each side with mysql --version before continuing. For databases larger than 50 GB, read the section on asynchronous replication at the end first — a traditional dump becomes impractical at that volume.
Preparation on the source server
Before taking the dump, it is crucial to understand what needs to be migrated beyond the tables. Real databases have users, permissions, scheduled events, procedures and triggers that live outside the .ibd files of the target database.
List the databases and measure the actual size:
mysql -uroot -p -e "SELECT table_schema AS db, ROUND(SUM(data_length + index_length)/1024/1024, 2) AS size_mb FROM information_schema.tables GROUP BY table_schema;"Note the size of the database you will migrate — you need this to size the space on the destination and estimate dump time (~1-2 minutes per GB on fast disk).
Check which engines are in use:
mysql -uroot -p -e "SELECT table_schema, engine, COUNT(*) FROM information_schema.tables WHERE table_schema = 'mydatabase' GROUP BY table_schema, engine;"If MyISAM appears in the result, consider converting to InnoDB before migrating. MyISAM does not support --single-transaction, forcing the dump to use a table lock that blocks writes.
Export users and permissions to a separate file:
mysql -uroot -p -e "SELECT CONCAT('SHOW GRANTS FOR ''', user, '''@''', host, ''';') FROM mysql.user WHERE user NOT IN ('mysql.sys','mysql.session','mysql.infoschema','root');" -s --skip-column-names | mysql -uroot -p -s --skip-column-names | sed 's/$/;/' > grants.sqlThe standard mysqldump does not include users — they live in mysql.user, outside the application database. Without this file, you restore the data but application connections fail with “Access denied”.
Generating the consistent dump
This is the most sensitive step. A poorly made dump captures partially updated data and generates inconsistencies that remain invisible until the application breaks with invalid foreign keys.
Generate the dump with consistency flags:
mysqldump -uroot -p \
--single-transaction \
--routines \
--triggers \
--events \
--master-data=2 \
--hex-blob \
--default-character-set=utf8mb4 \
mydatabase > mydatabase_$(date +%Y%m%d_%H%M%S).sqlEach flag covers a specific case: --single-transaction ensures a consistent snapshot without locks; --routines --triggers --events include procedures, triggers and scheduled events; --master-data=2 records the binlog position as a comment (useful for later replication); --hex-blob prevents BLOB field corruption during transfer.
Compress the dump to speed up transfer:
gzip -9 mydatabase_*.sqlgzip compression typically reduces SQL dumps by 70-85%. On large databases, this turns a 40-minute transfer into 8 minutes — worth the CPU time.
Without this flag, the dump captures each table at a different moment. If the application is writing, you end up with an order in the orders table that references a non-existent product in products. The restore accepts it, but the application breaks when trying to load the order.
Transferring the dump to the destination server
For dumps up to 10 GB, SCP is simple and secure. Above that, consider rsync with resume or parallel chunked transfer.
Transfer via SCP from the source server:
scp mydatabase_*.sql.gz [email protected]:/tmp/If the connection is unstable or the file exceeds 5 GB, prefer rsync --partial --progress — it resumes from where it stopped if it drops midway.
On the destination server, validate the checksum:
# On the source:
sha256sum mydatabase_*.sql.gz
# On the destination:
sha256sum /tmp/mydatabase_*.sql.gzCompare the hashes. A difference means corruption during transfer — repeat the SCP/rsync before restoring. Restoring a corrupted dump generates random errors midway and forces you to redo everything.
Restore on the destination server
Before pouring the SQL, prepare the environment: confirm charset, adjust buffers and disable checks that slow down the restore.
Create the empty database with the correct charset:
mysql -uroot -p -e "CREATE DATABASE mydatabase CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"The wrong charset is a classic source of ”?????” instead of accented characters. If the source uses utf8mb4, the destination must use utf8mb4 — not utf8 (which is 3 bytes and breaks emoji).
Restore the dump with performance flags:
gunzip < /tmp/mydatabase_*.sql.gz | mysql -uroot -p \
--max_allowed_packet=512M \
--init-command="SET autocommit=0; SET unique_checks=0; SET foreign_key_checks=0;" \
mydatabaseDisabling unique_checks and foreign_key_checks during the restore speeds it up by 3-5×. The dump is internally consistent, so there is no risk. The checks return to default on the next connection.
Restore users and permissions:
mysql -uroot -p < grants.sql
mysql -uroot -p -e "FLUSH PRIVILEGES;"FLUSH PRIVILEGES forces MySQL to reread the permissions table. Without it, some clients continue receiving “Access denied” even with the grants already inserted.
Integrity verification
A restore without errors does not mean it is complete. Always validate counts and structure before pointing the application.
Compare row counts table by table:
# On the source:
mysql -uroot -p -e "SELECT table_name, table_rows FROM information_schema.tables WHERE table_schema='mydatabase' ORDER BY table_name;" > source_counts.txt
# On the destination:
mysql -uroot -p -e "SELECT table_name, table_rows FROM information_schema.tables WHERE table_schema='mydatabase' ORDER BY table_name;" > destination_counts.txt
diff source_counts.txt destination_counts.txttable_rows in information_schema is an estimate for InnoDB — it can diverge slightly. For exact counts on critical tables, run SELECT COUNT(*) FROM table on both sides.
Verify auxiliary objects:
mysql -uroot -p mydatabase -e "SHOW TRIGGERS\G SHOW PROCEDURE STATUS WHERE Db='mydatabase'\G SHOW EVENTS\G"Triggers, procedures and events only come in the dump if you used the --triggers --routines --events flags. If any are missing, reinsert them manually via a specific dump (mysqldump --no-data --no-create-info --triggers ...).
For databases above 50 GB or applications that cannot tolerate a downtime window, configure the destination as a replica of the source via CHANGE MASTER TO ... MASTER_LOG_FILE=... MASTER_LOG_POS=... using the position recorded by --master-data=2. When Seconds_Behind_Master reaches zero, you redirect the application and promote the replica. Real downtime is reduced to seconds.
Troubleshooting
Error “MySQL server has gone away” during the restore
The dump has statements larger than the max_allowed_packet configured on the destination. Temporarily increase it to 512M or 1G via --max_allowed_packet in the command or in /etc/mysql/my.cnf before trying again.
Charset comes back as “latin1” even with the database in utf8mb4
The MySQL client on the destination is connecting with a charset different from the database. Force it with --default-character-set=utf8mb4 in the restore command. Confirm afterwards with SHOW VARIABLES LIKE 'character_set%'.
Broken foreign keys after the restore
This happened if you did not use --single-transaction in the dump or if you mixed InnoDB and MyISAM tables. Check via SHOW ENGINE INNODB STATUS and consider redoing the dump with the correct flag — fixing it manually is time-consuming and error-prone.
Next steps
With the migration complete, consider automating recurring backups on the new server to avoid having to improvise when you need another migration or emergency restore. Document the innodb_buffer_pool_size configuration and tune it to reflect the available RAM on the new server — keeping the default of 128 MB on a server with 16 GB of RAM wastes performance.
If you are consolidating databases in production, a Hostini VPS with NVMe SSD offers enough IOPS for MySQL under load without requiring aggressive cache tuning. Consistent latency also helps in scenarios of asynchronous replication between regions.
Next topics to dive deeper: MySQL master-replica replication setup, innodb_buffer_pool_size tuning based on real workload, incremental backups with Percona XtraBackup, and slow query monitoring via the slow log.
Frequently asked questions
Can I migrate MySQL between different versions (e.g., 5.7 to 8.0)?
Yes, but only upgrades (5.7 → 8.0), never downgrades. Use mysqldump on the source server and restore on the destination — the SQL format is compatible. After the restore, run mysql_upgrade on the 5.7→8.0 destination to update system tables. Test authentication: MySQL 8 uses caching_sha2_password by default, which can break older drivers.
What is the difference between mysqldump and mysqlpump?
mysqldump is single-threaded and has been stable since older versions. mysqlpump is multi-threaded and faster on large databases, but has limitations with triggers and routines in some versions. For critical migrations up to 50 GB, mysqldump with --single-transaction is more predictable. Above that, consider Percona XtraBackup to avoid read locks.
How can I migrate with zero downtime?
Configure the destination server as a replica of the source using asynchronous replication. Once the destination catches up with the source (Seconds_Behind_Master = 0), you perform the switchover by redirecting the application to the new server and promoting the replica to primary. The actual downtime window is reduced to seconds — only the time it takes to switch the endpoint.
Does mysqldump lock the database during the dump?
With --single-transaction on InnoDB tables, it does not lock — it uses MVCC to read a consistent snapshot without blocking writes. For MyISAM, the dump uses LOCK TABLES and blocks writes during the read. Mixing InnoDB and MyISAM in the same dump compromises consistency: migrate the schema to InnoDB before dumping.
Why is the .sql file size different from the actual database size?
The dump is plain SQL text, without materialized indexes or binary pages. It is usually 30-60% of the size of the .ibd files. After the restore, the on-disk size returns to normal because MySQL rebuilds indexes and statistics. Do not confuse dump size with actual database size.
How do I verify that no rows were lost during the migration?
Count rows table by table on both servers with SELECT COUNT(*) and compare. For deeper integrity, generate checksums with pt-table-checksum (from Percona Toolkit) or compare CHECKSUM TABLE for each table. If counts match but you suspect truncated data, also compare SUM() of critical numeric columns.