v0.163.0

plain-postgres changelog

0.117.0 (2026-09-04)

What's changed

  • Fixed QuerySet.ordered, which raised AttributeError: 'Meta' object has no attribute 'ordering' for any queryset without an explicit order_by(). It was reading the model's default ordering off _model_meta instead of model_options, so the property only ever worked on the one branch that never reached the lookup. It now correctly reports True when the model declares Meta.ordering and False when it doesn't (d9406dfc6f)
  • Unresolved relation references are now separate attributes from resolved model classes. rel.model and ManyToManyRel.through used to hold whatever you passed to the field — a "package.Model" string early on, the class after lazy_related_operation overwrote it — so every reader had to guess which it had, and the guessing was done inconsistently (isinstance(..., str) in some places, isinstance(..., ModelBase) in others, hasattr(..., "_model_meta") in others still). The raw reference now lives on rel.model_ref and rel.through_ref, and rel.model / rel.through are read-only properties that return the resolved class or raise TypeError if it isn't resolved yet. The TypeError is deliberate: an AttributeError would be swallowed by hasattr/getattr and silently read as "no model" (a3962f7255)
  • ForeignKeyField.target_field dropped its own unresolved-reference TypeError — the rel.model property raises it now, with a message naming the field (a3962f7255)
  • ManyToManyField._check_through_fields reports an unresolved target by name instead of crashing on related_model.model_options.object_name, so a through_fields mistake on a not-yet-resolved relation produces the intended preflight message (a3962f7255)
  • ModelState now raises TypeError (was ValueError) when a field's to or through refers to a model class instead of a string reference, and detects it by checking the reference directly rather than probing for _model_meta (a3962f7255)
  • Removed Field.concrete. It was set to self.column is not None right after column was assigned the field's name, so it was True on every forward field and the checks that consulted it were dead. bulk_create() and bulk_update() now say what they actually reject — "bulk_update() cannot be used with many-to-many fields." rather than "can only be used with concrete fields." — and Model.save(update_fields=...) drops "non-concrete fields" from its error message. ForeignObjectRel.concrete and the unreachable virtual-field skip in Model.__init__ are gone too (af56244677)
  • OrderBy.asc() and .desc() return the OrderBy instead of None, matching every other expression's asc/desc and making expr.desc().asc() and inline use work. They still take no options — passing nulls_first or nulls_last raises TypeError pointing you at the attributes on the OrderBy itself, rather than being silently ignored (f00e707ed0)
  • Preflight expression checks no longer crash when a lookup path continues past a non-relation field — for example Meta.ordering = ("name__nope",) on a text field. The traversal now raises FieldDoesNotExist for the trailing part, which preflight already reports, instead of an AttributeError on None (d9406dfc6f)
  • MigrationGraph.make_state() raises NodeNotFoundError when the plan hits a placeholder node, instead of an AttributeError on None (d9406dfc6f)
  • The migration autodetector and ProjectState narrow on RelatedField / ManyToManyField / ManyToManyRel instead of hasattr(field, "remote_field") and getattr(remote_field, "through", None). Rename handling only rewrites a reference when the old field is the same kind of relation, so a field swapped between a plain relation and an M2M during a rename no longer copies a mismatched reference across (a3962f7255)
  • ProjectState._get_related_models reads a forward relation's target from remote_field.model_ref rather than f.model — which returned the model the field is declared on, not the one it points at (a3962f7255)
  • Meta._expire_cache propagation to a related model no longer swallows AttributeError to handle the unresolved-string case; it checks model_ref up front, so a genuine AttributeError inside _expire_cache surfaces (a3962f7255)

Upgrade instructions

  • If you read rel.model or rel.through (via field.remote_field) in code that runs before models are fully loaded — preflight checks, contribute_to_class, custom fields, migration operations — switch to rel.model_ref / rel.through_ref and check isinstance(ref, str). After model loading completes, rel.model and rel.through behave as before.
  • If you assigned to rel.model or rel.through, assign to rel.model_ref / rel.through_ref instead — the plain names are now read-only properties.
  • If you referenced Field.concrete or ForeignObjectRel.concrete, remove the check. concrete was True for every forward field and False only on reverse relations, which get_fields() doesn't return unless you pass include_reverse=True.
  • If you asserted on the exact text of the bulk_create(), bulk_update(), or save(update_fields=...) errors, update the expected strings.
  • If you caught ValueError from ModelState for a model-class to/through reference, catch TypeError.
  • OrderBy.asc()/.desc() returning self is additive — existing calls that discarded the return value are unaffected.

0.116.0 (2026-09-04)

What's changed

  • Meta now exposes exactly two forward-field collections: fields and many_to_many. local_fields, local_many_to_many, concrete_fields, and local_concrete_fields are gone. Plain has no model inheritance and no non-concrete forward fields, so "local" and "concrete" drew distinctions that could never differ — four names for two lists, each with its own cached property and its own filtering pass. fields is every column-backed field (primary key first, then sorted by name) and many_to_many is every ManyToManyField (sorted by name); both now read straight from the storage add_field() fills, instead of re-filtering _get_fields() on each access (9f0d0da22d)
  • Meta.fields is typed as ImmutableList[ColumnField] and Meta.many_to_many as ImmutableList[ManyToManyField], so the column attributes (column, db_type(), primary_key) are readable off a field without an isinstance check first. add_field() enforces that by raising TypeError for a field that is neither a ColumnField nor a ManyToManyField (996c099e2b, 9f0d0da22d)
  • The postgres.non_local_field_reference preflight check is removed. It rejected an indexes or constraints entry naming a field "not local to" the model — unreachable without inheritance, since a forward field found on the model is always the model's own (9f0d0da22d)
  • Field.name is typed str instead of str | None — it starts as "" and becomes the real name in set_attributes_from_name(). That drops roughly twenty assert self.name is not None guards from the field descriptor, save, and preflight paths. Field.deconstruct() (and the ColumnField, DefaultableField, ChoicesField, RelatedField, ForeignKeyField, and ManyToManyField overrides) returns tuple[str, str, list, dict] accordingly (51cb71f758)
  • get_joining_columns() moved from ForeignObjectRel to ForeignKeyRel. It delegates to field.get_joining_columns(reverse_join=True), which only a foreign key has (51cb71f758)
  • PathInfo.join_field is typed ForeignKeyField | ForeignKeyRel (was ForeignKeyField | ForeignObjectRel), and select_related_descend() is a TypeGuard[RelatedField] so a passing field narrows at the call site (51cb71f758)
  • QuerySet._update(), _insert(), _batched_insert(), UpdateQuery.add_update_fields(), and InsertQuery.insert_values() accept a Sequence of fields rather than requiring a list, so an ImmutableList from Meta can be passed straight through (51cb71f758)

Upgrade instructions

  • Replace _model_meta.local_fields, _model_meta.concrete_fields, and _model_meta.local_concrete_fields with _model_meta.fields. In Plain these were always the same set of fields.
  • Replace _model_meta.local_many_to_many with _model_meta.many_to_many.
  • Replace _model_meta._non_pk_concrete_field_names with _model_meta._non_pk_field_names.
  • If you have a custom Field subclass, deconstruct() now returns str as its first element rather than str | None, and self.name is "" (not None) before the field is contributed to a model class. Update any annotation or is None check accordingly.
  • If you called get_joining_columns() on a reverse relation, it is now available on ForeignKeyRel only.

0.115.0 (2026-09-02)

What's changed

  • Foreign keys are now NOT DEFERRABLE (Postgres's own default) and checked at each write. Plain inherited Django's DEFERRABLE INITIALLY DEFERRED foreign keys, added in 2007 so fixtures could contain forward references. Plain has no fixtures, and the deferral was causing real problems: a migration that adds a column, backfills it in RunPython, then alters the same table failed with "cannot ALTER TABLE ... because it has pending trigger events" whenever the backfill touched a row, and FK violations only surfaced at COMMIT with a traceback pointing at atomic.__exit__ instead of the offending write (8cbfa0cb40)
  • Convergence detects an existing DEFERRABLE foreign key as drift and fixes it with a catalog-only ALTER CONSTRAINT ... NOT DEFERRABLE — no revalidation scan, just a brief ACCESS EXCLUSIVE lock bounded by the usual lock_timeout — so the first postgres sync after upgrading rewrites every FK quickly. A legacy DEFERRABLE + NOT VALID foreign key gets both corrections in one pass (8cbfa0cb40)
  • NO_ACTION is removed. Its only meaning was deferred checking — use RESTRICT (8cbfa0cb40)
  • A foreign key violation from create()/update() now surfaces as a ValidationError routed to the ForeignKeyField — "Author with id 5 does not exist." — with code invalid_choice so forms can override it. It lands in the same IntegrityError mapping that unique and check constraints already used, so a missing related row reports like any other field error instead of a 500. The message is ForeignKeyField.does_not_exist_error_message. Set-based writes (QuerySet.update(), bulk_create()) and a delete() blocked by RESTRICT still raise the raw psycopg error, since there's no instance to attribute it to (31df77e665)
  • That mapping keys on the foreign key's generated constraint name, so convergence now treats a stale name — left behind by RenameField/RenameModel, which only rename the column or table — as drift and renames the constraint. Because the write-path mapping depends on those corrections having run, FK renames and the NOT DEFERRABLE flip block sync on failure. generate_fk_constraint_name() moved to plain.postgres.utils and is now the single source of that name for both convergence and the write path (31df77e665)
  • UniqueConstraint(deferrable=...) and the Deferrable enum are removed. With foreign keys immediate, this was the last deferred-constraint concept in Plain, nothing in Plain's own packages used it, and it forced a caveat onto every "constraints are checked at the write" statement while keeping the pending-trigger-events failure alive for one case. Rails (pre-7.1), Ecto, and Laravel never had it for uniques. Every constraint is now checked at the write that violates it, never at commit (fd1e7f1e13)
  • DatabaseConnection.check_constraints() is gone, and the pytest db fixture no longer forces deferred constraints to immediate at teardown — there is nothing left to defer (fd1e7f1e13)
  • Constraint renames now run in convergence pass 1, matching index renames, so anything else planned for the same constraint in that pass addresses it by its new name (fd1e7f1e13)
  • New README section, "Constraints are checked immediately", covering the patterns this changes: create parents before children, use a nullable back-reference for a foreign key cycle (two required FKs pointing at each other can never be inserted), and move through a temporary value when swapping two rows under a unique constraint, since Postgres checks uniqueness per row even within a single UPDATE (8cbfa0cb40, fd1e7f1e13)

Upgrade instructions

  • Replace NO_ACTION with RESTRICT in your models and in any historical migration file that references itpostgres.NO_ACTION no longer exists, so a migration importing it fails to load.
  • Remove deferrable= from every UniqueConstraint(...) and drop any Deferrable import. An existing deferred unique constraint in the database shows as CHANGED drift with staged-rollout guidance — nothing is rewritten silently.
  • Run plain postgres sync after upgrading. It flips every existing foreign key to NOT DEFERRABLE (catalog-only, no table scan) and renames foreign key constraints left stale by past RenameField/RenameModel.
  • Code that inserted a child row before its parent, or deleted a parent and re-pointed its children later in the same transaction, now raises ForeignKeyViolation at that statement rather than succeeding by commit time. Create parents before children; for a foreign key cycle, create both rows and then update() the nullable back-reference.
  • If you catch psycopg.IntegrityError around create()/update() to handle a missing related row, catch ValidationError instead — or both, if the same block also covers set-based writes.
  • If you called connection.check_constraints(), remove the call.

0.114.0 (2026-08-12)

What's changed

  • Wrong-type arguments now raise TypeError instead of ValueError across the schema and query APIs: UniqueConstraint/Index options (condition, deferrable, include, opclasses, fields), Extract/Trunc field types, non-bool isnull lookups, RunPython without a callable, serializer registration, assigning a wrong-model instance (or bool) to a foreign key, and reverse descriptors on non-FK/M2M fields (f52e18f532)
  • Migration.operations/dependencies/replaces are typed as Sequence, and plain migrations create now writes them as tuples in generated files. Existing list-based migration files keep working (f52e18f532)
  • Migration filename timestamps use the app's TIME_ZONE (via timezone.localtime()) instead of the machine's local clock, so a team generates consistently named migrations (f52e18f532)
  • Model field default_validators is now a tuple and empty_values a tuple; ignored_tables on the connection is a tuple (f52e18f532)
  • ModelsRegistry.get_models() caching was reworked to a per-instance memo (still cleared by clear_cache()), replacing a functools.cache keyed on the registry instance (f52e18f532)
  • on_commit() handler errors are logged with logger.exception() — same message and traceback, standard idiom (f52e18f532)

Upgrade instructions

  • If you catch ValueError around any of the argument-validation cases above, catch TypeError instead
  • Newly generated migrations use tuples for dependencies/operations; existing migration files don't need to change
  • Requires plain>=0.157.0

0.113.2 (2026-08-10)

What's changed

  • Fixed a NameError on any expression-based ordering — Model.query.order_by(F("name")), order_by(Lower("name")), or Options(ordering=[Lower("name")]). SQLCompiler cast ordering items to BaseExpression, which was imported only under TYPE_CHECKING; cast()'s first argument is an ordinary expression and is evaluated at runtime, so the branch raised instead of compiling. BaseExpression moves into the existing runtime import from plain.postgres.expressions. (3429735be0)
  • QuerySet.order_by() and Query.add_ordering() now accept str | ResolvableExpression instead of str / str | BaseExpression. That's the protocol both layers actually test with isinstance(), and the one F() satisfies (F extends Combinable, not BaseExpression) — so no type-checked caller could reach the expression branch at all before. (3429735be0)

Upgrade instructions

  • No changes required.

0.113.1 (2026-08-07)

What's changed

  • Internal: Meta field sorting tolerates a field whose name hasn't been set yet, keeping the sort key type-correct under ty 0.0.65. No behavior change. (a798c13c92)

Upgrade instructions

  • No changes required.

0.113.0 (2026-08-02)

What's changed

  • QuerySet.extra() has been removed, along with all of its supporting machinery (extra_select, extra_tables, extra_order_by, ExtraWhere). Use annotate() with RawSQL or other expressions instead. (3fed0b32f1)
  • FilteredRelation has been removed — it's no longer importable from plain.postgres or usable in annotate(). Express conditional joins with Q filters or subqueries instead. (434f57852f)
  • QuerySet.alias() and logical XOR have been removedQ(...) ^ Q(...) and queryset ^ queryset are no longer supported. Use annotate() in place of alias(), and compose AND/OR/NOT in place of XOR. (c12cf81af4)
  • Encrypted and binary fields now accept their empty value as a declared default=. EncryptedTextField (now a TextField subclass) accepts exactly default="" and BinaryField accepts exactly default=b"", each paired with required=False — the empty value is stored as plaintext so it's the one default expressible as a column DEFAULT, which is what lets the field be added to a populated table. default=None requires allow_null=True + required=False. The types.pyi stub overloads encode the same coupling. (2a86968e5a)
  • The migration autodetector no longer waves through required=False fields with no declared default. Adding a non-nullable field without a default= now refuses at plain migrations create time with a field-accurate remedy (previously these could generate an AddField that failed at apply time on any populated table). The optional-string idiom is required=False, default="". (2a86968e5a)
  • EncryptedJSONField is now a JSONField subclass and EncryptedTextField a TextField subclass, instead of parallel ColumnField implementations — behavior like max_length validation and JSON encoding now comes from the real base classes. The encrypted lookup surface is still just exact/isnull, and JSON key transforms are blocked (they'd operate on ciphertext). EncryptedJSONField accepts no default= at all — even {} would need ciphertext; use allow_null=True. (5153ceb106, 2a86968e5a)
  • Model forms no longer prefill a BinaryField's b"" default as a text input's initial value (it rendered as the literal b''), and encrypted fields now map to form fields through the regular TextField/JSONField branches. (2a86968e5a)
  • M2M prefetches now expose the through-table join column via annotate(RawSQL(...)) instead of the removed extra(), and internal path-info plumbing lost its filtered_relation threading. (3fed0b32f1, 434f57852f)

Upgrade instructions

  • Replace QuerySet.extra() calls: extra(select=...) becomes annotate(name=RawSQL("...", [])), extra(where=...) becomes filter() with expressions or RawSQL, and extra(order_by=...) becomes order_by() on an annotation.
  • Replace FilteredRelation annotations with filtered subqueries or Q conditions, and QuerySet.alias() with annotate().
  • Replace Q(a) ^ Q(b) / qs1 ^ qs2 with explicit AND/OR logic: (Q(a) | Q(b)) & ~(Q(a) & Q(b)).
  • If plain migrations create now refuses a field it previously accepted, follow the error's remedy: declare required=False, default="" (or default=b"" / an appropriate literal) so existing rows get a value, or add it with allow_null=True plus a backfill data migration.

0.112.0 (2026-07-21)

What's changed

  • Schema-changing commands now serialize on an advisory schema lock. plain postgres sync, plain migrations apply, plain postgres converge, and plain postgres drop-unknown-tables take a session-level advisory lock, so two deploy processes running at once (a retried migrate job, overlapping release phases) can't interleave schema changes. A second process warns and waits, retrying up to an hour by default (POSTGRES_SCHEMA_LOCK_RETRY_INTERVAL, POSTGRES_SCHEMA_LOCK_MAX_RETRIES), and each command re-checks its plan after acquiring the lock — migrations apply skips work another process already did, converge refuses to execute corrections the operator never approved, and drop-unknown-tables skips tables that became known while waiting. The lock is held on its own connection so CREATE INDEX CONCURRENTLY still works, and it releases automatically if the holder crashes. (f12f5f1bd4)
  • New plain.postgres.databases module for cluster-level database management — create_database (with TEMPLATE support), drop_database, database_exists, list_databases, database comments, connection counting/termination, and a maintenance_cursor helper. It's a development/test capability (it needs CREATEDB and a connection to the postgres maintenance database) and is what powers plain-dev's managed development databases; test database creation now uses it too. (0a747b8933)
  • Fixed a stale rollback_exc on reused connections: entering an outermost atomic block now clears it, and a rollback caused by an exception inside a non-savepoint block records that exception — so a broken-transaction error chains from the real cause instead of a stale or absent one. (350f34b291)
  • Convergence terminology changed from "fix" to "correction" in CLI output and internals — plain postgres converge now reports "N corrections to apply" and plain postgres schema marks drift as (auto-correct). (08da420a9b)
  • The unconfigured-database error now points at PLAIN_POSTGRES_URL (or POSTGRES_URL in settings) instead of DATABASE_URL-first wording. (8b7aa38e95)

Upgrade instructions

  • No changes required. If your migration role's Postgres server sets idle_session_timeout, note the schema lock holds an idle session for the duration of a schema command — exempt that role, or the lock can be lost mid-run. If POSTGRES_URL points at a transaction-mode pooler (pgbouncer), set POSTGRES_MANAGEMENT_URL to a direct connection so the session-level lock works.

0.111.2 (2026-07-15)

What's changed

  • The setup_db pytest fixture is now pytest-xdist aware: each worker gets its own test database (gw0_<db>, gw1_<db>, …), so plain test -n <N> runs in parallel without workers colliding while creating, migrating, and dropping the same database. Off-xdist the database name is unchanged (test_<db>). (a8880771d2)
  • Removed an unnecessary type: ignore comment in the SQL compiler. (2a1bf46f13)

Upgrade instructions

  • No changes required.

0.111.1 (2026-07-10)

What's changed

  • suppress_db_tracing is now documented public API (plain.postgres.otel.suppress_db_tracing), with a new Tracing section in the README. Use it around framework-style housekeeping queries (pollers, gauge callbacks) that run outside any entry span, where each query would otherwise export as its own single-span root trace. (0560eb69b8)
  • Hardened the schema-convergence invariants behind plain postgres sync / converge / schema: a malformed drift now fails loudly at plan time instead of constructing a fix with missing fields, and column/foreign-key comparison asserts it never silently drops a detected drift. (cb9aa7087c)
  • Internal restructuring of the convergence subsystem: the per-kind drift classes were decomposed into shape dataclasses, and plain.postgres.convergence no longer re-exports the drift, fix, and status types — it is an internal subsystem driven through the CLI commands, and its __init__ now only exposes what the CLI uses. No CLI behavior changes. (75216e16c9, 2b2e57f876)

Upgrade instructions

  • No changes required.

0.111.0 (2026-06-30)

What's changed

  • Slicing a QuerySet now always returns a QuerySet, even when its results are already cached — previously, slicing an already-evaluated queryset returned a plain list. The returned queryset carries the sliced cache, so iterating it won't re-query, but re-chaining it (adding a .filter(), ordering, etc.) drops the cache and applies the slice as SQL LIMIT/OFFSET so the rows stay correct. (d60e07d6ad)
  • Step slicing (e.g. qs[::2]) now raises ValueError instead of silently evaluating the queryset and returning a stepped list. Negative indexing and negative slice bounds continue to raise, as before. RawQuerySet is unaffected — it is always fully materialized, so its slicing keeps plain list semantics, where step and negative slicing still work. (d60e07d6ad, a873a521d4)

Upgrade instructions

  • If you relied on queryset[a:b] returning a list — for example checking isinstance(result, list), calling list methods like .append() on it, or re-slicing it with a step — wrap it in list(queryset[a:b]) to materialize it explicitly.
  • Replace any step slicing of a queryset (queryset[::2]) with a list(...) conversion first, since it now raises ValueError.

0.110.2 (2026-06-26)

What's changed

  • Internal type-annotation cleanups (Func expression attributes, enum choices) and a dev-dependency bump (pytest 9.1.1) to clear a security advisory. No public API changes. (288bbf0bb1)

Upgrade instructions

  • No changes required.

0.110.1 (2026-06-22)

What's changed

  • Internal: the ordering infinite-loop guard builds its join signature with isinstance(alias, Join) instead of getattr(alias, "join_col", None), keeping .join_col greppable and type-checked. No behavior change. (c813405f)

Upgrade instructions

  • No changes required.

0.110.0 (2026-06-17)

What's changed

  • Internal: the foreign-key/related-field internals are collapsed to the single-column-to-id model Plain actually uses, removing the inherited Django composite-key and to_field scaffolding. PathInfo.target_fields (a tuple) becomes a scalar target_field, and a Join now holds one join_col pair instead of a join_cols list. The unused multi-column helpers go with it: RelatedField.resolve_related_fields(), the related_fields/local_related_fields/foreign_related_fields properties, ForeignKeyField.get_reverse_joining_columns(), and ForeignObjectRel.get_related_field() / field_name / set_field_name() / db_type / the multiple flag (along with its now-dead reverse-cache priming, left over from the long-removed one-to-one relation). get_local_related_value(), get_foreign_related_value(), and the join-column accessors now return scalars instead of 1-tuples. (cbd15fb707)
  • Internal: the related-lookup prep path (related_lookups.py, lookups.py) now narrows on RelatedField | ForeignObjectRel to find a relation's target field instead of duck-typing with getattr(output_field, "target_field") / hasattr(..., "path_infos"), so the dependency is greppable, type-checked, and fails loudly if it's ever removed rather than silently falling back. (cbd15fb707)

Upgrade instructions

  • No changes required. These are internal ORM query-construction details — how foreign keys, reverse relations, and many-to-many relations behave (filtering, prefetching, related-manager access) is unchanged.

0.109.0 (2026-06-17)

What's changed

  • The db_constraint argument is removed from ForeignKeyField — every foreign key is now backed by a database FOREIGN KEY constraint, always. Because the database guarantees the referenced row exists, accessing a foreign key always builds a partial related instance from just the stored id (no query); the old "query on access when unconstrained" path is gone. The fields.foreign_key_unconstrained_requires_no_action preflight check is also removed. (9ec559db38)
  • limit_choices_to is removed from ForeignKeyField and ManyToManyField. The supporting machinery goes with it: RelatedField.get_choices() / get_limit_choices_to(), ForeignObjectRel.get_choices(), and QuerySet.complex_filter() are removed, and BLANK_CHOICE_DASH is no longer exported from plain.postgres.fields. To restrict the rows offered for a relation, filter the queryset explicitly in your form or view. (68b6ced4f0)
  • The redundant foreign-key existence pre-check is removed. ForeignKeyField.validate() no longer runs a SELECT ... EXISTS query to confirm the target row exists — one fewer query per FK validation. The database FOREIGN KEY constraint already enforces this, rejecting an invalid reference at write time (as a psycopg.IntegrityError). (68b6ced4f0)
  • Internal: convergence (schema-drift) analysis now reuses a single session-private temp "probe" table per model across all of that model's round-trip comparisons, instead of creating and dropping one per index/constraint/default comparison — fewer DDL statements during schema checks. The internal canon* helpers were renamed to probe/normalize. (67167a11ee, 06650bea63)

Upgrade instructions

  • Remove any db_constraint=False arguments from ForeignKeyField. Foreign keys are always DB-constrained now; an unconstrained FK (e.g. for soft/cross-system references) is no longer supported.
  • Remove limit_choices_to= from ForeignKeyField / ManyToManyField definitions. To constrain the rows offered for a relation, filter the related queryset explicitly in your form or view.
  • Replace any QuerySet.complex_filter(...) calls with .filter(...) — pass a dict of lookups or a Q object directly.
  • Most projects use none of these and need no changes.

0.108.1 (2026-06-09)

What's changed

  • ensure_connection() now detects a pooled connection the server has closed while it was held (Postgres restart, failover, idle timeout) and discards it before establishing a fresh one — logging a "Discarding dead database connection" warning — instead of repeatedly failing on the dead connection. Inside an atomic block the dead connection is intentionally left in place so the transaction's normal error handling runs, rather than silently continuing the rest of the block outside its transaction. (9e82cb4454)
  • Query span instrumentation no longer pays for expensive attributes when the span is sampled out: the per-query Python stack walk (for code.* attributes) and the DEBUG-mode stringification of query parameters now only happen when the span is actually recording. The cheap connection attributes are still passed at span creation so attribute-aware samplers can see them in should_sample(). (af736043b0)

Upgrade instructions

  • No changes required.

0.108.0 (2026-06-09)

What's changed

  • A partial index of exactly WHERE <fk> IS NOT NULL on the FK column now counts as FK index coverage — every FK lookup and referencing-side sweep is a WHERE fk = ?, which implies the predicate, so Postgres can always use it. Both the postgres.missing_fk_indexes preflight check (an Index/UniqueConstraint with condition=Q(fk__isnull=False)) and the doctor's missing-FK check (matching the live index predicate) accept it; any other partial predicate still doesn't count. (4836069767)
  • The missing-FK-index warning now points out when an existing composite index or constraint already contains the FK at a non-leading position, suggesting a reorder to lead with the FK instead of adding a new index (safe when every query using the composite filters all of its columns with equality). (084b6abba5)
  • Internal: the preflight checks moved from a single preflight.py into a preflight/ package (models.py, database.py, indexes.py). Check names and behavior are unchanged. (edc792d358)

Upgrade instructions

  • No changes required.

0.107.1 (2026-06-08)

What's changed

  • Internal: typing-only cleanup for the ty 0.0.45 upgrade. Query.order_by is now annotated tuple[Any, ...] instead of relying on empty-tuple inference, a few field-name dict keys are narrowed via assert or the known-str loop variable, and ty: ignore comments on the intentionally heterogeneous get_fields() / klass_info structures were adjusted (net removal of several ignores). No runtime behavior changes. (5c8015795d, 95f54e880d)

Upgrade instructions

  • No changes required.

0.107.0 (2026-06-07)

What's changed

  • ModelForm.save() is replaced by explicit create() and update(), mirroring the Model.create()/update() split from 0.106.0. The view already knows whether it's inserting or updating (CreateView vs UpdateView), so the form no longer branches on instance._state.adding to guess. form.create() INSERTs the instance (and its m2m data) and returns it; form.update(fields=None) UPDATEs it (and its m2m data) and returns it, with fields=[...] passed straight through to Model.update() to limit the columns written. save(commit=...) — including the commit=False deferred path — is removed. (66634f5af9)

Upgrade instructions

  • Replace form.save() with form.create() in create flows and form.update() in update flows.

  • form.update(fields=[...]) is available to limit the columns written (a pass-through to Model.update(fields=...)).

  • For the old form.save(commit=False) pattern (set extra non-form attributes, then save), assign those attributes on form.instance after is_valid(), then call form.create() / form.update() — m2m data is still saved for you, so the separate save_m2m() step is no longer needed:

    if form.is_valid():
        form.instance.user = request.user
        obj = form.create()
    

0.106.0 (2026-06-03)

What's changed

  • Model.save() is replaced by explicit create() (always INSERT) and update() (always UPDATE). A call site now says which it does. create() inserts a new row and returns self; update() writes an existing row and returns self, raising if no row matched (there is no silent INSERT fallback). update(fields=[...]) limits the columns written (replacing save(update_fields=[...])). save(), save_base(), and the force_insert/force_update arguments are removed. (f75deb3ba2)
  • Constructing a model with id= now raises. A hand-set id at construction almost always means "load the existing row" — use Model.query.get(id=...). A freshly constructed instance is unambiguously new. (9c0acf7bac, 661c7806df)
  • Declared unique/check constraint violations now raise ValidationError, not raw psycopg.IntegrityError — mapped at the write boundary, so even a violation that races past validation surfaces as the same field-level error a pre-check would raise. The database is now the authority; the pre-check SELECT is no longer required for correctness. Set-based writes (bulk_create, QuerySet.update) still raise raw psycopg errors. (40c97521c5, bdbdd524e5)
  • full_clean() is now shape-only (clean_fields() + clean()); the integrity pre-check moved to validate_constraints(), called explicitly. full_clean()'s validate_unique/validate_constraints parameters and the Model.validate_unique() / _get_unique_checks / _perform_unique_checks trio are removed; forms call full_clean() then validate_constraints(). (ba4b386fb7, 65d56945fb)
  • delete() clears the instance's id and resets it to "new," so create() re-inserts it cleanly; the instance's other field values survive the delete. (f75deb3ba2)

Upgrade instructions

  • Replace obj.save() with obj.create() for a new row or obj.update() for an existing one; obj.save(update_fields=[...]) becomes obj.update(fields=[...]).
  • Remove force_insert=/force_update=create() and update() are the explicit forms.
  • Don't pass id= to a model constructor; load existing rows with Model.query.get(id=...).
  • Catch ValidationError (or (psycopg.IntegrityError, ValidationError) when retrying) for unique/check conflicts on instance writes. Inside an open transaction.atomic() a violation aborts the transaction — wrap the write in its own atomic() to catch and continue.
  • Direct full_clean() callers that relied on constraint pre-checking should call validate_constraints() explicitly.

0.105.0 (2026-05-25)

What's changed

  • Field constructors in plain.postgres.types are now typed as parameterized descriptors. Stubs return XField[T] (e.g. TextField[str], IntegerField[int | None] when allow_null=True) instead of the primitive value. Combined with Field[T]'s overloaded __get__, class-level access (User.email) sees the descriptor and instance access (user.email) sees the value — no annotation needed on model declarations. (229ecdbbfa)
  • Field.__set__ is now typed as value: T instead of Any, so a type checker flags row.name = 123 on a TextField at the call site. Runtime conversion via to_python() is unchanged. (229ecdbbfa)
  • ForeignKeyField overloads split by to= argument shape. Passing a model class (to=Author) returns _ForeignKeyDescriptor[T, V] and accepts bare-PK assignment (book.author = 5) via __set__. Passing a string forward reference (to="Author") returns the bare value type and requires an LHS annotation so the type can bind. (229ecdbbfa)
  • JSONField and EncryptedJSONField keep explicit annotations on model declarations — their runtime classes aren't generic, so the stubs return Any. (229ecdbbfa)

Upgrade instructions

  • Drop primitive type annotations on model field declarations. name: str = types.TextField() will now fail type checking because the right-hand side is typed TextField[str], not str. Change to name = types.TextField() — type checkers still infer str at instance access through the descriptor's __get__. Runtime is unchanged either way.
  • Keep explicit annotations for JSONField / EncryptedJSONField — these still return Any from the stubs, so the declared type is what flows through. Example: parameters: dict[str, Any] | None = types.JSONField(required=False, allow_null=True).

0.104.0 (2026-05-22)

What's changed

  • Foreign keys now return a partial related instance instead of an _id attribute. Reading book.author is query-free — it returns an Author instance with only its primary key loaded. book.author.id gives the foreign key value, and accessing any other field (book.author.name) triggers a single deferred-load query for the whole row. The descriptor caches the resolved object so subsequent accesses do not re-query. select_related() continues to populate the cache up front for loops. (b3be028b7b)
  • The <fk>_id shadow attribute is gone. There is no book.author_id anymore — the raw foreign key column lives in instance.__dict__ under the field name (book.__dict__["author"]) and is reached through the descriptor. book.author.id is the replacement read path and is type-checked because book.author is an Author. Writes that previously assigned book.author_id = 5 now need to assign the key (or a related instance) through book.author = 5 — the descriptor accepts a bare primary key value, a model instance, or None. (3baa1c0404, b3be028b7b)
  • Field.attname and Field.get_attname() are removed. Every internal use was the same as Field.name; the dual-keying only existed to track the _id suffix on foreign keys, which now lives on Field.column instead. Use field.name for the attribute (and instance-dict key) and field.column for the database column. (6a462a874c, e556bb7d63)
  • Foreign keys with db_constraint=False are queried on access rather than returning a partial instance. Without a database FK constraint a stale key cannot be assumed valid, so the descriptor issues a SELECT and raises RelatedObjectDoesNotExist immediately if the row is missing — the same exception an empty foreign key raises. Constrained foreign keys (the default) keep the no-query partial-instance shortcut. (d341d902b4)
  • Reading a deferred field now hydrates every still-missing concrete column in one query, not just the field you accessed. This is what makes the partial foreign-key instance cheap to use beyond its primary key — first non-key access loads the whole row. The foreign key column itself is the exception and is loaded on its own when deferred via .only() / .defer(), so the partial-instance shortcut stays cheap. (2440e415cb)
  • The descriptor's deferred foreign-key load now goes through _get_raw_value(), so a .only() queryset that excludes the FK column still resolves the related object on access without mistaking the deferred column for NULL. (d9765979ba)
  • Field.__delete__ reordered so a missing entry raises AttributeError without first wiping the related-object cache — repeated dels no longer silently evict cached descriptors. (b8949f00d0)
  • Internal cleanup: removed the unused ForeignKeyField.reverse_related_fields property, the dead composite-key branches in foreign-key resolution, and the unreachable code left over from the attname collapse. (98b27c5f8a, 92e905f25e, bfcac9a435)

Upgrade instructions

  • Audit every <fk>_id access — these attributes no longer exist. Reads (obj.author_id) and especially writes (obj.author_id = 5) need to move to the field name: obj.author.id for reads, obj.author = 5 for writes. Pay particular attention to assignments under # type: ignore or other suppression — a stray obj.author_id = ... write now creates a plain Python attribute on the instance and silently does nothing on save, because the ORM only persists the descriptor-backed value at obj.author. Grep for _id =, _id= (in .create() / .update() kwargs), and _id template/queryset references on your models. Querysets and lookups (Author.query.filter(book__author_id=5)) are unaffected — only attribute access on instances changed.
  • Stop reading Field.attname / calling Field.get_attname(). Any custom field or introspection code that referenced these attributes must switch to field.name (the Python attribute) or field.column (the database column).
  • Foreign keys declared with db_constraint=False now issue a query on first access (previously: no query, partial instance). If you relied on accessing a constraint-less FK without a query, the behavior is intentionally stricter — a stale key now fails fast with RelatedObjectDoesNotExist instead of yielding a phantom instance that broke only on field access. Use select_related() to load these up front in loops.
  • .only() / .defer() field lists: deferred non-FK columns no longer load one-at-a-time. If you .defer("body") and later read instance.body, the resulting query now refreshes every still-deferred column in the same instance — defer only the columns you do not intend to read.

0.103.5 (2026-05-19)

What's changed

  • Pooled connections are now validated on checkout. The connection pool runs check_connection on each getconn(), so a connection closed server-side while idle in the pool (a server or pooler idle timeout) is discarded and replaced rather than handed out dead. This closes a class of OperationalError: the connection is closed failures on the first query of a request after an idle period. (31ad84f423)
  • Standardized __all__ in forms.py to a list for consistency with the rest of the codebase. (64ee8a4de0)

Upgrade instructions

  • No changes required.

0.103.4 (2026-05-12)

What's changed

  • postgres.duplicate_indexes preflight check now skips partial indexes (those with a condition=). Previously a bare Index(fields=[fk]) carried for FK coverage was flagged as redundant with a partial composite Index(fields=[fk, ...], condition=Q(...)), contradicting the postgres.missing_fk_index check. The two warnings are now mutually consistent — partials don't cover full-column lookups, so they can't shadow a single-column index. (1e8a3f72db)

Upgrade instructions

  • No changes required. Apps that were silencing postgres.duplicate_indexes to work around the false positive can drop the silence.

0.103.3 (2026-05-08)

What's changed

  • QuerySet.__repr__ no longer issues SQL for unevaluated querysets. Error reporters (Sentry, pdb, exception templates) call repr() on stack-frame locals to build error events, and a surprise SELECT … LIMIT 21 inside an exception path is a known footgun — especially on production where the underlying query may itself be the cause of the failure. Unevaluated querysets now render as <QuerySet [unevaluated]>; once the result cache is populated, repr() formats from the cache as before (still truncating past 20 rows). (d8b7c4ec30)
  • ManyToManyField.value_from_object no longer calls .all() on a manager. The form-roundtrip path went through getattr(obj, attname).all(), which on the M2M manager dispatched to its descriptor and could trigger an unintended fetch/SQL path. Switched to the manager's .query queryset, which is the documented entry point. (83da86b19b)

Upgrade instructions

  • No changes required. Note that interactive shell users who relied on repr() triggering evaluation (e.g., typing qs at the prompt to print rows) will now see <QuerySet [unevaluated]> — call list(qs) or slice it to materialize.

0.103.2 (2026-05-06)

What's changed

  • postgres.missing_fk_indexes preflight now recognizes bare-column leading expressions. A UniqueConstraint(F("team"), Lower("email")) declared via the expressions= API previously slipped past the model-level check — the preflight only inspected fields=, so it warned about a missing FK index even though the underlying btree's leading column was the real team attribute and the live diagnose check correctly recognized coverage. The preflight now extracts the leading column from F(...) and OrderBy(F(...)) expressions to match Postgres' actual coverage semantics. (ae3880098f)
  • Both FK-coverage checks now skip partial indexes. A partial index like Index(fields=["team"], condition=Q(deleted_at__isnull=True)) only satisfies queries whose predicate implies the partial-index WHERE, so an unfiltered FK lookup or cascade delete still sequential-scans. The preflight (_fk_covered_field_names) and the live diagnose check (check_missing_fk_indexes) both used to silently treat partial indexes as covering — now they don't, so the warning fires on the real coverage gap. The narrow WHERE fk IS NOT NULL case is conservatively also treated as not covering; users wanting guaranteed FK coverage should add a regular non-partial Index(fields=[...]). (b1d13a6b42)
  • New is_partial property on Index and UniqueConstraint returning condition is not None, for callers that need the distinction. (b1d13a6b42)

Upgrade instructions

  • No changes required. After upgrading, postgres.missing_fk_indexes may surface previously-undetected FK coverage gaps (where the only matching index was partial) and may suppress previously-warning false positives (where the only matching index used expressions=). Add a non-partial Index(fields=["fk"]) to silence the warning in the partial-index case.

0.103.1 (2026-05-06)

What's changed

  • postgres.duplicate_indexes now flags exact-column duplicates, not just prefix-redundancy. Previously the check required the redundant index to be strictly shorter than the index covering it, so an Index(fields=["x"]) declared next to a same-column UniqueConstraint(fields=["x"]) slipped past — even though the unique-backed btree already covers the same lookups and enforces uniqueness. The check (and the matching preflight) now flag the non-unique side of a same-column pair. Two non-unique indexes on identical columns flag the alphabetically later name (deterministic). (253513b9)

Upgrade instructions

  • No changes required. After upgrading, plain postgres diagnose and plain preflight may surface previously-undetected duplicate indexes — drop the redundant Index(...) declaration on the model and run plain postgres sync.

0.103.0 (2026-05-06)

What's changed

  • New storage_parameters on model_options, managed by convergence. Declare per-table Postgres storage parameters (pg_class.reloptions) on the model — autovacuum tuning, fillfactor, TOAST options, anything you'd otherwise set with ALTER TABLE … SET (...) — and plain postgres sync reconciles them via instant catalog-only ALTER TABLE … SET / RESET (...) statements. Models are the source of truth: parameters set on the live table that aren't declared on the model get reset, matching how indexes and constraints work. TOAST parameters use a toast. prefix (toast.autovacuum_vacuum_scale_factor) and are stored on the toast relation. Storage parameters are not serialized into migrations. New public API: StorageParameterDrift, SetStorageParameterFix, ResetStorageParameterFix. (7fe40f72)
  • New table_bloat health check. Estimates per-table page-level bloat using the ioguix estimator (same heuristic as pghero). Complements vacuum_health: dead-tuple counts only show what autovacuum hasn't reclaimed yet, but a table that's been vacuumed regularly can still carry gigabytes of bloat because plain VACUUM marks pages reusable without returning space to the OS. Surfaces tables with both >100 MB wasted bytes AND >25% bloat ratio, with pg_repack / pg_squeeze / VACUUM FULL suggestions. Cross-check caveats now link vacuum_health and table_bloat findings on the same table. (ee9dc1d5)
  • Tightened index_bloat thresholds. Now requires both >100 MB wasted bytes AND >30% bloat ratio (was 10 MB only). The previous floor surfaced too many small, healthy indexes; the higher percentage bar reflects that REINDEX CONCURRENTLY is cheap so it's only worth flagging genuinely degraded indexes. Results are also capped at 100 rows per check. (ee9dc1d5)

Upgrade instructions

  • No changes required. To opt into the new storage_parameters API, declare them on model_options = postgres.Options(storage_parameters={...}) and run plain postgres sync. After upgrading, expect previously-noisy index_bloat findings to disappear (now require ≥100 MB AND ≥30%) and the new table_bloat check to appear in plain postgres diagnose.

0.102.0 (2026-05-05)

What's changed

  • Model.query is now bound to Self (PEP 673), so subclasses specialize automatically. User.query types as QuerySet[User] and User.query.first() as User | None without per-model annotations. Custom QuerySet subclasses (e.g. TaskQuerySet) are still preserved by the existing Self-returning descriptor. Now-redundant cast(T, ...) wrappers in the FK/M2M related managers are gone — self.model.query.create(...) already types as T. (0f5b2f66)
  • Convergence diffs are now canonicalized through Postgres pg_get_* round-trips on a session-private temp table instead of sqlparse-based text normalization. Both sides of every index/constraint/default comparison are deparsed by Postgres itself, eliminating false-positive drift from formatting differences. Adds ReadOnlyConnectionError when the round-trip can't get DDL. The normalize_check_definition, normalize_default_sql, normalize_expression, normalize_index_definition, and normalize_unique_definition helpers are removed from plain.postgres.introspection, and sqlparse is no longer a dependency. (4b42b4d1)
  • CheckConstraint.validate() now exits early when a referenced field is missing from the value map, deferring to the field-level error that excluded it. Calling full_clean() on a model with both choices= and a CheckConstraint referencing the same field used to crash with AssertionError: Field lookups require a model — the choice error excluded the field, then constraint validation tried to resolve the missing annotation. The walker is exposed as the public CheckConstraint.referenced_fields() method. (d13f47d1)
  • Tightened class-level annotations on Query.select and friends, Operation.atomic, and ChoicesField.choices for ty 0.0.33; replaced the ModelState fields_cache descriptor with a plain __init__. (4b9d1db1)
  • Exposes __version__ from importlib.metadata on plain.postgres. (c6cf6edb)

Upgrade instructions

  • If you imported any of normalize_check_definition, normalize_default_sql, normalize_expression, normalize_index_definition, or normalize_unique_definition from plain.postgres.introspection, those helpers are gone — use pg_get_indexdef / pg_get_constraintdef directly or rely on the new convergence round-trip path.

0.101.0 (2026-04-30)

What's changed

  • Validate CHECK constraints in the same converge run that adds them. AddConstraintFix now runs ALTER TABLE ... ADD CONSTRAINT ... NOT VALID followed by ALTER TABLE ... VALIDATE CONSTRAINT in a single apply(). The add is catalog-only (brief lock) and validate uses SHARE UPDATE EXCLUSIVE (doesn't block writes), so there's no benefit to deferring validation to a later run. Existing rows are checked before convergence reports success — previously, a CHECK constraint could be added in NOT VALID state and the validation step was its own follow-up fix. (dc7eb8d3c2b7)
  • plain-postgres rule references updated for the simpler plain docs CLI (no more --section). (e03c3bd8b6d3)

Upgrade instructions

  • No changes required. The next plain postgres sync (or scheduled converge run) on a database with pending CHECK constraints will now both add and validate them in one step instead of two.

0.100.0 (2026-04-28)

What's changed

  • Replaced the violation_error_message / violation_error_code triad on CheckConstraint and UniqueConstraint with a single violation_error kwarg. The new kwarg accepts anything ValidationError(...) accepts — a string, a {field: message} dict, a list, or a fully-formed ValidationError — so message text, error code, and field routing all live on one object. (8650edc22c09)
  • Single-field UniqueConstraint now auto-routes flat errors to its field. A violation_error="That email is taken." on UniqueConstraint(fields=["email"]) lands on the email form field instead of NON_FIELD_ERRORS. A caller-built ValidationError({"other_field": ...}) is preserved as-is. (8650edc22c09)
  • Dropped the hardcoded code == "unique" routing in validate_constraints(). Routing is now uniform across constraint types: dict-form errors land on fields, flat errors go to NON_FIELD_ERRORS. (8650edc22c09)
  • Removed the %(name)s interpolation magic on BaseConstraint.default_violation_error_message. The default message still includes the constraint name; users wanting runtime interpolation can pass ValidationError(..., params={"name": ...}). (8650edc22c09)
  • Documented that save() runs full_clean() by default (clean_and_validate=True); fixed the README's Validation example which previously implied users had to override save() to call full_clean() manually. (8650edc22c09)

Upgrade instructions

  • Replace violation_error_message="..." and violation_error_code="..." on CheckConstraint / UniqueConstraint with a single violation_error=ValidationError("...", code="...") (or a string if you only need the message).
  • If you relied on the implicit single-field-unique routing for a constraint with a custom violation_error_code, no change needed — single-field UniqueConstraint still auto-routes by default.
  • If you used %(name)s in violation_error_message, switch to ValidationError("...", params={"name": "your_constraint_name"}) or hardcode the name.

0.99.1 (2026-04-26)

What's changed

  • Duplicate-index check now catches expression-prefix duplicates. Previously the check excluded any index containing expressions (it compared raw indkey/indclass arrays), so a redundant (LOWER(email)) alongside (LOWER(email), team_id) was missed. The query now compares per-column pg_get_indexdef(indexrelid, k, false) text — canonical output that includes column name/expression, opclass, collation, and sort order — and checks pg_am.amname separately so a hash and btree on the same column don't false-match. (4bd8a713649f)

Upgrade instructions

  • No changes required.

0.99.0 (2026-04-23)

What's changed

  • Reworked plain postgres diagnose around tiered findings. Warnings are now reserved for things the user can fix by editing model code or taking an app-level action — every warning carries a copy-paste fix or a model-file pointer (app/path.py :: ModelName). Noisy one-off signals (cache/index hit ratios, XID wraparound, connection saturation, pg_stat_statements availability, stats reset age) render as informational context; DB-state facts whose remedies live outside Plain (stats freshness, vacuum health, index bloat) render as operational context instead of warnings. Added --verbose to expand every check, and --all still includes installed-package tables. (26abb6cbc075)
  • New diagnostic checks: stats_freshness (uses pg_class.reltuples so it survives pg_stat_reset), index_bloat (ioguix btree estimator, public schema only), missing_index_candidates (seq-scan heuristics with per-query drill-down from pg_stat_statements), blocking_queries (wait age from pg_locks.waitstart, PG 14+), and long_running_connections (xact age for idle-in-transaction). Findings include cross-check caveats — e.g. an unused_indexes finding on a table that's also flagged by stats_freshness or vacuum_health now carries a warning that dropping the index may be premature. (26abb6cbc075)
  • Permission-safe probes. Checks that may hit permission errors (pg_stat_statements, pg_stat_activity, pg_locks) now wrap their queries in cursor.connection.transaction() so a failure rolls back cleanly in either autocommit or transaction mode without cascade-failing later checks. (26abb6cbc075)
  • Refactored internals. The 1800+ line introspection/health.py split into an introspection/health/ package along natural seams (types, ownership, context, helpers, checks grouped by structural/cumulative/snapshot, and a runner). Public re-exports are unchanged. (26abb6cbc075)
  • Adapter annotations use Response after plain 0.135.0 merged ResponseBase into Response. (f5007281d7fa)

Upgrade instructions

  • Requires plain>=0.135.0.
  • No code changes required. If you parse plain postgres diagnose --json, note the new tier field on each finding ("structural", "cumulative", "snapshot", or "operational") — operational findings still carry status: "warning" but the CLI renders them as context rather than as alarming warnings.

0.98.0 (2026-04-22)

What's changed

  • Pool-backed connections via psycopg_pool.ConnectionPool. A new sources abstraction routes DatabaseConnection through either a long-lived PoolSource (runtime) or a DirectSource (management / one-shot). Each request checks a connection out of the pool on first use and returns it when the HTTP request finishes. psycopg>=3.2 and psycopg-pool>=3.2 are now declared as hard dependencies. (2a51b25)
  • New DatabaseConnectionMiddleware (required). Add "plain.postgres.DatabaseConnectionMiddleware" to MIDDLEWARE — it's what returns the pooled connection at the end of each request. For StreamingResponse / AsyncStreamingResponse the connection is returned after the body fully drains, so generators that lazily query the database (e.g. Model.query.iterator()) keep their cursor alive until the last chunk is sent. A new postgres.middleware_installed preflight check errors if the middleware is missing. (2a51b25)
  • Connection settings replaced with pool settings. POSTGRES_CONN_MAX_AGE and POSTGRES_CONN_HEALTH_CHECKS are gone. Tune the pool with POSTGRES_POOL_MIN_SIZE (default 4), POSTGRES_POOL_MAX_SIZE (default 20), POSTGRES_POOL_MAX_LIFETIME seconds (default 3600.0), and POSTGRES_POOL_TIMEOUT seconds (default 30.0). Each is also available as a PLAIN_POSTGRES_POOL_* environment variable. (2a51b25)
  • plain.postgres.connections module removed. get_connection, has_connection, use_management_connection, and read_only now live in plain.postgres.db (the underscore-less counterpart). (2a51b25)
  • read_only() is now pgbouncer-safe. It opens a single BEGIN READ ONLY transaction for the block (previously a session-level SET default_transaction_read_only = on). Nested atomic() blocks become savepoints of the outer read-only transaction. Entering read_only() inside an existing atomic() block now raises TransactionManagementError. The old DatabaseConnection.set_read_only() method is removed. (ebdec30)
  • Added OTel pool + rowcount metrics and semconv polish. Wires the db.client.connection.* metric family (count, max, idle.min/max, pending_requests, wait_time, use_time, timeouts) from the pool's stats and the acquire/release path, plus db.client.response.returned_rows for SELECT queries including streamed iterators. Query spans now carry server.address / server.port alongside network.peer.*, and the tracer/meter are tagged with the plain.postgres package version for InstrumentationScope. (61278d5)
  • Moved psql CLI orchestration off DatabaseConnection. New postgres_cli_args / postgres_cli_env helpers in plain.postgres.database_url build the arguments and environment for psql, pg_dump, etc.; plain postgres shell and the plain-dev backup client both use them. DatabaseConnection.runshell() and executable_name are gone. (5b4a488)
  • Removed dead connection-lifecycle plumbing. close_if_unusable_or_obsolete, close_if_health_check_failed, closed_in_transaction, is_usable, health_check_enabled, health_check_done, close_at, _maintenance_cursor, and DatabaseConnection.from_url are gone — the pool handles recycling, health checks, and URL parsing. close() now validates there's no open atomic block instead of silently deferring. (044e942, 2a51b25)
  • Inlined pg_version and removed temporary_connection(). The single caller now reads connection.info.server_version directly; temporary_connection() has no remaining users. (319f6ac)
  • APIResult shorthand returns moved out of View. Any internal views that relied on dict/int shorthand now wrap their returns in JsonResponse / Response(status_code=...) to match plain 0.134.0's narrower View handler return type. (1935f3f)
  • Adapter registration extracted to plain.postgres.adapters. PlainRangeDumper and get_adapters_template() moved out of connection.py into their own module.

Upgrade instructions

  • Requires plain>=0.134.0.

  • Add the middleware to app/settings.py:

    MIDDLEWARE = [
        "plain.postgres.DatabaseConnectionMiddleware",
        # ...the rest of your middleware
    ]
    

    Place it near the top so downstream middleware can use the database inside before_request / after_response and still have the connection returned cleanly. Preflight will error if it's missing.

  • Replace POSTGRES_CONN_MAX_AGE / POSTGRES_CONN_HEALTH_CHECKS with the pool settings (POSTGRES_POOL_MIN_SIZE, POSTGRES_POOL_MAX_SIZE, POSTGRES_POOL_MAX_LIFETIME, POSTGRES_POOL_TIMEOUT) or remove them to take the defaults.

  • Update imports from plain.postgres.connections to plain.postgres.db:

    # Before
    from plain.postgres.connections import (
        get_connection,
        read_only,
        use_management_connection,
    )
    
    # After
    from plain.postgres.db import get_connection, read_only, use_management_connection
    
  • If you called DatabaseConnection.set_read_only(True) for a sticky read-only session, switch to the read_only() context manager around the block you want read-only. If you need session-level enforcement outside a transaction, open a DirectSource connection yourself and issue SET default_transaction_read_only = on on it.

  • If you entered read_only() inside an atomic() block, move read_only() to the outer position — it now owns the transaction. Nested atomic() blocks inside read_only() are fine (they become savepoints).

  • If you pinned psycopg via your own dependency, make sure it's >=3.2, and add psycopg-pool>=3.2 if you were installing psycopg without extras.

0.97.0 (2026-04-21)

What's changed

  • Replaced individual POSTGRES_* connection fields with a single POSTGRES_URL setting. POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DATABASE, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_OPTIONS, and POSTGRES_TIME_ZONE are gone — configure the connection with one URL (e.g. postgresql://user:pass@host:5432/db?sslmode=require). DATABASE_URL is still read as a fallback. Set the URL to none to explicitly disable the database (e.g. during Docker image builds). (770a74606463)
  • Added POSTGRES_MANAGEMENT_URL for routing DDL through a separate connection. When set, plain migrations create|apply|list|prune|squash, plain postgres sync|converge|schema|diagnose|drop-unknown-tables|shell connect through this URL instead of POSTGRES_URL. Use it to bypass transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, pgbouncer) for schema changes, long transactions, and pg_dump. A new use_management_connection() context manager routes custom code through the same connection. When unset, all commands use POSTGRES_URL — no behavior change for existing apps. (d1cc9630d049)
  • Extracted the test-database lifecycle off DatabaseConnection. Test setup/teardown now lives in plain.postgres.test instead of coupling it to the runtime connection class. (ea67f82c746c)
  • Removed thin psycopg re-export wrappers. Internal code now imports directly from psycopg rather than the redundant Plain-level passthroughs. (d1cb74100e0d)

Upgrade instructions

  • Replace individual POSTGRES_* settings with POSTGRES_URL in app/settings.py (or PLAIN_POSTGRES_URL in the environment). For example:

    # Before
    POSTGRES_HOST = "localhost"
    POSTGRES_PORT = 5432
    POSTGRES_DATABASE = "myapp"
    POSTGRES_USER = "app"
    POSTGRES_PASSWORD = "secret"
    
    # After
    POSTGRES_URL = "postgresql://app:secret@localhost:5432/myapp"
    

    Apps that already set DATABASE_URL in the environment don't need any change.

  • If POSTGRES_OPTIONS or POSTGRES_TIME_ZONE were set, move them into the URL as query parameters (e.g. ?application_name=web&timezone=UTC).

  • If you run behind a transaction-mode pooler, consider setting POSTGRES_MANAGEMENT_URL to a direct-to-Postgres connection string so plain migrations and plain postgres sync can issue DDL.

0.96.0 (2026-04-17)

What's changed

  • DateTimeField gained create_now=True / update_now=True kwargs; auto_now_add and auto_now are removed. create_now=True installs a persistent DEFAULT STATEMENT_TIMESTAMP() column default — raw-SQL inserts now get a value, not just ORM-driven ones. update_now=True stamps the column on every save() via pre_save. Preflight requires update_now=True to be paired with create_now=True or allow_null=True so existing rows have a backfill path. default= is no longer accepted on DateTimeField. (5d145e4, a44e5ec, 091bac7)
  • UUIDField gained generate=True; default=GenRandomUUID() is no longer accepted. generate=True installs DEFAULT gen_random_uuid() on the column, so Postgres produces a fresh UUID per row (raw-SQL inserts included). (a44e5ec)
  • Added RandomStringField(length=N) for per-row DB-generated random hex strings. Backed by a DEFAULT that slices gen_random_uuid()::text; use in place of Python default=secrets.token_hex callables for tokens, slugs, and short IDs. Alphabet is always hex — an earlier draft accepted alphabet= but it was dropped because the generated expression grew to ~4 KB for a 40-char token. (34858ab, 0918702)
  • Added GenRandomUUID() function. Exported at plain.postgres.functions.GenRandomUUID. No longer valid as default=; use UUIDField(generate=True) or reference it in annotations/expressions. (da58230)
  • Callable default= is banned on model fields. default=uuid.uuid4, default=secrets.token_hex, default=dict, default=lambda: ..., etc. raise TypeError at field construction. Use DB-side generation (UUIDField(generate=True), RandomStringField, DateTimeField(create_now=True)) or a static literal. Empty-collection defaults use literal {} / [] — the value is deep-copied on each get_default() call. (091bac7)
  • Literal default=X values now persist as column DEFAULT in the catalog and are reconciled by convergence. Previously default= was Python-side only; now it is compiled to a DDL DEFAULT <literal> clause. Raw-SQL INSERTs get the default, and drift is detected if someone edits it out-of-band. (c59473d, 6ed95fe, 161c7f9)
  • Column nullability and DEFAULT transitions now go through convergence, not the schema editor. AlterField is a no-op when only allow_null or default= changed; plain postgres sync applies the change with online-safe DDL (CHECK NOT VALID + VALIDATE + SET NOT NULL for NOT NULL flips; catalog-only SET/DROP DEFAULT for default changes). The old 4-way NULL → NOT NULL backfill in the schema editor is gone — if a column has NULL rows, convergence now blocks with guidance instead of silently backfilling. (3e10ab2, c59473d)
  • Every framework-issued DDL statement now emits SET LOCAL lock_timeout and, where relevant, SET LOCAL statement_timeout. Defaults are 3s each and apply to both migration operations and convergence fixes. Non-blocking operations (CREATE INDEX CONCURRENTLY, VALIDATE CONSTRAINT) skip statement_timeout. Configure via new settings POSTGRES_MIGRATION_LOCK_TIMEOUT, POSTGRES_MIGRATION_STATEMENT_TIMEOUT, POSTGRES_CONVERGENCE_LOCK_TIMEOUT, POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT (all PLAIN_POSTGRES_* env-var compatible). RunSQL(no_timeout=True) opts a single operation out — useful for batched backfills that manage their own timeouts. (11d903b)
  • The autodetector rejects unsafe column type changes. Base-type changes outside a lossless widening allowlist (smallint → integer, smallint → bigint, integer → bigint) raise MigrationSchemaError with scaffold guidance instead of emitting an AlterField that would compile to a blind ALTER COLUMN ... TYPE ... USING col::newtype. Parameter-only changes (e.g. max_length) and the widening allowlist still auto-generate. (073a9af)
  • The autodetector rejects adding a NOT NULL column without a default. Previously Plain prompted interactively for a one-shot value; now the autodetector errors out with two remediation options: declare a default=, or add the field as nullable, backfill, and drop allow_null=True via convergence. The MigrationQuestioner.ask_not_null_* prompts are gone. (091bac7)
  • AddField / AlterField no longer accept preserve_default. The argument is removed from both operation classes and from ProjectState.add_field / alter_field. Existing migration files that pass it will fail to load — regenerate them or remove the kwarg. (c0a117f)
  • Backslashes are banned in string default= values. default=r"C:\path" raises ValueError at construction to prevent spurious DEFAULT drift on every convergence run. (f8b6227)
  • choices= is now only accepted on TextField (and TimeZoneField). Other fields (IntegerField, BooleanField, etc.) reject choices= at call time. (01584dc)
  • Removed IntegerChoices and the Choices base class. Only TextChoices remains; it now subclasses str, enum.Enum directly. (96acf13)
  • max_length= is now only accepted on TextField, BinaryField, and EncryptedTextField. Other fields reject it. (aaa0fb6)
  • default= is no longer accepted on ForeignKeyField, ManyToManyField, BinaryField, EncryptedTextField, or EncryptedJSONField. (60299dc, 99ba5c2)
  • ManyToManyField signature is now explicit — it rejects required=, allow_null=, default=, and validators= with TypeError. (be7fd86)
  • Removed error_messages= from model fields and ModelForm.Meta. Form-field error_messages is unchanged; this only affects the model layer. (4dee5ec)
  • PrimaryKeyField takes no arguments. It is always bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL. Removed kwargs for required, allow_null, default, and validators; the type stub now matches the runtime signature. (ca122c9, 0ecd71e)
  • plain postgres sync --check now prints pending work. Previously --check only exited non-zero; it now enumerates pending migrations, convergence items, and blocked items with guidance. (0de289d)
  • Fixed index drift false positive for DESC / NULLS FIRST|LAST columns. Indexes like Index(fields=["-created_at"]) were rebuilt on every postgres sync because the introspection parser misread the sort direction as an opclass. (07cb500)
  • Fixed Field.deconstruct() over-shortening import pathsplain.postgres.fields.<submod>.X now shortens to plain.postgres.X only when X is actually re-exported at the top level. (34858ab)
  • ModelForm no longer marks DB-expression-default and auto-filled fields as required. Fields with db_returning=True (e.g. create_now=True, generate=True, RandomStringField) and auto_fills_on_save=True (update_now=True) produce form fields with required=False and preserve the DATABASE_DEFAULT sentinel through construct_instance so INSERT emits DEFAULT instead of NULL on empty submissions. modelfield_to_formfield now returns None for non-column-backed fields (M2M, etc.). (6ed95fe)
  • Internal restructuring. Field is split into ColumnFieldDefaultableFieldChoicesField with kwargs scoped to the fields that actually accept them. plain.postgres.fields.__init__ is split into per-type modules (base, text, numeric, temporal, boolean, binary, uuid, network, duration, primary_key). PrimaryKeyField moved off the BigIntegerField → IntegerField chain onto ColumnField[int] directly. non_db_attrs renamed to non_migration_attrs. Removed dead Django-era internals: SubqueryConstraint, MultiColSource, multi-column FK machinery, multi-table-inheritance UPDATE machinery, Field.description, Field.value_to_string(), Field.get_limit_choices_to(). (476e1ae, 9ed8cc6, ca122c9, 21cf85f, 18080ca, 9d4ff49, 07b5f0b, 176f56e, 16e4fcd, cb98bfa)

Upgrade instructions

  • Replace auto_now_add=True with create_now=True on every DateTimeField.
  • Replace auto_now=True with update_now=True. If the field was NOT NULL, also set create_now=True (or allow_null=True) — preflight will fail otherwise.
  • Replace DateTimeField(default=timezone.now) / default=Now() with DateTimeField(create_now=True). DateTimeField(default=...) is no longer accepted.
  • Replace UUIDField(default=uuid.uuid4) and UUIDField(default=GenRandomUUID()) with UUIDField(generate=True). UUIDField(default=...) is no longer accepted.
  • Replace default=secrets.token_hex / default=secrets.token_urlsafe with RandomStringField(length=N) (hex output only).
  • Replace default=dict / default=list with default={} / default=[]. Any other callable passed as default= will now raise TypeError.
  • Remove choices= from non-text fields (IntegerField, BooleanField, etc.).
  • Replace IntegerChoices usages with TextChoices or a plain enum.IntEnum. Choices (the base class) is also gone.
  • Remove max_length= from any field that isn't TextField, BinaryField, or EncryptedTextField.
  • Remove default= from ForeignKeyField, BinaryField, EncryptedTextField, and EncryptedJSONField.
  • Remove required=, allow_null=, default=, and validators= from ManyToManyField — its signature is now explicit (to, through, through_fields, related_query_name, limit_choices_to, symmetrical).
  • Remove kwargs from PrimaryKeyField() — it no longer accepts any.
  • Remove error_messages= from model-level fields and ModelForm.Meta. (Form-field error_messages on standalone form fields is unchanged.)
  • Escape backslashes in string default= values. default="C:\\path" is fine; default=r"C:\path" now raises at construction.
  • Edit or regenerate migration files that pass preserve_default=... to AddField / AlterField — the kwarg was removed.
  • Rename non_db_attrs to non_migration_attrs in any custom field subclass.
  • If your migrations hit the new 3s statement_timeout against a large dev/staging DB, raise it for that run via PLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s, or pass RunSQL(sql, no_timeout=True) on individual long-running operations.
  • Run plain postgres sync after upgrading to let convergence install persisted column DEFAULTs on existing tables.

0.95.0 (2026-04-14)

What's changed

  • Deletes now run as a single DELETE statement and cascade through Postgres ON DELETE clauses. The Python Collector (which walked relationships in Python to fire per-table DELETEs) has been removed. Model.delete() and QuerySet.delete() issue one statement and let Postgres do the cascading via the FK actions installed by convergence. The old Collector path required N queries per cascade; the new path requires exactly one. (29e10dba51d9)
  • Model.delete() and QuerySet.delete() now return int (the directly-deleted row count). They previously returned a (count, {label: count}) tuple — Postgres does not report cascaded counts, and the per-label dict was Collector-only bookkeeping. (29e10dba51d9)
  • on_delete constants are now OnDelete instances, not bare functions. ForeignKeyField rejects any non-OnDelete value at construction, and the declared action is emitted as the FK's Postgres ON DELETE clause. (29e10dba51d9)
  • Removed PROTECT, SET(), SET_DEFAULT, ProtectedError, and RestrictedError. PROTECT and SET(callable) had no Postgres equivalent (prefer RESTRICT). SET_DEFAULT was removed because Plain does not currently persist Python model defaults as DB-level column defaults — emitting ON DELETE SET DEFAULT would set children to NULL on bypass-the-ORM deletes, contradicting the model's intent. SET_DEFAULT will return once DB-level defaults are supported. RESTRICT now surfaces as psycopg.errors.IntegrityError directly. (670dab428ad2, 29e10dba51d9)
  • Renamed DO_NOTHING to NO_ACTION to match Postgres's SQL term. No behavior change. (5fcf8aa9ced3)
  • Convergence now owns FK on_delete drift. plain postgres sync introspects pg_constraint.confdeltype, compares it to the declared on_delete, and replaces the constraint when they drift. Replacement uses ADD CONSTRAINT … NOT VALID + VALIDATE to minimize lock time. Existing databases auto-upgrade on their next sync. (211840197e1e)
  • Preflight rejects db_constraint=False with a non-NO_ACTION on_delete. Without a constraint there is no place to attach a deletion action. (29e10dba51d9)
  • Tightened types. on_delete is now typed as OnDelete everywhere (was Any). ForeignKeyField.remote_field narrows to ForeignKeyRel so remote_field.on_delete is non-optional. ForeignObjectRel, ForeignKeyRel, and ManyToManyRel __init__ are kwarg-only. (29e10dba51d9)
  • Known limitation: data migrations + cascading deletes. On a fresh migrations apply, FK constraints don't exist yet (they're added by convergence in step 3 of postgres sync). A RunPython data migration that calls .delete() on a parent with cascading children will orphan the children, and the subsequent convergence VALIDATE will fail. Existing databases are unaffected. Documented with a workaround in the Postgres README (delete children explicitly first, or use RunSQL). (29e10dba51d9)
  • Rewrote the Schema management docs to distinguish convergence, structural migrations, and data migrations by who authors the change and whether the framework can guarantee a safe apply. Added a per-change-type table covering safe apply patterns (CREATE INDEX CONCURRENTLY, NOT VALID + VALIDATE, etc.) and split the Migrations section into “Structural migrations” and “Data migrations.” (8ae39e2cef78, 49d2b2452dea)

Upgrade instructions

  • Adapt callers of .delete(). .delete() now returns an int, not a (count, by_label) tuple.
    • Before: count, _ = qs.delete() or count = qs.delete()[0]
    • After: count = qs.delete()
  • Rename DO_NOTHING to NO_ACTION at all import and usage sites. Regenerate or hand-edit migration files that reference DO_NOTHING.
  • Replace PROTECT with RESTRICT. Catch psycopg.errors.IntegrityError instead of ProtectedError / RestrictedError.
  • Replace SET(callable) usages. There is no one-line equivalent — the Python-callable path doesn't exist in Postgres. Either switch to a supported action (SET_NULL, RESTRICT, CASCADE, NO_ACTION) or handle the affected rows explicitly before deletion.
  • Replace SET_DEFAULT usages. Pick a different on_delete, or set the default explicitly in application code before deletion. SET_DEFAULT will return once Plain persists column defaults.
  • Run plain postgres sync after upgrading. Convergence will install the correct ON DELETE clauses on existing FKs — no migration file, no manual step.
  • If you set db_constraint=False on a FK with a non-NO_ACTION on_delete, change the action to NO_ACTION — preflight will now fail otherwise.
  • Review RunPython migrations that call .delete() on parents with cascading children. On a fresh migrations apply before convergence runs, children become orphans and break the subsequent VALIDATE. Delete children explicitly first, or use RunSQL.

0.94.2 (2026-04-13)

What's changed

  • Updated internal references to use the fixed app.users.models.User convention. (0861c9915cb6)
  • Migrated type suppression comments to ty: ignore for the new ty checker version. (4ec631a7ef51)

Upgrade instructions

  • No changes required.

0.94.1 (2026-04-05)

What's changed

  • Removed deprecated db.user attribute from query spans. The attribute was removed from the OTel semconv with no replacement. (b56a9edc9c7d)
  • Switched DbSystemValues to stable DbSystemNameValues. Migrated from the deprecated opentelemetry.semconv.trace module to the stable opentelemetry.semconv.attributes.db_attributes. (b56a9edc9c7d)
  • Added error.type attribute to query spans on exceptions. Set to the fully-qualified exception class name (e.g. psycopg.errors.UniqueViolation) for queryable error grouping. (b56a9edc9c7d)
  • Removed set_status(OK) from query spans. Per the OTel spec, instrumentation libraries should leave span status as Unset on success. (b56a9edc9c7d)

Upgrade instructions

  • No changes required.

0.94.0 (2026-04-02)

What's changed

  • Undeclared indexes and constraints are now automatically dropped by postgres sync and postgres converge. Models are the source of truth — if an index or constraint exists in the database but isn't declared on any model, convergence removes it. The --drop-undeclared flag has been removed from both commands. (a74b6ab30c14)

Upgrade instructions

  • Remove --drop-undeclared from any scripts or Procfiles that use plain postgres sync or plain postgres converge. Undeclared objects are now dropped automatically.

0.93.1 (2026-04-02)

What's changed

  • Fixed F.deconstruct() failing with "Could not find object F in plain.postgres". F, Value, Func, and other expression classes had @deconstructible paths pointing to plain.postgres but weren't exported from it, breaking migration serialization. F is now exported from plain.postgres (alongside Q), and the other classes use their actual module paths. (5fdcb040)

  • Fixed false-positive convergence mismatches for expression-based unique constraints. PostgreSQL's pg_get_indexdef adds type casts (e.g. lower((slug)::text)) and the ORM wraps each expression in parentheses — normalize_expression() now strips both, preventing spurious "definition changed" errors during postgres sync. (b734205268)

  • Removed constraints and indexes from migration options. These were serialized into migration files but never used during execution — convergence reads from the live model class. Removing them eliminates unnecessary serialization of complex expressions like Lower() and reduces migration file noise. (82e5a880)

Upgrade instructions

  • No changes required. Existing migrations with constraints/indexes in their options will continue to load fine.

0.93.0 (2026-04-01)

What's changed

  • Added db.client.operation.duration OTel histogram for database query timing. Every query executed through db_span() now records its duration as an OpenTelemetry histogram metric, following the semantic conventions for database client metrics. Attributes include db.system.name, db.operation.name, and db.collection.name. Without a configured MeterProvider, this is a no-op with zero overhead. (56c2f993b88c)

Upgrade instructions

  • No changes required.

0.92.1 (2026-03-30)

What's changed

  • Fixed false-positive "definition differs" for UniqueConstraint with expressions and conditions. A UniqueConstraint using both expressions (e.g. Lower("username")) and a condition (e.g. ~Q(username="")) was incorrectly flagged as drifted. PostgreSQL adds type casts (''::text) and the ORM adds extra parentheses around expressions — the old full-SQL-string comparison couldn't reconcile these differences. (e03f3496a49a)

  • Replaced fragile full-SQL comparison with structured comparison for all index and constraint definitions. Instead of normalizing entire CREATE INDEX statements, convergence now parses pg_get_indexdef output into components (expression text, columns, opclasses, WHERE clause) and compares each independently. Both regular indexes and unique constraints share a single comparison core. (e03f3496a49a)

Upgrade instructions

  • No changes required.

0.92.0 (2026-03-30)

What's changed

  • Foreign key constraints are now managed by convergence, not migrations. The schema editor no longer creates, drops, or alters FK constraints — convergence handles them declaratively using ADD CONSTRAINT ... NOT VALID followed by VALIDATE CONSTRAINT. FK constraint names are deterministic and match the old migration-generated names. (b2b968297fea, 8658be035a46)

  • NOT NULL enforcement is now managed by convergence. Column nullability drift is detected and fixed automatically — convergence uses the safe CHECK NOT VALID → VALIDATE → SET NOT NULL pattern to avoid long table locks. Columns with existing NULL rows are reported as blocked, requiring a backfill before convergence can proceed. (5ea3dc589453)

  • Managed type boundaries — convergence now distinguishes managed vs unmanaged index types and constraint types. Only btree/hash indexes and check/unique/FK constraints participate in drift detection and rename matching. Unmanaged types (GIN, GiST, BRIN, exclusion, trigger) are displayed for informational purposes but are never modified or reported as undeclared. (f123eae2fa56)

  • Unique constraint drift detection — convergence now compares unique constraint definitions (not just column lists), detecting behavioral changes like modified WHERE clauses, opclasses, or expressions. Index-only uniques (partial, expression, or opclass) are correctly handled through both pg_constraint and pg_index. (09b439e8448a)

  • Full index definition matching — index drift detection now compares normalized CREATE INDEX definitions instead of just column lists, catching changes to conditions, expressions, opclasses, and include columns. (70d7a6725498)

  • Removed dead index/constraint/deferred SQL infrastructure and primary-key transition code from the schema editor. (266b0635f0bf, 4a92f5479e4e)

  • Rewrote the introspection layer to mirror Postgres catalog structures — TableState now uses a unified constraints dict keyed by constraint name with ConType enum, replacing the separate unique_constraints, check_constraints, and foreign_keys dicts. (f123eae2fa56)

  • Expanded schema management documentation with a comprehensive overview of the migrations + convergence split, sync workflow, and convergence behavior. (57caeee5ff89)

Upgrade instructions

  • If you have custom code that interacts with TableState.unique_constraints, TableState.check_constraints, or TableState.foreign_keys, update it to use the unified TableState.constraints dict with ConType filtering instead.
  • FK constraints in existing databases are left as-is. New FKs will be created by convergence on the next postgres sync.
  • NOT NULL enforcement is automatic — postgres sync will detect and fix nullability drift. If columns have existing NULL rows, you'll need to backfill before convergence can apply NOT NULL.

0.91.1 (2026-03-29)

What's changed

  • Indented sync and converge sub-items under section headers for readability in environments without ANSI colors (e.g. Heroku deploy logs). (b6b494dcc698)
  • sync now uses MigrationExecutor directly instead of calling through the CLI layer, giving cleaner indented output. (b6b494dcc698)

Upgrade instructions

  • No changes required.

0.91.0 (2026-03-29)

What's changed

  • New postgres sync command — the primary command for both development and deployment. In DEBUG mode it creates migrations, applies them, and converges. In production it applies migrations and converges. Use --check in CI to verify the database is fully synced. (b026895edc4c, b348a5af0867)

  • Indexes and constraints are now managed by convergence, not migrations. The migration autodetector no longer generates AddIndex, RemoveIndex, RenameIndex, AddConstraint, or RemoveConstraint operations — these classes have been removed. Convergence (postgres sync or postgres converge) creates, renames, rebuilds, and validates indexes and constraints using safe strategies: CREATE INDEX CONCURRENTLY, NOT VALID + VALIDATE CONSTRAINT for check constraints, and CONCURRENTLY + USING INDEX for unique constraints. (c58b4ba1fec9, f6506d263f3f, 1f15538b008f)

  • Command renames: makemigrationsmigrations create, migratemigrations apply. The old top-level makemigrations and migrate shortcuts have been removed. (adf021688bf3)

  • Removed --backup flag from migrations apply — database backups have moved to plain-dev. (50773a50f674)

  • Removed PositiveIntegerField, PositiveBigIntegerField, and PositiveSmallIntegerField — use IntegerField, BigIntegerField, or SmallIntegerField with a CheckConstraint if you need positivity enforcement. The db_check pipeline has also been removed. (738a1efbca59)

  • Convergence overhaul — rewritten into analysis, planning, and execution layers. Now detects index/constraint renames, stale definitions, INVALID indexes, and NOT VALID constraints. Each fix is applied and committed independently so partial failures don't block subsequent fixes. The --prune flag has been renamed to --drop-undeclared, which distinguishes between indexes (non-blocking) and constraints (blocking) when undeclared objects remain. (987791d345cb, 66ac1152be0d, f2f46e1a6054, 5bb1472acf0f)

  • Fixed test database names exceeding Postgres's 63-character identifier limit. (4a8937ba2758)

Upgrade instructions

  1. Replace migrate with postgres sync in deploy scripts and CI. postgres sync applies migrations and runs convergence in a single step. For CI checks, use postgres sync --check instead of migrate --check / makemigrations --check. The lower-level commands are still available as migrations create and migrations apply.

  2. Remove index/constraint operations from migration files. Delete any AddIndex, RemoveIndex, RenameIndex, AddConstraint, and RemoveConstraint operations from your migration files — these classes no longer exist and will cause import errors. It's fine to leave a migration with operations = []. Indexes and constraints declared on your models will be created automatically by convergence.

  3. Replace PositiveIntegerField (and PositiveBigIntegerField, PositiveSmallIntegerField) with IntegerField (or BigIntegerField, SmallIntegerField) in both models and migration files. Add a CheckConstraint if you need to enforce positive values.

  4. Run plain postgres sync after upgrading to create indexes and constraints via convergence.

  5. If you used plain postgres backups, install plain-dev>=0.60.0 — backups have moved to plain dev backups.

0.90.0 (2026-03-28)

What's changed

  • Removed CharField — use TextField for all string fields. PostgreSQL treats varchar and text identically (same storage, same performance), so the distinction was unnecessary. TextField now accepts an optional max_length for Python-side validation via MaxLengthValidator, without affecting the database column type. (5062ee4dd1fd)
  • EmailField and URLField now extend TextField instead of CharField. Their default max_length values (254 and 200 respectively) have been removed — pass max_length explicitly if you need validation. (5062ee4dd1fd)
  • Simplified field class internals — removed the get_internal_type() method and 6 lookup dicts from dialect.py. Each field class now declares its SQL type directly via db_type_sql class attribute. String-based type comparisons replaced with isinstance() checks throughout. (3ffdebe22250)
  • Added postgres converge command — detects and fixes safe schema mismatches between models and the database. Currently handles character varyingtext conversions. (fe8cf3995e95)

Upgrade instructions

  • Replace CharField with TextField in model code (e.g. types.CharField(max_length=100)types.TextField(max_length=100))
  • Replace CharField with TextField in migration files (e.g. postgres.CharField(max_length=255)postgres.TextField(max_length=255))
  • If you subclass CharField, change the parent class to TextField
  • EmailField no longer defaults max_length=254 and URLField no longer defaults max_length=200 — remove these from migration files if present (e.g. postgres.EmailField(max_length=254)postgres.EmailField())
  • Run plain postgres converge to convert existing character varying columns to text (in development and production). The conversion is instant and safe — PostgreSQL treats them identically. Use --yes to skip confirmation in CI/deploy scripts.

0.89.2 (2026-03-27)

What's changed

  • Fixed schema command miscategorizing expression-based unique constraints as missing columns (93ab244416f8)
  • Used canonical Postgres type names in DATA_TYPES mapping, removing the _normalize_type helper (f581fe6009bd)
  • Moved diagnose/ module to introspection/, consolidated into 2 files, added schema introspection functions used by schema and drop-unknown-tables commands (86f7f5b85a87)
  • diagnose --json now exits 0 — the JSON data is the signal, not the exit code (86f7f5b85a87)
  • Added migration reset documentation for replacing migration history with a fresh 0001_initial (2fa6203379e9)
  • Updated form field references from CharField to TextField in model forms (4e29f5d6cade)
  • Changed CLI confirmation flags to --yes/-y across all commands (0af36e101f03)

Upgrade instructions

  • Requires plain>=0.129.0. If you use plain postgres diagnose --json exit codes in CI, note that it now always exits 0 — check the JSON output for issues instead.

0.89.1 (2026-03-26)

What's changed

  • Fixed schema command type mismatches for time, timestamp, and DecimalField types that caused false drift reports (187e39e3faeb)
  • Fixed schema command crash on expression-based unique constraints (e.g. UniqueConstraint with expressions instead of fields) (187e39e3faeb)
  • Improved 0.89.0 upgrade instructions with clearer ordering and step descriptions (a59062327ed5, c0520bdca709)

Upgrade instructions

  • No changes required.

0.89.0 (2026-03-25)

What's changed

  • Removed db_index from ForeignKeyField — FK fields no longer create indexes automatically. Declare an explicit Index(fields=["field"], name="...") for any FK column that needs one. The db_index parameter has been removed entirely. (061b97f5d538)
  • Removed Index.set_name_with_model() — the hash-based auto-naming machinery is gone. Index.name is now validated as non-empty at construction time. (9a4ecf8ac2f0)
  • Index/constraint name collision detection — preflight now checks index and constraint names together (they share the same Postgres namespace), catching cross-type collisions that would fail at migrate time. (292f8d6791d6)
  • New plain postgres schema command — shows expected DB schema from model definitions and compares it against the actual database. Detects column type mismatches, nullability drift, missing/extra columns, and orphan indexes. Use --check for CI (exits non-zero on drift). (ee336078483f)

Upgrade instructions

  1. Remove any db_index=False from FK fields in models and migration files — the parameter no longer exists.

  2. For each ForeignKeyField, check if it's covered by an explicit Index or UniqueConstraint (with the FK as the leading field). Most FK columns should have an index.

  3. If uncovered, add an explicit index:

    model_options = postgres.Options(
        indexes=[
            postgres.Index(name="myapp_mymodel_author_id_idx", fields=["author"]),
        ],
    )
    
  4. Run makemigrations. Before the AddIndex operation, add a RunSQL to drop the orphan auto-index left behind by the old db_index=True default:

    operations = [
        migrations.RunSQL('DROP INDEX IF EXISTS "myapp_mymodel_author_id_abc12345"'),
        migrations.AddIndex(...),
    ]
    

    The old auto-index name follows the pattern {table}_{column}_{hash}. Find orphan names by running plain postgres schema.

  5. If already covered by a composite index or unique constraint, the orphan auto-index is redundant. Generate a migration to drop it:

    operations = [
        migrations.RunSQL('DROP INDEX IF EXISTS "myapp_mymodel_author_id_abc12345"'),
    ]
    
  6. Run migrate.

0.88.2 (2026-03-25)

What's changed

  • Actually enforce name as a required keyword argument on Index.__init__ — 0.88.0 documented the requirement but the code enforcement was missing from the release.

Upgrade instructions

  • See 0.88.0 upgrade instructions.

0.88.1 (2026-03-25)

Yanked — code change missing, see 0.88.2.

0.88.0 (2026-03-25)

What's changed

  • Index now requires a name argument — auto-naming (set_name_with_model) is no longer used for new indexes. Use the {table}_{column(s)}_idx convention (e.g., plainjobs_jobrequest_priority_idx). (74aa8b76aa40)
  • Raised Index.max_name_length from 30 to 63 to match Postgres's actual identifier limit (NAMEDATALEN - 1). The old limit was inherited from Django's multi-database support. (74aa8b76aa40)

Upgrade instructions

  • Add name= to all Index objects in your models. Use the {table}_{column}_idx convention. Run makemigrations — it will auto-generate RenameIndex operations (instant ALTER INDEX RENAME, no locks). Then run migrate.

0.87.0 (2026-03-25)

What's changed

  • Renamed plain db CLI to plain postgres — all subcommands (migrate, diagnose, wait, backups, etc.) are now under plain postgres (a639aeacbf8d)
  • Extracted diagnose checks into plain.postgres.diagnose package — the monolithic diagnose module is now split into individual check modules for better maintainability (91f354108202)
  • FK-aware index checks — duplicate index detection now recognizes that FK fields auto-create indexes, avoiding false positives when a composite index covers the FK column (c116f808ac0b)
  • Added Diagnostics documentation section to README with check details, thresholds, and production usage guidance (c116f808ac0b)
  • Show slow queries in diagnose human-readable output and fix Heroku command quoting in the diagnose skill (6feaad54065d)

Upgrade instructions

  • Replace plain db with plain postgres in all scripts, CI configs, and documentation. The old plain db command no longer exists.

0.86.0 (2026-03-24)

What's changed

  • New plain db diagnose command — runs health checks against your Postgres database and reports issues as structured JSON. Checks for unused indexes, duplicate indexes, missing foreign key indexes, sequence exhaustion, transaction ID wraparound, vacuum health, and slow queries (via pg_stat_statements). Each finding includes table ownership info (app vs package) and actionable suggestions (91994604b60d)
  • New preflight checks for missing foreign key indexes and duplicate indexes — these run automatically during plain check and flag issues before they hit production (3703fe8ab38d)
  • New plain-postgres-diagnose AI skill for guided database health check workflow (91994604b60d)

Upgrade instructions

  • No changes required.

0.85.0 (2026-03-22)

What's changed

  • Added read-only database connection support via read_only() context manager and connection.set_read_only() — enforces SET default_transaction_read_only = ON so any write attempt raises a database error (69d23b04fde9)
  • Removed PEP-249 exception mirror — IntegrityError, OperationalError, ProgrammingError, etc. are no longer re-exported from plain.postgres. Use psycopg exceptions directly (e.g. psycopg.IntegrityError) (d4b170e60a2c)
  • Removed DatabaseErrorWrapper context manager — psycopg's native connection state handling replaces it (015b04ce38e9)
  • Added transaction and read-only connection documentation to README

Upgrade instructions

  • Replace any from plain.postgres import IntegrityError (or OperationalError, ProgrammingError, etc.) with import psycopg and use psycopg.IntegrityError directly.
  • Replace any usage of plain.postgres.db.DatabaseErrorWrapper with standard try/except on psycopg exceptions.

0.84.2 (2026-03-20)

What's changed

  • Migrated all internal logging to structured format using get_framework_logger() and flat extra={} dicts instead of inline string formatting — log messages are now short descriptive labels (e.g. "Query executed", "Transaction command") with structured metadata (sql, params, duration, etc.) passed separately (75a8b60c91)

Upgrade instructions

  • No changes required.

0.84.1 (2026-03-16)

What's changed

  • Renamed _nodb_cursor to _maintenance_cursor for clarity — it now always connects to the postgres database directly instead of falling back through multiple connection strategies (27bdee72d03e, f15b46ede57d)
  • DATABASE config key is now required (validated at configure time) rather than allowing None/empty string with runtime fallbacks (f15b46ede57d)

Upgrade instructions

  • No changes required.

0.84.0 (2026-03-12)

What's changed

  • Renamed package from plain-models to plain-postgres — the pip package, module path, and package label (plainmodels to plainpostgres) all reflect the PostgreSQL-only scope.
  • All internal imports updated from plain.models to plain.postgres.
  • Flattened plain.models.postgres subpackage into top-level plain.postgres.

Upgrade instructions

  • Update imports: from plain.models to from plain.postgres, from plain import models to from plain import postgres.
  • In pyproject.toml, change plain-models to plain-postgres and plain.models to plain.postgres in dependencies.
  • In INSTALLED_PACKAGES, change "plain.models" to "plain.postgres".

0.83.0 (2026-03-12)

What's changed

  • Renamed plain.models to plain.postgres — the package name now reflects its PostgreSQL-only scope.
  • Flattened the plain.models.postgres subpackage — all internal modules now live at the top level.

Upgrade instructions

  • Change from plain import modelsfrom plain import postgres and update all models.X usages to postgres.X (e.g. postgres.Model, postgres.register_model, postgres.CASCADE).
  • Change from plain.models import ...from plain.postgres import ....
  • In pyproject.toml, change plain.modelsplain.postgres and plain-modelsplain-postgres in dependencies.
  • In INSTALLED_PACKAGES, change "plain.models""plain.postgres".

0.82.3 (2026-03-10)

What's changed

  • Removed type: ignore comments on POSTGRES_PASSWORD default values, now that Secret is type-transparent (997afd9a558f)
  • Adopted PEP 695 type parameter syntax across Field, QuerySet, register_model, type stubs, and other generics (aa5b2db6e8ed)
  • Added migration docs reminder to AI rules (09deb5d5a382)

Upgrade instructions

  • No changes required.

0.82.2 (2026-03-10)

What's changed

  • Updated all README code examples to use types.* with Python type annotations as the default pattern (772345d4e1f1)
  • Removed separate "Typed fields" and "Typing reverse relationships" doc sections — typed fields are now the default in all examples (772345d4e1f1)
  • Added "Field Imports" section and "Differences from Django" section to AI rules (772345d4e1f1)
  • Broadened AI rules to apply to all Python files, not just model files (772345d4e1f1)

Upgrade instructions

  • No changes required.

0.82.1 (2026-03-10)

What's changed

  • Replaced SET() closure with a _SetOnDelete class to eliminate type: ignore comments for dynamic attribute assignment (deconstruct, lazy_sub_objs) (cda461b1b4f6)
  • Replaced lazy_sub_objs function attribute on SET_NULL and SET_DEFAULT with a module-level _LAZY_ON_DELETE set (cda461b1b4f6)
  • Narrowed _relation_tree type and used get_forward_field in migration operations (eb5af6a525b5)
  • Type annotation improvements across expressions, indexes, related fields, and deletion modules (f56c6454b164)

Upgrade instructions

  • No changes required.

0.82.0 (2026-03-09)

What's changed

  • Added EncryptedTextField and EncryptedJSONField for transparent encryption at rest using Fernet (AES-128-CBC + HMAC-SHA256) with keys derived from SECRET_KEY (73f3534f9334)
  • Encrypted fields support key rotation via SECRET_KEY_FALLBACKS and gradual migration from plaintext columns (73f3534f9334)
  • Preflight checks prevent encrypted fields from being used in indexes or constraints (73f3534f9334)

Upgrade instructions

  • No changes required. Install the cryptography package to use the new encrypted fields.

0.81.1 (2026-03-09)

What's changed

  • Use connection.execute() instead of opening a cursor for internal one-off queries (timezone configuration, role assumption, connection health checks) (828d665979df)

Upgrade instructions

  • No changes required.

0.81.0 (2026-03-09)

What's changed

  • psycopg3 cursor.stream() for iterator queriesQuerySet.iterator() now uses psycopg3's native server-side streaming instead of fetchmany() chunking, reducing memory overhead for large result sets (49f4d1d996b4)
  • Minimum PostgreSQL 16 enforced — a preflight check now validates the connected PostgreSQL version is 16 or higher (e1f21c4b251a)
  • Renamed DatabaseWrapperDatabaseConnection and moved from postgres/wrapper.py to postgres/connection.py to better reflect the class's purpose (7f17a96a7f8e, 4a79279d01dd)
  • Replaced db_connection proxy with get_connection() — the stateless DatabaseConnection proxy class is removed in favor of module-level get_connection() and has_connection() functions, giving type checkers direct access to the real DatabaseConnection class and eliminating proxy overhead (4a79279d01dd)
  • Replaced threading.local() with ContextVar for DB connection storage — database connections are now stored per-context instead of per-thread, enabling proper async support (cc2469b1260a)
  • Removed validate_thread_sharing() from DatabaseConnection — thread sharing validation is no longer needed with ContextVar-based connection storage (3a6d6efd09d2)
  • Extracted get_converters() and apply_converters() as standalone functions from SQLCompiler and added type annotations (ed18d3c97142)

Upgrade instructions

  • Replace from plain.models import db_connection with from plain.models import get_connection, and change db_connection.cursor() to get_connection().cursor() (and similar attribute access).
  • If you imported DatabaseWrapper, it is now DatabaseConnection from plain.models.postgres.connection.
  • PostgreSQL 16 or higher is now required.

0.80.0 (2026-02-25)

What's changed

  • Replaced the DATABASE dict setting with individual POSTGRES_* settings (POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DATABASE, POSTGRES_USER, POSTGRES_PASSWORD, etc.) configurable via PLAIN_POSTGRES_* environment variables or app/settings.py (e3c5a32d4da6)
  • DATABASE_URL still works and takes priority — individual settings are parsed from it automatically (e3c5a32d4da6)
  • Added DATABASE_URL=none to explicitly disable the database (e.g. during Docker builds) (e3c5a32d4da6)
  • Removed the AUTOCOMMIT config setting — Plain always runs with autocommit=True (5dc1995615d9)
  • Refactored backup client internals with shared _get_conn_args() and _run() helpers (e3c5a32d4da6)

Upgrade instructions

  • If you use DATABASE_URL, no changes are required — it continues to work as before.

  • If you manually defined the DATABASE dict in settings, replace it with individual POSTGRES_* settings:

    # Before
    DATABASE = {"NAME": "mydb", "USER": "me", "HOST": "localhost"}
    
    # After
    POSTGRES_DATABASE = "mydb"
    POSTGRES_USER = "me"
    POSTGRES_HOST = "localhost"
    
  • The DATABASE dict key "NAME" is now "DATABASE" internally — update any code that accessed settings_dict["NAME"] directly.

  • Remove any AUTOCOMMIT setting from your database config — it is no longer recognized.

0.79.0 (2026-02-24)

What's changed

  • Added plain db drop-unknown-tables command to remove database tables not associated with any Plain model (108b0bce59e6)
  • The unknown-tables preflight warning now suggests running plain db drop-unknown-tables instead of manual SQL (108b0bce59e6)

Upgrade instructions

  • No changes required.

0.78.0 (2026-02-16)

What's changed

  • PostgreSQL is now the only supported database — MySQL and SQLite backends have been removed (6f3a066bf80f)
  • The ENGINE key has been removed from the DATABASE setting — it is no longer needed since PostgreSQL is implicit (6f3a066bf80f)
  • Database backends consolidated from backends/base/, backends/postgresql/, backends/mysql/, and backends/sqlite3/ into a single postgres/ module (6f3a066bf80f)
  • Removed DatabaseOperations indirection layer — compilers are now created directly by Query.get_compiler() (6f3a066bf80f)
  • Removed backend feature flags and multi-database conditional code throughout expressions, aggregates, schema editor, and migrations (6f3a066bf80f)
  • Installation now recommends uv add plain.models psycopg[binary] to include the PostgreSQL driver (6f3a066bf80f)

Upgrade instructions

  • Remove "ENGINE" from your DATABASE setting — it will be ignored
  • If you were using MySQL or SQLite, you must migrate to PostgreSQL
  • Update any imports from plain.models.backends.base or plain.models.backends.postgresql to plain.models.postgres
  • Install a PostgreSQL driver if you haven't already: uv add psycopg[binary]

0.77.1 (2026-02-13)

What's changed

  • Added migration development workflow documentation covering how to consolidate uncommitted and committed migrations (0b30f98b5346)
  • Added migration cleanup guidance to agent rules: consolidate before committing, use squash only for deployed migrations (0b30f98b5346)

Upgrade instructions

  • No changes required.

0.77.0 (2026-02-13)

What's changed

  • makemigrations --dry-run now shows a SQL preview of the statements each migration would execute, making it easier to review schema changes before writing migration files (c994703f9a28)
  • makemigrations now warns when packages have models but no migrations/ directory, which can cause "No changes detected" confusion for new apps (c994703f9a28)
  • Restructured README documentation: consolidated Querying section with Custom QuerySets, Typing, and Raw SQL; added N+1 avoidance and query efficiency subsections; reorganized Relationships and Constraints into clearer sections with schema design guidance (f5d2731ebda0, 8c2189a896d2)
  • Slimmed agent rules to concise bullet reminders with paths: scoping for **/models.py files (f5d2731ebda0)

Upgrade instructions

  • No changes required.

0.76.5 (2026-02-12)

What's changed

  • Updated README model validation example to use @models.register_model, UniqueConstraint, and model_options (9db8e0aa5d43)
  • Added schema planning guidance to agent rules (eaf55cb1b893)

Upgrade instructions

  • No changes required.

0.76.4 (2026-02-04)

What's changed

  • Added __all__ exports to expressions module for explicit public API boundaries (e7164d3891b2)
  • Refactored internal imports to use explicit module paths instead of the sql namespace (e7164d3891b2)
  • Updated agent rules to use --api instead of --symbols for plain docs command (e7164d3891b2)

Upgrade instructions

  • No changes required.

0.76.3 (2026-02-02)

What's changed

  • Fixed observer query summaries for SQL statements starting with parentheses (e.g., UNION queries) by stripping leading ( before extracting the operation (bfbcb5a256f2)
  • UNION queries now display with a "UNION" suffix in query summaries for better identification (bfbcb5a256f2)
  • Agent rules now include query examples showing the Model.query pattern (02e11328dbf5)

Upgrade instructions

  • No changes required.

0.76.2 (2026-01-28)

What's changed

  • Converted the plain-models skill to a passive .claude/rules/ file (512040ac51)

Upgrade instructions

  • Run plain agent install to update your .claude/ directory.

0.76.1 (2026-01-28)

What's changed

Upgrade instructions

  • No changes required.

0.76.0 (2026-01-22)

What's changed

  • Removed the db_column field parameter - column names are now always derived from the field name (eed1bb6)
  • Removed the db_collation field parameter from CharField and TextField - use raw SQL or database-level collation settings instead (49b362d)
  • Removed the Collate database function from plain.models.functions (49b362d)
  • Removed the db_comment field parameter and db_table_comment model option - database comments are no longer supported (eb5aabb)
  • Removed the AlterModelTableComment migration operation (eb5aabb)
  • Added BaseDatabaseSchemaEditor and StateModelsRegistry exports from plain.models.migrations for use in type annotations in RunPython functions (672aa88)

Upgrade instructions

  • Remove any db_column arguments from field definitions - the column name will always match the field's attribute name (with _id suffix for foreign keys)
  • Remove db_column from all migrations
  • Remove any db_collation arguments from CharField and TextField definitions
  • Replace any usage of Collate() function with raw SQL queries or configure collation at the database level
  • Remove any db_comment arguments from field definitions
  • Remove db_comment from all migrations
  • Remove any db_table_comment from model_options definitions
  • Replace AlterModelTableComment migration operations with RunSQL if database comments are still needed

0.75.0 (2026-01-15)

What's changed

  • Added type annotations to CursorWrapper fetch methods (fetchone, fetchmany, fetchall) for better type checker support (7635258)
  • Internal cleanup: removed redundant tzinfo class attribute from TruncBase (0cb5a84)

Upgrade instructions

  • No changes required

0.74.0 (2026-01-15)

What's changed

  • Internal skill configuration update - no user-facing changes (fac8673)

Upgrade instructions

  • No changes required

0.73.0 (2026-01-15)

What's changed

  • The __repr__ method on models now returns <ClassName: id> instead of <ClassName: str(self)>, avoiding potential side effects from custom __str__ implementations (0fc4dd3)

Upgrade instructions

  • No changes required

0.72.0 (2026-01-13)

What's changed

  • Fixed TimezoneField deconstruct path to correctly resolve to plain.models instead of plain.models.fields.timezones, preventing migration churn when using TimezoneField (03cc263)

Upgrade instructions

  • No changes required

0.71.0 (2026-01-13)

What's changed

  • TimeZoneField choices are no longer serialized in migrations, preventing spurious migration diffs when timezone data differs between machines (0ede3aae)
  • TimeZoneField no longer accepts custom choices - the field's purpose is to provide the canonical timezone list (0ede3aae)
  • Simplified plain migrate output - package name is only shown when explicitly targeting a specific package (006efae9)
  • Field ordering is now explicit (primary key first, then alphabetically by name) instead of using an internal creation counter (3ffa44bd)

Upgrade instructions

  • If you have existing migrations that contain TimeZoneField with serialized choices, you can safely remove the choices parameter from those migrations as they are now computed dynamically
  • If you were passing custom choices to TimeZoneField, this is no longer supported - use a regular CharField with choices instead

0.70.0 (2025-12-26)

What's changed

  • Added TimeZoneField for storing timezone information - stores timezone names as strings in the database but provides zoneinfo.ZoneInfo objects when accessed, similar to how DateField works with datetime.date (b533189)
  • Documentation improvements listing all available field types in the README (11837ad)

Upgrade instructions

  • No changes required

0.69.1 (2025-12-22)

What's changed

  • Internal type annotation improvements for better type checker compatibility (539a706, 5c0e403)

Upgrade instructions

  • No changes required

0.69.0 (2025-12-12)

What's changed

  • The queryset.all() method now preserves the prefetch cache, fixing an issue where accessing prefetched related objects through .all() would trigger additional database queries instead of using the cached results (8b899a8)

Upgrade instructions

  • No changes required

0.68.0 (2025-12-09)

What's changed

  • Database backups now store git metadata (branch and commit) and the plain db backups list command displays this information along with source and size in a table format (287fa89f)
  • Added --branch option to plain db backups list to filter backups by git branch (287fa89f)
  • ReverseForeignKey and ReverseManyToMany now support an optional second type parameter for custom QuerySet types, enabling type checkers to recognize custom QuerySet methods on reverse relations (487c6195)
  • Internal cleanup: removed legacy generic foreign key related code (c9ca1b67)

Upgrade instructions

  • To get type checking for custom QuerySet methods on reverse relations, you can optionally add a second type parameter: books: types.ReverseForeignKey[Book, BookQuerySet] = types.ReverseForeignKey(to="Book", field="author"). This is optional and existing code without the second parameter continues to work.

0.67.0 (2025-12-05)

What's changed

  • Simplified Query/Compiler architecture by moving compiler selection from Query classes to DatabaseOperations (1d1ae5a6)
  • The raw() method now accepts any Sequence for params (e.g., lists) instead of requiring tuples (1d1ae5a6)
  • Internal type annotation improvements across database backends and SQL compiler modules (bc02184d, e068dcf2, 33fa09d6)

Upgrade instructions

  • No changes required

0.66.0 (2025-12-05)

What's changed

  • Removed union(), intersection(), and difference() combinator methods from QuerySet - use raw SQL for set operations instead (0bae6abd)
  • Removed dates() and datetimes() methods from QuerySet (62ba81a6)
  • Removed in_bulk() method from QuerySet (62ba81a6)
  • Removed contains() method from QuerySet (62ba81a6)
  • Internal cleanup: removed unused database backend feature flags and operations (autoinc_sql, allows_group_by_selected_pks_on_model, connection_persists_old_columns, implied_column_null, for_update_after_from, select_for_update_of_column, modify_insert_params) (defe5015, 7e62b635, 30073da1)

Upgrade instructions

  • Replace any usage of queryset.union(other_qs), queryset.intersection(other_qs), or queryset.difference(other_qs) with raw SQL queries using Model.query.raw() or database cursors
  • Replace queryset.dates(field, kind) with equivalent annotate/values_list queries using Trunc and DateField
  • Replace queryset.datetimes(field, kind) with equivalent annotate/values_list queries using Trunc and DateTimeField
  • Replace queryset.in_bulk(id_list) with a dictionary comprehension like {obj.id: obj for obj in queryset.filter(id__in=id_list)}
  • Replace queryset.contains(obj) with queryset.filter(id=obj.id).exists()

0.65.1 (2025-12-04)

What's changed

  • Fixed type annotations for get_rhs_op method in lookup classes to accept str | list[str] parameter, resolving type checker errors when using Range and other lookups that return list-based RHS values (7030cd0)

Upgrade instructions

  • No changes required

0.65.0 (2025-12-04)

What's changed

  • Improved type annotations for ReverseForeignKey and ReverseManyToMany descriptors - they are now proper generic descriptor classes with __get__ overloads, providing better type inference when accessed on class vs instance (ac1eeb0)
  • Internal type annotation improvements across aggregates, expressions, database backends, and SQL compiler modules (ac1eeb0)

Upgrade instructions

  • No changes required

0.64.0 (2025-11-24)

What's changed

  • bulk_create() and bulk_update() now accept any Sequence type (e.g., tuples, generators) instead of requiring a list (6c7469f)

Upgrade instructions

  • No changes required

0.63.1 (2025-11-21)

What's changed

  • Fixed ManyToManyField preflight checks that could fail when the intermediate model contained non-related fields (e.g., CharField, IntegerField) by properly filtering to only check RelatedField instances when counting foreign keys (4a3fe5d)

Upgrade instructions

  • No changes required

0.63.0 (2025-11-21)

What's changed

  • ForeignKey has been renamed to ForeignKeyField for consistency with other field naming conventions (8010204)
  • Improved type annotations for ManyToManyField - now returns ManyToManyManager[T] instead of Any for better IDE support (4536097)
  • Related managers (ReverseForeignKeyManager and ManyToManyManager) are now generic classes with proper type parameters for improved type checking (3f61b6e)
  • Added ManyToManyManager and ReverseForeignKeyManager exports to plain.models.types for use in type annotations (4536097)

Upgrade instructions

  • Replace all usage of models.ForeignKey with models.ForeignKeyField (e.g., category = models.ForeignKey("Category", on_delete=models.CASCADE) becomes category = models.ForeignKeyField("Category", on_delete=models.CASCADE))
  • Replace all usage of types.ForeignKey with types.ForeignKeyField in typed model definitions
  • Update migrations to use ForeignKeyField instead of ForeignKey

0.62.1 (2025-11-20)

What's changed

  • Fixed a bug where non-related fields could cause errors in migrations and schema operations by incorrectly assuming all fields have a remote_field attribute (60b1bcc)

Upgrade instructions

  • No changes required

0.62.0 (2025-11-20)

What's changed

  • The named parameter has been removed from QuerySet.values_list() - named tuples are no longer supported for values lists (0e39711)
  • Internal method get_extra_restriction() has been removed from related fields and query data structures (6157bd9)
  • Internal helper function get_model_meta() has been removed in favor of direct attribute access (cb5a50e)
  • Extensive type annotation improvements across the entire package, including database backends, query compilers, fields, migrations, and SQL modules (a43145e)
  • Added isinstance checks for related fields and improved type narrowing throughout the codebase (5b4bdf4)
  • Improved type annotations for Options.get_fields() and related meta methods with more specific return types (2c26f86)

Upgrade instructions

  • Remove any usage of the named=True parameter in values_list() calls - if you need named access to query results, use .values() which returns dictionaries instead

0.61.1 (2025-11-17)

What's changed

  • The @dataclass_transform decorator has been removed from ModelBase to avoid type checker issues (e0dbedb)
  • Documentation and examples no longer suggest using ClassVar for QuerySet type annotations - the simpler query: models.QuerySet[Model] = models.QuerySet() pattern is now recommended (1c624ff, 99aecbc)

Upgrade instructions

  • If you were using ClassVar annotations for the query attribute, you can optionally remove the ClassVar wrapper and the from typing import ClassVar import. Both patterns work, but the simpler version without ClassVar is now recommended.

0.61.0 (2025-11-14)

What's changed

  • The related_name parameter has been removed from ForeignKey and ManyToManyField - reverse relationships are now declared explicitly using ReverseForeignKey and ReverseManyToMany descriptors on the related model (a4b630969d)
  • Added ReverseForeignKey and ReverseManyToMany descriptor classes to plain.models.types for declaring reverse relationships with full type support (a4b630969d)
  • The new reverse descriptors are exported from plain.models for easy access (97fa112975)
  • Renamed internal references from ManyToOne to ForeignKey for consistency (93c30f9caf)
  • Fixed a preflight check bug related to reverse relationships (9191ae6e4b)
  • Added comprehensive documentation for reverse relationships in the README (5abf330e06)

Upgrade instructions

  • Remove all related_name parameters from ForeignKey and ManyToManyField definitions
  • Remove related_name from all migrations
  • On the related model, add explicit reverse relationship descriptors using ReverseForeignKey or ReverseManyToMany from plain.models.types:
    • For the reverse side of a ForeignKey, use: children: types.ReverseForeignKey[Child] = types.ReverseForeignKey(to="Child", field="parent")
    • For the reverse side of a ManyToManyField, use: cars: types.ReverseManyToMany[Car] = types.ReverseManyToMany(to="Car", field="features")
  • Remove any TYPE_CHECKING blocks that were used to declare reverse relationship types - the new descriptors provide full type support without these hacks
  • The to parameter accepts either a string (model name) or the model class itself
  • The field parameter should be the name of the forward field on the related model

0.60.0 (2025-11-13)

What's changed

  • Type annotations for QuerySets using ClassVar to improve type checking when accessing Model.query (c3b00a6)
  • The id field on the Model base class now uses a type annotation (id: int = types.PrimaryKeyField()) for better type checking (9febc80)
  • Replaced wildcard imports (import *) with explicit imports in internal modules for better code clarity (eff36f3)

Upgrade instructions

  • Optionally (but recommended) add ClassVar type annotations to custom QuerySets on your models using query: ClassVar[models.QuerySet[YourModel]] = models.QuerySet() for improved type checking and IDE autocomplete

0.59.1 (2025-11-13)

What's changed

  • Added documentation for typed field definitions in the README, showing examples of using plain.models.types with type annotations (f95d32d)

Upgrade instructions

  • Optionally (but recommended) move to typed model field definitions by using name: str = types.CharField(...) instead of name = models.CharField(...). Types can be imported with from plain.models import types.

0.59.0 (2025-11-13)

What's changed

  • Added a new plain.models.types module with type stub support (.pyi) for improved IDE and type checker experience when defining models (c8f40fc)
  • Added @dataclass_transform decorator to ModelBase to enable better type checking for model field definitions (c8f40fc)

Upgrade instructions

  • No changes required

0.58.0 (2025-11-12)

What's changed

Upgrade instructions

  • No changes required

0.57.0 (2025-11-11)

What's changed

  • The plain.models import namespace has been cleaned up to only include the most commonly used APIs for defining models (e9edf61, 22b798c, d5a2167)
  • Field classes are now descriptors themselves, eliminating the need for a separate descriptor class (93f8bd7)
  • Model initialization no longer accepts positional arguments - all field values must be passed as keyword arguments (685f99a)
  • Attempting to set a primary key during model initialization now raises a clear ValueError instead of silently accepting the value (ecf490c)

Upgrade instructions

  • Import advanced query features from their specific modules instead of plain.models:
    • Aggregates: from plain.models.aggregates import Avg, Count, Max, Min, Sum
    • Expressions: from plain.models.expressions import Case, Exists, Expression, ExpressionWrapper, F, Func, OuterRef, Subquery, Value, When, Window
    • Query utilities: from plain.models.query import Prefetch, prefetch_related_objects
    • Lookups: from plain.models.lookups import Lookup, Transform
  • Remove any positional arguments in model instantiation and use keyword arguments instead (e.g., User("John", "Doe") becomes User(first_name="John", last_name="Doe"))

0.56.1 (2025-11-03)

What's changed

  • Fixed preflight checks and README to reference the correct new command names (plain db shell and plain migrations prune) instead of the old plain models commands (b293750)

Upgrade instructions

  • No changes required

0.56.0 (2025-11-03)

What's changed

  • The CLI has been reorganized into separate plain db and plain migrations command groups for better organization (7910a06)
  • The plain models command group has been removed - use plain db and plain migrations instead (7910a06)
  • The plain backups command group has been removed - use plain db backups instead (dd87b76)
  • Database backup output has been simplified to show file size and timestamp on a single line (765d118)

Upgrade instructions

  • Replace plain models db-shell with plain db shell
  • Replace plain models db-wait with plain db wait
  • Replace plain models list with plain db list (note: this command was moved to the main plain package)
  • Replace plain models show-migrations with plain migrations list
  • Replace plain models prune-migrations with plain migrations prune
  • Replace plain models squash-migrations with plain migrations squash
  • Replace plain backups commands with plain db backups (e.g., plain backups list becomes plain db backups list)
  • The shortcuts plain makemigrations and plain migrate continue to work unchanged

0.55.1 (2025-10-31)

What's changed

  • Added license = "BSD-3-Clause" to package metadata (8477355)

Upgrade instructions

  • No changes required

0.55.0 (2025-10-24)

What's changed

  • The plain-models package now uses an explicit package_label = "plainmodels" to avoid conflicts with other packages (d1783dd)
  • Fixed migration loader to correctly check for plainmodels package label instead of models (c41d11c)

Upgrade instructions

  • No changes required

0.54.0 (2025-10-22)

What's changed

  • SQLite migrations are now always run separately instead of in atomic batches, fixing issues with foreign key constraint handling (5082453)

Upgrade instructions

  • No changes required

0.53.1 (2025-10-20)

What's changed

  • Internal packaging update to use dependency-groups standard instead of tool.uv.dev-dependencies (1b43a3a)

Upgrade instructions

  • No changes required

0.53.0 (2025-10-12)

What's changed

  • Added new plain models prune-migrations command to identify and remove stale migration records from the database (998aa49)
  • The --prune option has been removed from plain migrate command in favor of the dedicated prune-migrations command (998aa49)
  • Added new preflight check models.prunable_migrations that warns about stale migration records in the database (9b43617)
  • The show-migrations command no longer displays prunable migrations in its output (998aa49)

Upgrade instructions

  • Replace any usage of plain migrate --prune with the new plain models prune-migrations command

0.52.0 (2025-10-10)

What's changed

  • The plain migrate command now shows detailed operation descriptions and SQL statements for each migration step, replacing the previous verbosity levels with a cleaner --quiet flag (d6b041bd24)
  • Migration output format has been improved to display each operation's description and the actual SQL being executed, making it easier to understand what changes are being made to the database (d6b041bd24)
  • The -v/--verbosity option has been removed from plain migrate in favor of the simpler --quiet flag for suppressing output (d6b041bd24)

Upgrade instructions

  • Replace any usage of -v or --verbosity flags in plain migrate commands with --quiet if you want to suppress migration output

0.51.1 (2025-10-08)

What's changed

  • Fixed a bug in Subquery and Exists expressions that was using the old query attribute name instead of sql_query when extracting the SQL query from a QuerySet (79ca52d)

Upgrade instructions

  • No changes required

0.51.0 (2025-10-07)

What's changed

  • Model metadata has been split into two separate descriptors: model_options for user-defined configuration and _model_meta for internal metadata (73ba469, 17a378d)
  • The _meta attribute has been replaced with model_options for user-defined options like indexes, constraints, and database settings (17a378d)
  • Custom QuerySets are now assigned directly to the query class attribute instead of using Meta.queryset_class (2578301)
  • Added comprehensive type improvements to model metadata and related fields for better IDE support (3b477a0)

Upgrade instructions

  • Replace Meta.queryset_class = CustomQuerySet with query = CustomQuerySet() as a class attribute on your models
  • Replace class Meta: with model_options = models.Options(...) in your models

0.50.0 (2025-10-06)

What's changed

  • Added comprehensive type annotations throughout plain-models, improving IDE support and type checking capabilities (ea1a7df, f49ee32, 369353f, 13b7d16, e23a0ca, 02d8551)
  • The QuerySet class is now generic and the model parameter is now required in the __init__ method (719e792)
  • Database wrapper classes have been renamed for consistency: DatabaseWrapper classes are now named MySQLDatabaseWrapper, PostgreSQLDatabaseWrapper, and SQLiteDatabaseWrapper (5a39e85)
  • The plain-models package now has 100% type annotation coverage and is validated in CI to prevent regressions

Upgrade instructions

  • No changes required

0.49.2 (2025-10-02)

What's changed

  • Updated dependency to use the latest plain package version

Upgrade instructions

  • No changes required

0.49.1 (2025-09-29)

What's changed

  • Fixed get_field_display() method to accept field name as string instead of field object (1c20405)

Upgrade instructions

  • No changes required

0.49.0 (2025-09-29)

What's changed

  • Model exceptions (FieldDoesNotExist, FieldError, ObjectDoesNotExist, MultipleObjectsReturned, EmptyResultSet, FullResultSet) have been moved from plain.exceptions to plain.models.exceptions (1c02564)
  • The get_FOO_display() methods for fields with choices have been replaced with a single get_field_display(field_name) method (e796e71)
  • The get_next_by_* and get_previous_by_* methods for date fields have been removed (3a5b8a8)
  • The id primary key field is now defined directly on the Model base class instead of being added dynamically via Options (e164dc7)
  • Model DoesNotExist and MultipleObjectsReturned exceptions now use descriptors for better performance (8f54ea3)

Upgrade instructions

  • Update imports for model exceptions from plain.exceptions to plain.models.exceptions (e.g., from plain.exceptions import ObjectDoesNotExist becomes from plain.models.exceptions import ObjectDoesNotExist)
  • Replace any usage of instance.get_FOO_display() with instance.get_field_display("FOO") where FOO is the field name
  • Remove any usage of get_next_by_* and get_previous_by_* methods - use QuerySet ordering instead (e.g., Model.query.filter(date__gt=obj.date).order_by("date").first())

0.48.0 (2025-09-26)

What's changed

  • Migrations now run in a single transaction by default for databases that support transactional DDL, providing all-or-nothing migration batches for better safety and consistency (6d0c105)
  • Added --atomic-batch/--no-atomic-batch options to plain migrate to explicitly control whether migrations are run in a single transaction (6d0c105)

Upgrade instructions

  • No changes required

0.47.0 (2025-09-25)

What's changed

  • The QuerySet.query property has been renamed to QuerySet.sql_query to better distinguish it from the Model.query manager interface (d250eea)

Upgrade instructions

  • If you directly accessed the QuerySet.query property in your code (typically for advanced query manipulation or debugging), rename it to QuerySet.sql_query

0.46.1 (2025-09-25)

What's changed

  • Fixed prefetch_related for reverse foreign key relationships by correctly handling related managers in the prefetch query process (2c04e80)

Upgrade instructions

  • No changes required

0.46.0 (2025-09-25)

What's changed

  • The preflight system has been completely reworked with a new PreflightResult class that unifies messages and hints into a single fix field, providing clearer and more actionable error messages (b0b610d, c7cde12)
  • Preflight check IDs have been renamed to use descriptive names instead of numbers for better clarity (e.g., models.E003 becomes models.duplicate_many_to_many_relations) (cd96c97)
  • Removed deprecated field types: CommaSeparatedIntegerField, IPAddressField, and NullBooleanField (345295dc)
  • Removed system_check_deprecated_details and system_check_removed_details from fields (e3a7d2dd)

Upgrade instructions

  • Remove any usage of the deprecated field types CommaSeparatedIntegerField, IPAddressField, and NullBooleanField - use CharField, GenericIPAddressField, and BooleanField(null=True) respectively

0.45.0 (2025-09-21)

What's changed

  • Added unlimited varchar support to SQLite - CharField fields without a max_length now generate varchar columns instead of varchar() with no length specified (c5c0c3a)

Upgrade instructions

  • No changes required

0.44.0 (2025-09-19)

What's changed

  • PostgreSQL backup restoration now drops and recreates the database instead of using pg_restore --clean, providing more reliable restoration by terminating active connections and ensuring a completely clean database state (a8865fe)
  • Added _meta type annotation to the Model class for improved type checking and IDE support (387b92e)

Upgrade instructions

  • No changes required

0.43.0 (2025-09-12)

What's changed

  • The related_name parameter is now required for ForeignKey and ManyToManyField relationships if you want a reverse accessor. The "+" suffix to disable reverse relations has been removed, and automatic _set suffixes are no longer generated (89fa03979f)
  • Refactored related descriptors and managers for better internal organization and type safety (9f0b03957a)
  • Added docstrings and return type annotations to model query property and related manager methods for improved developer experience (544d85b60b)

Upgrade instructions

  • Remove any related_name="+" usage - if you don't want a reverse accessor, simply omit the related_name parameter entirely
  • Update any code that relied on automatic _set suffixes - these are no longer generated, so you must use explicit related_name values
  • Add explicit related_name arguments to all ForeignKey and ManyToManyField definitions where you want reverse access (e.g., models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles"))
  • Consider removing related_name arguments that are not used in practice

0.42.0 (2025-09-12)

What's changed

  • The model manager interface has been renamed from .objects to .query (037a239)
  • Manager functionality has been merged into QuerySet, simplifying the architecture - custom QuerySets can now be set directly via Meta.queryset_class (bbaee93)
  • The objects manager is now set directly on the Model class for better type checking (fccc5be)
  • Database backups are now created automatically during migrations when in DEBUG mode (c8023074)
  • Removed several legacy manager features: default_related_name, base_manager_name, creation_counter, use_in_migrations, auto_created, and routing hints (multiple commits)

Upgrade instructions

  • Replace all usage of Model.objects with Model.query in your codebase (e.g., User.objects.filter() becomes User.query.filter())
  • If you have custom managers, convert them to custom QuerySets and set them using Meta.queryset_class instead of assigning to class attributes (if there is more than one custom manager on a class, invoke the new QuerySet class directly or add a shortcut on the Model using @classmethod)
  • Remove any usage of the removed manager features: default_related_name, base_manager_name, manager creation_counter, use_in_migrations, auto_created, and database routing hints
  • Any reverse accessors (typically <related_model>_set or defined by related_name) will now return a manager class for the additional add(), remove(), clear(), etc. methods and the regular queryset methods will be available via .query (e.g., user.articles.first() becomes user.articles.query.first())

0.41.1 (2025-09-09)

What's changed

  • Improved stack trace filtering in OpenTelemetry spans to exclude internal plain/models frames, making debugging traces cleaner and more focused on user code (5771dd5)

Upgrade instructions

  • No changes required

0.41.0 (2025-09-09)

What's changed

  • Python 3.13 is now the minimum required version (d86e307)
  • Removed the earliest(), latest(), and get_latest_by model meta option - use order_by().first() and order_by().last() instead (b6093a8)
  • Removed automatic ordering in first() and last() queryset methods - they now respect the existing queryset ordering without adding default ordering (adc19a6)
  • Added code location attributes to database operation tracing, showing the source file, line number, and function where the query originated (da36a17)

Upgrade instructions

  • Replace usage of earliest(), latest(), and model Meta get_latest_by queryset methods with equivalent order_by().first() or order_by().last() calls
  • The first() and last() methods no longer automatically add ordering by id - explicitly add .order_by() to your querysets or ordering to your models Meta class if needed

0.40.1 (2025-09-03)

What's changed

  • Internal documentation updates for agent commands (df3edbf0bd)

Upgrade instructions

  • No changes required

0.40.0 (2025-08-05)

What's changed

  • Foreign key fields now accept lazy objects (like SimpleLazyObject used for request.user) by automatically evaluating them (eb78dcc76d)
  • Added --no-input option to plain migrate command to skip user prompts (0bdaf0409e)
  • Removed the plain models optimize-migration command (6e4131ab29)
  • Removed the --fake-initial option from plain migrate command (6506a8bfb9)
  • Fixed CLI help text to reference plain commands instead of manage.py (8071854d61)

Upgrade instructions

  • Remove any usage of plain models optimize-migration command - it is no longer available
  • Remove any usage of --fake-initial option from plain migrate commands - it is no longer supported
  • It is no longer necessary to do user=request.user or None, for example, when setting foreign key fields with a lazy object like request.user. These will now be automatically evaluated.

0.39.2 (2025-07-25)

What's changed

  • Fixed remaining to_field_name attribute usage in ModelMultipleChoiceField validation to use id directly (26c80356d3)

Upgrade instructions

  • No changes required

0.39.1 (2025-07-22)

What's changed

  • Added documentation for sharing fields across models using Python class mixins (cad3af01d2)
  • Added note about PrimaryKeyField() replacement requirement for migrations (70ea931660)

Upgrade instructions

  • No changes required

0.39.0 (2025-07-22)

What's changed

  • Models now use a single automatic id field as the primary key, replacing the previous pk alias and automatic field system (4b8fa6a)
  • Removed the to_field option for ForeignKey - foreign keys now always reference the primary key of the related model (7fc3c88)
  • Removed the internal from_fields and to_fields system used for multi-column foreign keys (0e9eda3)
  • Removed the parent_link parameter on ForeignKey and ForeignObject (6658647)
  • Removed InlineForeignKeyField from forms (ede6265)
  • Merged ForeignObject functionality into ForeignKey, simplifying the foreign key implementation (e6d9aaa)
  • Cleaned up unused code in ForeignKey and fixed ForeignObjectRel imports (b656ee6)

Upgrade instructions

  • Replace any direct references to pk with id in your models and queries (e.g., user.pk becomes user.id)
  • Remove any to_field arguments from ForeignKey definitions - they are no longer supported
  • Remove any parent_link=True arguments from ForeignKey definitions - they are no longer supported
  • Replace any usage of InlineForeignKeyField in forms with standard form fields
  • models.BigAutoField(auto_created=True, primary_key=True) need to be replaced with models.PrimaryKeyField() in migrations

0.38.0 (2025-07-21)

What's changed

  • Added get_or_none() method to QuerySet which returns a single object matching the given arguments or None if no object is found (48e07bf)

Upgrade instructions

  • No changes required

0.37.0 (2025-07-18)

What's changed

  • Added OpenTelemetry instrumentation for database operations - all SQL queries now automatically generate OpenTelemetry spans with standardized attributes following semantic conventions (b0224d0)
  • Database operations in tests are now wrapped with tracing suppression to avoid generating telemetry noise during test execution (b0224d0)

Upgrade instructions

  • No changes required

0.36.0 (2025-07-18)

What's changed

  • Removed the --merge option from the makemigrations command (d366663)
  • Improved error handling in the restore-backup command using Click's error system (88f06c5)

Upgrade instructions

  • No changes required

0.35.0 (2025-07-07)

What's changed

  • Added the plain models list CLI command which prints a nicely formatted list of all installed models, including their table name, fields, and originating package. You can pass package labels to filter the output or use the --app-only flag to only show first-party app models (1bc40ce).
  • The MySQL backend no longer enforces a strict mysqlclient >= 1.4.3 version check and had several unused constraint-handling methods removed, reducing boilerplate and improving compatibility with a wider range of mysqlclient versions (6322400, 67f21f6).

Upgrade instructions

  • No changes required

0.34.4 (2025-07-02)

What's changed

  • The built-in on_delete behaviors (CASCADE, PROTECT, RESTRICT, SET_NULL, SET_DEFAULT, and the callables returned by SET(...)) no longer receive the legacy using argument. Their signatures are now (collector, field, sub_objs) (20325a1).
  • Removed the unused interprets_empty_strings_as_nulls backend feature flag and the related fallback logic (285378c).

Upgrade instructions

  • No changes required

0.34.3 (2025-06-29)

What's changed

  • Simplified log output when creating or destroying test databases during test setup. The messages now display the test database name directly and no longer reference the deprecated "alias" terminology (a543706).

Upgrade instructions

  • No changes required

0.34.2 (2025-06-27)

What's changed

  • Fixed PostgreSQL _nodb_cursor fallback that could raise TypeError: __init__() got an unexpected keyword argument 'alias' when the maintenance database wasn't available (3e49683).
  • Restored support for the USING clause when creating PostgreSQL indexes; custom index types such as GIN and GIST are now generated correctly again (9d2b8fe).

Upgrade instructions

  • No changes required

0.34.1 (2025-06-23)

What's changed

  • Fixed Markdown bullet indentation in the 0.34.0 release notes so they render correctly (2fc81de).

Upgrade instructions

  • No changes required

0.34.0 (2025-06-23)

What's changed

  • Switched to a single DATABASE setting instead of DATABASES and removed DATABASE_ROUTERS. A helper still automatically populates DATABASE from DATABASE_URL just like before (d346d81).
  • The plain.models.db module now exposes a db_connection object that lazily represents the active database connection. Previous connections, router, and DEFAULT_DB_ALIAS exports were removed (d346d81).

Upgrade instructions

  • Replace any DATABASES definition in your settings with a single DATABASE dict (keys are identical to the inner dict you were previously using).
  • Remove any DATABASE_ROUTERS configuration – multiple databases are no longer supported.
  • Update import sites:
    • from plain.models import connectionsfrom plain.models import db_connection
    • from plain.models import router → (no longer needed; remove usage or switch to db_connection where appropriate)
    • from plain.models.connections import DEFAULT_DB_ALIAS → (constant removed; default database is implicit)