Repository navigation
ALTER TABLE on a parent table deletes child rows through ON DELETE CASCADE #528
Description
Reproduction
CREATE TABLE parent (id INT PRIMARY KEY);
CREATE TABLE child (id INT, pid INT, FOREIGN KEY (pid) REFERENCES parent (id) ON DELETE CASCADE);
INSERT INTO parent VALUES (1), (2);
INSERT INTO child VALUES (10, 1), (20, 2);
ALTER TABLE parent ADD COLUMN name TEXT;
SELECT COUNT(*) FROM child;
-- 0 (MySQL: 2)Reproduced on trunk (3.1.0) with PHP 8.3 and SQLite 3.45.1, with stringified fetches on, as in WordPress. It also happens when the ALTER TABLE runs inside START TRANSACTION ... COMMIT. With ON DELETE SET NULL, the child rows lose their references instead.
Cause
ALTER TABLE recreates the table: it creates a new table, copies the rows, drops the old table, and renames the new one. Before that, it runs PRAGMA foreign_keys = OFF, as the SQLite procedure requires. But the statement runs inside the driver's wrapper transaction, and PRAGMA foreign_keys is a no-op inside a transaction. Foreign keys stay on, so DROP TABLE performs an implicit DELETE of the old table, which fires the foreign key actions of the child tables.
Related
OPTIMIZE TABLE and REPAIR TABLE use the same rebuild path and are affected by the same foreign-key handling. With a populated child table and no ON DELETE clause, both commands fail with FOREIGN KEY constraint failed, whether called on their own or inside START TRANSACTION.
#549 documents that reproduction. PR #567 adds per-table rollback so failed rebuilds leave no partial changes. Foreign-key handling and MySQL-compatible implicit commits for these commands remain in scope here.
The same implicit DELETE affects two statements that MySQL rejects for a referenced parent table:
DROP TABLE parentdeletes the child rows. MySQL fails with error 3730 (ER_FK_CANNOT_DROP_PARENT).TRUNCATE TABLE parentdeletes the child rows. MySQL fails with error 1701 (ER_TRUNCATE_ILLEGAL_FK).
A related transaction difference: MySQL implicitly commits an open transaction before and after DDL statements, but the driver runs them inside the user transaction, so a ROLLBACK undoes them together with the earlier work:
START TRANSACTION;
INSERT INTO t VALUES (1);
CREATE TABLE u (id INT);
ROLLBACK;
-- Driver: t is empty, and u doesn't exist.
-- MySQL: t has the row, and u exists.Fix direction
Foreign keys can only be disabled outside a transaction. Emulating the implicit commit for DDL would fix both problems: with no transaction open, the driver can disable foreign keys before the wrapper transaction begins and restore them after it ends, as the SQLite procedure intends. For DROP TABLE and TRUNCATE, the driver could reject a referenced parent table as MySQL does.
This changes transaction behavior, since DDL could no longer be rolled back, so I'd like your view on the approach first. I'm happy to contribute a PR in whichever direction you prefer.
Use of AI
This report was prepared with Claude Code.
Activity
Nandan108 commentedon Oct 13, 2026
The same cause has a second effect. When a populated child table references the parent without a cascading action, the
ALTER TABLEfails instead:This happens with
ON DELETE RESTRICT,ON DELETE NO ACTION, and with noON DELETEclause, so it affects every non-cascading foreign key. The implicitDELETEofDROP TABLEis rejected, the whole statement is rolled back, and no data is lost, but theALTER TABLEcan't succeed while the child table has rows. With an empty child table, it works.So for any parent table with referencing rows,
ALTER TABLEcurrently either deletes or nulls the child rows (CASCADE,SET NULL) or fails (all other actions). Disabling foreign keys outside the transaction, as proposed above, fixes both.Reproduced on
trunk(3.1.0) with PHP 8.3 and SQLite 3.45.1, and also seen on 3.0.2 in a real plugin upgrade.JanJakes commentedon Oct 20, 2026
Thanks for the detailed report. I agree with the direction. MySQL doesn't support transactional DDL, so the driver can do the same:
CREATE TEMPORARY TABLEandDROP TEMPORARY TABLEare the exceptions.ALTER TABLE,OPTIMIZE TABLE,REPAIR TABLE), disable foreign keys while no transaction is open.PRAGMA foreign_key_checkand fail on violations. Today its result is ignored.For
DROP TABLEandTRUNCATE TABLEof a referenced parent, rejecting them like MySQL sounds right, rather than disabling foreign keys.This also relates to #542, which needs to recreate existing tables when the driver is upgraded.
Recreate tables when repairing temporal zero defaults
Recreate tables when repairing temporal zero defaults
Recreate tables when repairing temporal zero defaults
Recreate tables when repairing temporal zero defaults
Nandan108 commentedon Oct 23, 2026
Thanks, the plan sounds good to me. One point on step 4, checking
PRAGMA foreign_key_checkbefore committing:I'd fail only on violations that the
ALTER TABLEintroduced, not on ones that were already there.PRAGMA foreign_key_checklists every violating row in the database, so a single orphan row that predates the statement would make every laterALTER TABLEon that table fail, with no way forward except repairing the data by hand:Orphans like this are easy to end up with, for example from rows written while
foreign_keyswas off, or by a driver version that didn't enforce foreign keys. MySQL doesn't re-validate existing rows on an unrelatedALTER TABLEeither. I tested this on MySQL 8.0.46 and MariaDB 11.8.6, with the same results: a child table with an orphan row (inserted withforeign_key_checks=0) and oneON DELETE RESTRICTand oneON DELETE CASCADEtable referencing a parent. Withforeign_key_checks=1and the orphan present, these all succeed:ALTER TABLE parent ADD COLUMN ...ALTER TABLE parent MODIFY ..., ALGORITHM=COPY(the parent is fully rebuilt)ALTER TABLE child ADD COLUMN ...ALTER TABLE child MODIFY ..., ALGORITHM=COPY(the child is fully rebuilt)Afterwards, the orphan is still there, and the rows of the cascading table are intact, so rebuilding the parent fired no
ON DELETEaction. OnlyALTER TABLE child ADD CONSTRAINT ... FOREIGN KEYover the orphan fails, with error 1452.So failing on pre-existing violations would be stricter than MySQL: a plugin update that alters one of its tables would start failing on sites where it didn't fail before.
A simple way is to run
PRAGMA foreign_key_checkbefore and after the rebuild and fail only when the second result has rows that the first didn't. The SQLite documentation's intent for this step is the same: to "verify that the schema change did not break any foreign key constraints".Repair temporal zero defaults with ALTER TABLE
Repair temporal zero defaults with ALTER TABLE