Skip to content

ALTER TABLE on a parent table deletes child rows through ON DELETE CASCADE #528

Open

Description

@Nandan108

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

The same implicit DELETE affects two statements that MySQL rejects for a referenced parent table:

  • DROP TABLE parent deletes the child rows. MySQL fails with error 3730 (ER_FK_CANNOT_DROP_PARENT).
  • TRUNCATE TABLE parent deletes 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

  1. Nandan108 commented on Oct 13, 2026

    @Nandan108
    ContributorAuthor

    The same cause has a second effect. When a populated child table references the parent without a cascading action, the ALTER TABLE fails instead:

    CREATE TABLE parent (id INT PRIMARY KEY);
    CREATE TABLE child (id INT, pid INT, FOREIGN KEY (pid) REFERENCES parent (id));
    INSERT INTO parent VALUES (1), (2);
    INSERT INTO child VALUES (10, 1);
    
    ALTER TABLE parent ADD COLUMN name TEXT;
    -- SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed
    -- (MySQL: the column is added)

    This happens with ON DELETE RESTRICT, ON DELETE NO ACTION, and with no ON DELETE clause, so it affects every non-cascading foreign key. The implicit DELETE of DROP TABLE is rejected, the whole statement is rolled back, and no data is lost, but the ALTER TABLE can't succeed while the child table has rows. With an empty child table, it works.

    So for any parent table with referencing rows, ALTER TABLE currently 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.

  2. JanJakes commented on Oct 20, 2026

    @JanJakes
    Member

    Thanks for the detailed report. I agree with the direction. MySQL doesn't support transactional DDL, so the driver can do the same:

    1. Implicitly commit any open transaction before DDL, following MySQL's list. CREATE TEMPORARY TABLE and DROP TEMPORARY TABLE are the exceptions.
    2. For statements that recreate a table (ALTER TABLE, OPTIMIZE TABLE, REPAIR TABLE), disable foreign keys while no transaction is open.
    3. Still run each DDL statement in its own transaction. Recreating a table takes several steps (information schema, new table, copy, drop, rename, indexes), and they must stay atomic, like DDL in MySQL 8.
    4. Before committing, check PRAGMA foreign_key_check and fail on violations. Today its result is ignored.
    5. Re-enable foreign keys.

    For DROP TABLE and TRUNCATE TABLE of 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.

  3. Nandan108 commented on Oct 23, 2026

    @Nandan108
    ContributorAuthor

    Thanks, the plan sounds good to me. One point on step 4, checking PRAGMA foreign_key_check before committing:

    I'd fail only on violations that the ALTER TABLE introduced, not on ones that were already there. PRAGMA foreign_key_check lists every violating row in the database, so a single orphan row that predates the statement would make every later ALTER TABLE on that table fail, with no way forward except repairing the data by hand:

    PRAGMA foreign_keys = OFF;
    CREATE TABLE parent (id INTEGER PRIMARY KEY);
    CREATE TABLE child (id INTEGER, pid INTEGER REFERENCES parent (id));
    INSERT INTO parent VALUES (1);
    INSERT INTO child VALUES (10, 1), (20, 99);   -- 99 is an orphan
    PRAGMA foreign_key_check;                     -- reports the orphan before any ALTER
    
    -- Rebuild `child` for an unrelated change, as the SQLite procedure describes:
    -- CREATE new table, copy rows, DROP old table, RENAME.
    PRAGMA foreign_key_check;                     -- reports the same orphan again

    Orphans like this are easy to end up with, for example from rows written while foreign_keys was off, or by a driver version that didn't enforce foreign keys. MySQL doesn't re-validate existing rows on an unrelated ALTER TABLE either. 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 with foreign_key_checks=0) and one ON DELETE RESTRICT and one ON DELETE CASCADE table referencing a parent. With foreign_key_checks=1 and 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 DELETE action. Only ALTER TABLE child ADD CONSTRAINT ... FOREIGN KEY over 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_check before 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".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions