From 9336577abe5913d7a194a5042f17ecdfca456413 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 13 Aug 2026 09:28:11 -0400 Subject: [PATCH] Commit the migration run instead of trusting MySQL to SQLAlchemy 2.0 connections do not autocommit, and on MySQL alembic reports "non-transactional DDL", so context.begin_transaction() is a no-op. Nothing in a migration run committed. It looked like it worked because MySQL implicitly commits on DDL: every ALTER/CREATE flushed whatever was queued before it, including the PREVIOUS migration's version stamp. The LAST migration of a run has no DDL after it, so its stamp was rolled back when the connection closed. `flask db upgrade` then exited 0 with the schema change really applied and alembic_version one revision behind, and re-ran that same migration on the next deploy. A migration that is not idempotent applies twice. Found while adding a core migration: the column default really changed and alembic_version still named its parent, with UPDATE alembic_version followed immediately by ROLLBACK in the log. Existing databases upgraded before this are one revision behind their real schema. Compare `flask db current` against `flask db heads`; re-running the upgrade re-applies the final migration once and lands the stamp. --- migrations/env.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/migrations/env.py b/migrations/env.py index 0b4049e..7b15020 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -147,6 +147,21 @@ def run_migrations_online(): with context.begin_transaction(): context.run_migrations() + # COMMIT THE RUN. SQLAlchemy 2.0 connections do not autocommit, and on + # MySQL alembic reports "non-transactional DDL" so begin_transaction() + # above is a no-op - nothing here commits on its own. + # + # It looked like it worked because MySQL implicitly commits on DDL: each + # ALTER/CREATE flushed everything queued before it, including the + # PREVIOUS migration's version stamp. The LAST migration of every run + # has no DDL after it, so its stamp was rolled back at close. The column + # changes survived (already committed by their own DDL) while + # alembic_version stayed one revision behind, so `flask db upgrade` + # exited 0 having silently re-run the final migration, and re-ran it + # again on the next deploy. A migration that is not idempotent would + # apply twice. + connection.commit() + if context.is_offline_mode(): run_migrations_offline()