Refuse to autogenerate a NOT NULL column filled only by a dataclass default_factory
All Submissions:
- Have you followed the guidelines stated in CONTRIBUTING.md file?
- Have you checked to ensure there aren't any other open Pull Requests for the desired changed?
Type of change
- Bug fix (non-breaking change which fixes an issue)
- This change requires a documentation update
Changes To Core Features:
- Have you added an explanation of what your changes do and why you'd like us to include them?
- Have you written new tests for your core changes, as applicable?
- Have you successfully ran tests with your changes locally?
Based on #7797, now merged. It does not depend on #7809, which handles FINDING-005/006/017.
Fixes FINDING-012 from the reflex 0.11.0a1 pre-release testing (artifacts on branch claude/vibrant-pasteur-3921g3, prerelease-testing/runs/2026-11-11-v0.11.0a1/).
Defect
Take a non-empty table and add score: Mapped[int] = mapped_column(default_factory=lambda: 7) on a MappedAsDataclass model. Autogenerate wrote add_column(... nullable=False) with no server_default, so reflex db migrate failed with NOT NULL constraint failed. When the revision rebuilt the table in sqlite batch mode, alembic also left an _alembic_tmp_* table behind that broke the next migrate.
The cause is the env.py template's add-column hook, which only reads Column.default. A dataclass default_factory is stored on the dataclass field only, so Column.default is None.
Fix
The hook now handles a NOT NULL added column that has no server_default and no Column.default like this:
-
It looks the column up in the mapped dataclasses of the registered or detected model bases. It uses
table.c[op.column.key](keyed byColumn.key, which can differ from the name), thenmapper.get_property_by_column(column).key, then__dataclass_fields__. A mapper that doesn't map the column, as with a single-table-inheritance parent, is skipped. -
If the column's attribute has a
default_factory, the hook raises aCommandErrorrather than guess a value:Cannot add the NOT NULL column 'item.score' with only a default_factory: the migration has no value for rows that a database already holds. Set server_default=..., a literal default= or insert_default=, or make the column nullable.
I put the refusal at autogenerate time because it fails before a broken revision is written, so no _alembic_tmp_* leftover can come from this path. It also fires in alembic check, so the startup schema check reports it.
Docs: docs/database/tables.md gets a sentence about default_factory and migrations.
Known behaviour
This was confirmed by the release owner.
- A callable
Column.defaultis still evaluated once, not refused. Examples are sqlmodelField(default_factory=...)andmapped_column(insert_default=callable). The value is taken when the revision is generated and becomes the server default for existing rows, as in 0.10.1.test_callable_default_is_evaluated_for_existing_rowscovers it. Only a dataclassdefault_factorythat leavesColumn.defaultempty is refused. - A non-callable
Column.defaultstill becomes the server default, and a nullable column is never refused. - The column is refused on an empty table too. The revision also runs on other databases, such as production, that may hold rows.
Test plan
-
New test
test_default_factory_column_is_refusedintests/units/utils/test_migrations.py(real alembic and sqlite, MappedAsDataclass app). It failed before the fix:makemigrationsexited 0 and wrote..._factory.py. After the fix it checks:makemigrationsexits 1 with the message naming'thing.score', and no revision is written.- Bare
alembic checkfails with the same message. - A column with a custom key,
mapped_column('points', key='points_key', default_factory=...), is refused too, naming'thing.points'. This case was added for the review thread and failed before the key fix. - A literal
default=7getsserver_default=sa.text('7')and migrates the existing row. - A nullable
default_factorycolumn is accepted. - The test app has an
rx.Modeltable besideBase, so autogenerate compares merged copies of the columns and the hook must find the column by key. - A column that a single-table-inheritance subclass adds to its parent's table is refused, naming
'thing.extra'. Only the subclass mapper maps it.
-
uv run pytest tests/units --cov(withouttests/units/reflex_bench/drivers/test_browser.py, since there is no Chromium in this sandbox): 15645 passed, 66 skipped, coverage 81.44%. After the rebase onto 93beac9a3, the migration and model tests pass again: 43/43 locally, and test_migrations 30/30 on SQLite 3.45. -
uv run ruff check .anduv run ruff format --check .are clean.uv run pyright reflex testsgives 0 errors. -
End to end with the verifier's
verify_dbm/v3_default_factory.sh, run on an editable install:Variant Result dfmakemigrationsrc 1 with the message, no revision;migraterc 0 and its schema check logs the refusal; row untouched; no_alembic_tmp_*tabledfbatchsame as dfdfltserver_default=sa.text('7'), rows[(1, 'a', 7)]insserver_default=sa.text('7'), rows[(1, 'a', 7)]rxdfserver_default=sa.text('7'), rows[(1, 'a', 7)]
Notes
- The fix lives in the env.py template. Apps upgrading from 0.10.x get it when their stock env.py is replaced. An env.py that 0.11.0a1's
reflex db initwrote keeps the old hook until it is moved aside and re-initialized. - I did not add cleanup of an
_alembic_tmp_*table left by an already-written broken revision.
š¤ Generated with Claude Code