#37348: Removing null=True from GeneratedField should be a SQL no-op
-------------------------------------+-------------------------------------
Reporter: Michal Porteš | Owner: Md.
| Saikat Islam
Type: Bug | Status: assigned
Component: Database layer | Version: 6.1
(models, ORM) |
Severity: Release blocker | Resolution:
Keywords: | Triage Stage: Accepted
Has patch: 0 | Needs documentation: 0
Needs tests: 0 | Patch needs improvement: 0
Easy pickings: 0 | UI/UX: 0
-------------------------------------+-------------------------------------
Comment (by Natalia Bidart):
== DB Backend Specifics ==
||= DB =||= Virtual =||= Stored =||= NOT NULL allowed =||= Effect of the
null=True removal migration =||
|| PostgreSQL || 18+ || 12+ || Stored: yes; virtual: to verify || Adds
constraint; fails if NULLs exist ||
|| MySQL || Yes || Yes || Yes || `MODIFY` without `AS` clause: may convert
a stored column to a plain column (to verify) ||
|| MariaDB || Yes || Yes || Historically no (to verify) || Likely error ||
|| SQLite || 3.31+ || 3.31+ || Yes || Table rebuild; effectively a no-op
||
|| Oracle || Yes || 23.7+ || Yes || Adds constraint; fails if NULLs exist
||
The exclude bug affects all backends, except string outputs on Oracle.
This backend spread supports the "always nullable" approach. Nobody can
know nullability statically, Django doesn't emit NOT NULL on creation
anywhere, and the alter path gives different results on each backend. If
someone really wants NOT NULL, a CheckConstraint is the portable way to
get it.
== Longer explanation (Claude Opus 5) ==
Here's how each backend handles nullability of generated columns. The
Django parts come from the repo; the database behavior is from my
knowledge of each vendor's docs, which I haven't tested. I've marked the
claims worth double-checking before you rely on them in the ticket.
=== The common ground ===
No backend infers NOT NULL from the expression. A generated column is
nullable unless you add a NOT NULL constraint. Whether it can actually
hold NULL depends on the expression (Lower("name") over a nullable column
can) and sometimes on the backend (e.g. how string functions treat NULL).
That is why "null has no effect" is true for the DDL Django emits but
false as a statement about the data.
Django never emits NOT NULL for a generated column when creating a table
(base/schema.py:362). The only way it adds NOT NULL is the _alter_field
path from this ticket, and that path behaves differently per backend.
=== PostgreSQL (Django minimum is 15) ===
- Stored generated columns exist since PG 12. Virtual ones were added in
PG 18, which also made virtual the default when neither is specified.
Django gates this with supports_virtual_generated_columns =
is_postgresql_18.
- NOT NULL on a stored generated column is allowed, and ALTER COLUMN ...
SET NOT NULL works; it fails if existing rows are NULL, as the reporter
saw.
- For virtual columns on PG 18, I believe NOT NULL and CHECK constraints
are allowed while unique constraints, indexes and foreign keys are not.
Verify that before citing it.
- Result: the migration from the ticket really adds a constraint that a
fresh CREATE TABLE would not have. Databases migrated from older
migrations end up with a different schema than new ones.
=== MySQL (8.4+) and MariaDB (10.11+) ===
- Both support stored and virtual columns.
- MySQL allows NOT NULL in a generated column definition.
- MariaDB historically did not allow NOT NULL on generated columns. Verify
for 10.11+.
- Django's MySQL schema editor uses sql_alter_column_not_null = "MODIFY
%(column)s %(type)s NOT NULL". That statement omits the AS (...) clause.
MySQL's docs say a stored generated column can be converted to a regular
column with MODIFY/CHANGE, and a virtual one cannot. So on MySQL, this
migration may do more than add NOT NULL:
- If the table has NULLs, it fails.
- If it doesn't, a stored generated column could silently become a plain
column.
- A virtual column should just error.
This is the most serious case, but it needs an actual test on
MySQL/MariaDB before it goes in the ticket.
=== SQLite (3.37+) ===
- Generated columns exist since 3.31, and both kinds are supported. NOT
NULL and CHECK constraints are allowed; DEFAULT and PRIMARY KEY are not.
- Django's _alter_field on SQLite rebuilds the table, and the new column
definition goes through the column SQL that ignores null for generated
fields. So the reporter's migration is in effect a no-op on SQLite: the
table is rebuilt without NOT NULL. That explains why this goes unnoticed
in a SQLite-only test run.
=== Oracle (19+) ===
- Oracle has had virtual columns since 11g. Stored ("materialized")
generated columns need 23.7+ in Django
(supports_stored_generated_columns).
- NOT NULL constraints on virtual columns are allowed. Django would emit
MODIFY col NOT NULL, which should work and fails if NULLs exist.
- Oracle treats `''` as NULL (interprets_empty_strings_as_nulls), so
is_nullable() returns True for string outputs regardless of null. The
exclude bug is hidden there for CharField/TextField outputs, but still
shows for e.g. IntegerField outputs.
--
Ticket URL: <
https://code.djangoproject.com/ticket/37348#comment:4>