How to Migrate a PHP App from SQLite to MySQL
Create the MySQL schema by porting your SQLite tables (types, auto-increment, indexed TEXT columns), translate the handful of SQLite-only statements such as INSERT OR IGNORE and datetime('now'), copy the rows table by table with a PDO script, check the counts, then switch the connection and keep the SQLite file as your rollback.
Check you need to move first
SQLite serves a lot of single-server apps well, and moving costs you a separate service to run, patch and back up. Good reasons to switch are measured ones: lock waits that remain after you shorten write transactions, a second app server that must write the same data, or a host that gives you managed MySQL with failover. Our SQLite in production guide lists the checks to run before deciding.
If you do need to move, plan it as a short, rehearsed job rather than a live experiment. Run the whole thing against a copy first.
Why a straight dump doesn't import
The long-running Stack Overflow question "Quick easy way to migrate SQLite3 to MySQL?" is mostly answers explaining why sqlite3 app.db .dump | mysql fails. SQLite's dump contains statements MySQL rejects, such as BEGIN TRANSACTION, PRAGMA lines and double-quoted identifiers, and its loose typing lets a column declared INTEGER hold text. Sed scripts can patch the syntax, but they don't fix the types, and the result needs checking line by line. For an app you own, porting the schema deliberately and copying the data with a script is more reliable, and you can repeat it.
Step 1: port the schema
INTEGER PRIMARY KEY AUTOINCREMENTbecomesBIGINT AUTO_INCREMENT PRIMARY KEY.- Any
TEXTcolumn that is unique or indexed (emails, token hashes, external IDs) must becomeVARCHAR(n); MySQL can't index unboundedTEXTwithout a prefix length. - Timestamps stored as
Y-m-d H:i:sUTC strings map cleanly toDATETIME. - Create every table with
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci, so foreign keys work and emoji don't break inserts. - Keep every index. Check foreign keys point at InnoDB tables, and decide whether you want
ON DELETE CASCADEor deletes handled in code, as before.
Collation is the quiet change. SQLite compares text case-sensitively by default; utf8mb4_unicode_ci doesn't. A unique email column that held both Sam@x.com and sam@x.com in SQLite will reject the second row. Look for those duplicates before you copy.
Step 2: translate the SQLite-only SQL
Prepared statements that use plain SELECT, INSERT, UPDATE and DELETE usually work unchanged. Search your code for the few constructs that don't:
-- Ignore duplicates
INSERT OR IGNORE INTO t ... → INSERT IGNORE INTO t ...
-- Upsert
ON CONFLICT(key) DO UPDATE
SET n = excluded.n → ON DUPLICATE KEY UPDATE n = VALUES(n)
-- Current time (store UTC)
datetime('now') → UTC_TIMESTAMP()
datetime('now', '-1 day') → UTC_TIMESTAMP() - INTERVAL 1 DAY
-- Write lock
BEGIN IMMEDIATE → START TRANSACTION + SELECT ... FOR UPDATE
Also remove PRAGMA calls from the connection code, and connect with charset=utf8mb4 in the DSN and a strict sql_mode, so MySQL rejects bad data instead of silently truncating it.
Step 3: copy the data with PDO
$src = new PDO('sqlite:storage/app.db');
$dst = new PDO('mysql:host=127.0.0.1;dbname=app;charset=utf8mb4', $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$dst->exec('SET FOREIGN_KEY_CHECKS = 0');
foreach ($tables as $table) { // parents before children
$rows = $src->query("SELECT * FROM $table");
$dst->beginTransaction();
$insert = null;
foreach ($rows as $row) {
$row = array_filter($row, 'is_string', ARRAY_FILTER_USE_KEY);
$insert ??= $dst->prepare(sprintf(
'INSERT INTO %s (%s) VALUES (%s)', $table,
implode(', ', array_keys($row)),
implode(', ', array_fill(0, count($row), '?'))
));
$insert->execute(array_values($row));
}
$dst->commit();
}
$dst->exec('SET FOREIGN_KEY_CHECKS = 1');
Name columns explicitly rather than relying on order, as one developer who moved a Doctrine app found: SQLite's column order can differ from the order your code assumes. Copy IDs as they are so foreign keys still line up, then set each table's AUTO_INCREMENT above its highest ID. Run the copy from a backup of the SQLite file, not the live one.
Step 4: verify, switch and keep a way back
- Compare row counts for every table, and spot-check a few accounts, payments and anything with money or credits.
- Run your test suite and click through sign-in, a payment and an admin action against MySQL on a staging copy.
- For the real move, stop writes (maintenance mode or stop PHP-FPM for a minute), take a final SQLite backup, run the copy, switch the connection setting, and start again.
- Keep the final SQLite file untouched for a few days. If something is wrong, switch the setting back.
- Change your backups: a file copy no longer works, so schedule
mysqldump --single-transactionor your provider's snapshots, and test a restore.
Kanboard's forum thread on the same move shows the common snags in practice: dump syntax, character sets and getting the schema to match what the app expects.
Sources: Stack Overflow: Quick easy way to migrate SQLite3 to MySQL? · Kanboard forum: How to migrate SQLite to MySQL · Danny van Kooten: Migrating a Doctrine database from SQLite to MySQL · MySQL manual: INSERT ... ON DUPLICATE KEY UPDATE · SQLite: datatypes in SQLite
How Simple Stack handles it
Simple Stack starts on SQLite, and the MySQL path is already part of the code. Set DB_DRIVER=mysql and the app connects with prepared statements, utf8mb4 and strict mode. MYSQL.md is written so a coding agent can do the schema port and the dialect changes top to bottom.
- A built-in MySQL connection layer (DB_DRIVER=mysql) with utf8mb4 and STRICT mode
- MYSQL.md maps every SQLite type to its MySQL equivalent, including which columns must be VARCHAR
- It lists each SQLite-only statement in the app: five upserts and three date helpers
- Timestamps are stored as UTC Y-m-d H:i:s strings, so they move to DATETIME without conversion
- Automatic SQLite migrations are skipped on MySQL, so you load the schema once
- Child rows are deleted in code rather than by cascade, so behaviour stays the same
Questions people ask
Skip the plumbing. Build the product.
Sign-in, Stripe, emails, an admin and a CMS — already built and tested in plain PHP. £49, once, for unlimited projects.