plain-postgres changelog
0.103.5 (2026-05-19)
What's changed
- Pooled connections are now validated on checkout. The connection pool runs
check_connectionon eachgetconn(), 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 ofOperationalError: the connection is closedfailures on the first query of a request after an idle period. (31ad84f423) - Standardized
__all__informs.pyto 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_indexespreflight check now skips partial indexes (those with acondition=). Previously a bareIndex(fields=[fk])carried for FK coverage was flagged as redundant with a partial compositeIndex(fields=[fk, ...], condition=Q(...)), contradicting thepostgres.missing_fk_indexcheck. 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_indexesto 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) callrepr()on stack-frame locals to build error events, and a surpriseSELECT … LIMIT 21inside 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_objectno longer calls.all()on a manager. The form-roundtrip path went throughgetattr(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.queryqueryset, 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., typingqsat the prompt to print rows) will now see<QuerySet [unevaluated]>— calllist(qs)or slice it to materialize.
0.103.2 (2026-05-06)
What's changed
postgres.missing_fk_indexespreflight now recognizes bare-column leading expressions. AUniqueConstraint(F("team"), Lower("email"))declared via theexpressions=API previously slipped past the model-level check — the preflight only inspectedfields=, so it warned about a missing FK index even though the underlying btree's leading column was the realteamattribute and the live diagnose check correctly recognized coverage. The preflight now extracts the leading column fromF(...)andOrderBy(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-indexWHERE, 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 narrowWHERE fk IS NOT NULLcase is conservatively also treated as not covering; users wanting guaranteed FK coverage should add a regular non-partialIndex(fields=[...]). (b1d13a6b42) - New
is_partialproperty onIndexandUniqueConstraintreturningcondition is not None, for callers that need the distinction. (b1d13a6b42)
Upgrade instructions
- No changes required. After upgrading,
postgres.missing_fk_indexesmay 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 usedexpressions=). Add a non-partialIndex(fields=["fk"])to silence the warning in the partial-index case.
0.103.1 (2026-05-06)
What's changed
postgres.duplicate_indexesnow 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 anIndex(fields=["x"])declared next to a same-columnUniqueConstraint(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 diagnoseandplain preflightmay surface previously-undetected duplicate indexes — drop the redundantIndex(...)declaration on the model and runplain postgres sync.
0.103.0 (2026-05-06)
What's changed
- New
storage_parametersonmodel_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 withALTER TABLE … SET (...)— andplain postgres syncreconciles them via instant catalog-onlyALTER 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 atoast.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_bloathealth check. Estimates per-table page-level bloat using the ioguix estimator (same heuristic as pghero). Complementsvacuum_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 plainVACUUMmarks pages reusable without returning space to the OS. Surfaces tables with both >100 MB wasted bytes AND >25% bloat ratio, withpg_repack/pg_squeeze/VACUUM FULLsuggestions. Cross-check caveats now linkvacuum_healthandtable_bloatfindings on the same table. (ee9dc1d5) - Tightened
index_bloatthresholds. 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 thatREINDEX CONCURRENTLYis 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_parametersAPI, declare them onmodel_options = postgres.Options(storage_parameters={...})and runplain postgres sync. After upgrading, expect previously-noisyindex_bloatfindings to disappear (now require ≥100 MB AND ≥30%) and the newtable_bloatcheck to appear inplain postgres diagnose.
0.102.0 (2026-05-05)
What's changed
Model.queryis now bound toSelf(PEP 673), so subclasses specialize automatically.User.querytypes asQuerySet[User]andUser.query.first()asUser | Nonewithout per-model annotations. CustomQuerySetsubclasses (e.g.TaskQuerySet) are still preserved by the existingSelf-returning descriptor. Now-redundantcast(T, ...)wrappers in the FK/M2M related managers are gone —self.model.query.create(...)already types asT. (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. AddsReadOnlyConnectionErrorwhen the round-trip can't get DDL. Thenormalize_check_definition,normalize_default_sql,normalize_expression,normalize_index_definition, andnormalize_unique_definitionhelpers are removed fromplain.postgres.introspection, andsqlparseis 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. Callingfull_clean()on a model with bothchoices=and aCheckConstraintreferencing the same field used to crash withAssertionError: 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 publicCheckConstraint.referenced_fields()method. (d13f47d1)- Tightened class-level annotations on
Query.selectand friends,Operation.atomic, andChoicesField.choicesfor ty 0.0.33; replaced theModelStatefields_cachedescriptor with a plain__init__. (4b9d1db1) - Exposes
__version__fromimportlib.metadataonplain.postgres. (c6cf6edb)
Upgrade instructions
- If you imported any of
normalize_check_definition,normalize_default_sql,normalize_expression,normalize_index_definition, ornormalize_unique_definitionfromplain.postgres.introspection, those helpers are gone — usepg_get_indexdef/pg_get_constraintdefdirectly 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.
AddConstraintFixnow runsALTER TABLE ... ADD CONSTRAINT ... NOT VALIDfollowed byALTER TABLE ... VALIDATE CONSTRAINTin a singleapply(). The add is catalog-only (brief lock) and validate usesSHARE 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 inNOT VALIDstate and the validation step was its own follow-up fix. (dc7eb8d3c2b7) plain-postgresrule references updated for the simplerplain docsCLI (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_codetriad onCheckConstraintandUniqueConstraintwith a singleviolation_errorkwarg. The new kwarg accepts anythingValidationError(...)accepts — a string, a{field: message}dict, a list, or a fully-formedValidationError— so message text, error code, and field routing all live on one object. (8650edc22c09) - Single-field
UniqueConstraintnow auto-routes flat errors to its field. Aviolation_error="That email is taken."onUniqueConstraint(fields=["email"])lands on theemailform field instead ofNON_FIELD_ERRORS. A caller-builtValidationError({"other_field": ...})is preserved as-is. (8650edc22c09) - Dropped the hardcoded
code == "unique"routing invalidate_constraints(). Routing is now uniform across constraint types: dict-form errors land on fields, flat errors go toNON_FIELD_ERRORS. (8650edc22c09) - Removed the
%(name)sinterpolation magic onBaseConstraint.default_violation_error_message. The default message still includes the constraint name; users wanting runtime interpolation can passValidationError(..., params={"name": ...}). (8650edc22c09) - Documented that
save()runsfull_clean()by default (clean_and_validate=True); fixed the README's Validation example which previously implied users had to overridesave()to callfull_clean()manually. (8650edc22c09)
Upgrade instructions
- Replace
violation_error_message="..."andviolation_error_code="..."onCheckConstraint/UniqueConstraintwith a singleviolation_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-fieldUniqueConstraintstill auto-routes by default. - If you used
%(name)sinviolation_error_message, switch toValidationError("...", 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/indclassarrays), so a redundant(LOWER(email))alongside(LOWER(email), team_id)was missed. The query now compares per-columnpg_get_indexdef(indexrelid, k, false)text — canonical output that includes column name/expression, opclass, collation, and sort order — and checkspg_am.amnameseparately 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 diagnosearound 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--verboseto expand every check, and--allstill includes installed-package tables. (26abb6cbc075) - New diagnostic checks:
stats_freshness(usespg_class.reltuplesso it survivespg_stat_reset),index_bloat(ioguix btree estimator, public schema only),missing_index_candidates(seq-scan heuristics with per-query drill-down frompg_stat_statements),blocking_queries(wait age frompg_locks.waitstart, PG 14+), andlong_running_connections(xact age for idle-in-transaction). Findings include cross-check caveats — e.g. anunused_indexesfinding on a table that's also flagged bystats_freshnessorvacuum_healthnow 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 incursor.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.pysplit into anintrospection/health/package along natural seams (types, ownership, context, helpers, checks grouped bystructural/cumulative/snapshot, and a runner). Public re-exports are unchanged. (26abb6cbc075) - Adapter annotations use
Responseafter plain 0.135.0 mergedResponseBaseintoResponse. (f5007281d7fa)
Upgrade instructions
- Requires
plain>=0.135.0. - No code changes required. If you parse
plain postgres diagnose --json, note the newtierfield on each finding ("structural","cumulative","snapshot", or"operational") — operational findings still carrystatus: "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 newsourcesabstraction routesDatabaseConnectionthrough either a long-livedPoolSource(runtime) or aDirectSource(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.2andpsycopg-pool>=3.2are now declared as hard dependencies. (2a51b25) - New
DatabaseConnectionMiddleware(required). Add"plain.postgres.DatabaseConnectionMiddleware"toMIDDLEWARE— it's what returns the pooled connection at the end of each request. ForStreamingResponse/AsyncStreamingResponsethe 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 newpostgres.middleware_installedpreflight check errors if the middleware is missing. (2a51b25) - Connection settings replaced with pool settings.
POSTGRES_CONN_MAX_AGEandPOSTGRES_CONN_HEALTH_CHECKSare gone. Tune the pool withPOSTGRES_POOL_MIN_SIZE(default4),POSTGRES_POOL_MAX_SIZE(default20),POSTGRES_POOL_MAX_LIFETIMEseconds (default3600.0), andPOSTGRES_POOL_TIMEOUTseconds (default30.0). Each is also available as aPLAIN_POSTGRES_POOL_*environment variable. (2a51b25) plain.postgres.connectionsmodule removed.get_connection,has_connection,use_management_connection, andread_onlynow live inplain.postgres.db(the underscore-less counterpart). (2a51b25)read_only()is now pgbouncer-safe. It opens a singleBEGIN READ ONLYtransaction for the block (previously a session-levelSET default_transaction_read_only = on). Nestedatomic()blocks become savepoints of the outer read-only transaction. Enteringread_only()inside an existingatomic()block now raisesTransactionManagementError. The oldDatabaseConnection.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, plusdb.client.response.returned_rowsfor SELECT queries including streamed iterators. Query spans now carryserver.address/server.portalongsidenetwork.peer.*, and the tracer/meter are tagged with theplain.postgrespackage version forInstrumentationScope. (61278d5) - Moved
psqlCLI orchestration offDatabaseConnection. Newpostgres_cli_args/postgres_cli_envhelpers inplain.postgres.database_urlbuild the arguments and environment forpsql,pg_dump, etc.;plain postgres shelland theplain-devbackup client both use them.DatabaseConnection.runshell()andexecutable_nameare 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, andDatabaseConnection.from_urlare 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_versionand removedtemporary_connection(). The single caller now readsconnection.info.server_versiondirectly;temporary_connection()has no remaining users. (319f6ac) APIResultshorthand returns moved out ofView. Any internal views that relied on dict/int shorthand now wrap their returns inJsonResponse/Response(status_code=...)to match plain 0.134.0's narrowerViewhandler return type. (1935f3f)- Adapter registration extracted to
plain.postgres.adapters.PlainRangeDumperandget_adapters_template()moved out ofconnection.pyinto 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_responseand still have the connection returned cleanly. Preflight will error if it's missing.Replace
POSTGRES_CONN_MAX_AGE/POSTGRES_CONN_HEALTH_CHECKSwith 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.connectionstoplain.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_connectionIf you called
DatabaseConnection.set_read_only(True)for a sticky read-only session, switch to theread_only()context manager around the block you want read-only. If you need session-level enforcement outside a transaction, open aDirectSourceconnection yourself and issueSET default_transaction_read_only = onon it.If you entered
read_only()inside anatomic()block, moveread_only()to the outer position — it now owns the transaction. Nestedatomic()blocks insideread_only()are fine (they become savepoints).If you pinned
psycopgvia your own dependency, make sure it's>=3.2, and addpsycopg-pool>=3.2if you were installing psycopg without extras.
0.97.0 (2026-04-21)
What's changed
- Replaced individual
POSTGRES_*connection fields with a singlePOSTGRES_URLsetting.POSTGRES_HOST,POSTGRES_PORT,POSTGRES_DATABASE,POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_OPTIONS, andPOSTGRES_TIME_ZONEare gone — configure the connection with one URL (e.g.postgresql://user:pass@host:5432/db?sslmode=require).DATABASE_URLis still read as a fallback. Set the URL tononeto explicitly disable the database (e.g. during Docker image builds). (770a74606463) - Added
POSTGRES_MANAGEMENT_URLfor routing DDL through a separate connection. When set,plain migrations create|apply|list|prune|squash,plain postgres sync|converge|schema|diagnose|drop-unknown-tables|shellconnect through this URL instead ofPOSTGRES_URL. Use it to bypass transaction-mode poolers (PlanetScale, Supabase's pooler, Neon's pooler, pgbouncer) for schema changes, long transactions, andpg_dump. A newuse_management_connection()context manager routes custom code through the same connection. When unset, all commands usePOSTGRES_URL— no behavior change for existing apps. (d1cc9630d049) - Extracted the test-database lifecycle off
DatabaseConnection. Test setup/teardown now lives inplain.postgres.testinstead of coupling it to the runtime connection class. (ea67f82c746c) - Removed thin psycopg re-export wrappers. Internal code now imports directly from
psycopgrather than the redundant Plain-level passthroughs. (d1cb74100e0d)
Upgrade instructions
Replace individual
POSTGRES_*settings withPOSTGRES_URLinapp/settings.py(orPLAIN_POSTGRES_URLin 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_URLin the environment don't need any change.If
POSTGRES_OPTIONSorPOSTGRES_TIME_ZONEwere 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_URLto a direct-to-Postgres connection string soplain migrationsandplain postgres synccan issue DDL.
0.96.0 (2026-04-17)
What's changed
DateTimeFieldgainedcreate_now=True/update_now=Truekwargs;auto_now_addandauto_noware removed.create_now=Trueinstalls a persistentDEFAULT STATEMENT_TIMESTAMP()column default — raw-SQL inserts now get a value, not just ORM-driven ones.update_now=Truestamps the column on everysave()viapre_save. Preflight requiresupdate_now=Trueto be paired withcreate_now=Trueorallow_null=Trueso existing rows have a backfill path.default=is no longer accepted onDateTimeField. (5d145e4, a44e5ec, 091bac7)UUIDFieldgainedgenerate=True;default=GenRandomUUID()is no longer accepted.generate=TrueinstallsDEFAULT 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 aDEFAULTthat slicesgen_random_uuid()::text; use in place of Pythondefault=secrets.token_hexcallables for tokens, slugs, and short IDs. Alphabet is always hex — an earlier draft acceptedalphabet=but it was dropped because the generated expression grew to ~4 KB for a 40-char token. (34858ab, 0918702) - Added
GenRandomUUID()function. Exported atplain.postgres.functions.GenRandomUUID. No longer valid asdefault=; useUUIDField(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. raiseTypeErrorat 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 eachget_default()call. (091bac7) - Literal
default=Xvalues now persist as columnDEFAULTin the catalog and are reconciled by convergence. Previouslydefault=was Python-side only; now it is compiled to a DDLDEFAULT <literal>clause. Raw-SQLINSERTs 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.
AlterFieldis a no-op when onlyallow_nullordefault=changed;plain postgres syncapplies the change with online-safe DDL (CHECK NOT VALID+VALIDATE+SET NOT NULLfor NOT NULL flips; catalog-onlySET/DROP DEFAULTfor 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_timeoutand, where relevant,SET LOCAL statement_timeout. Defaults are3seach and apply to both migration operations and convergence fixes. Non-blocking operations (CREATE INDEX CONCURRENTLY,VALIDATE CONSTRAINT) skipstatement_timeout. Configure via new settingsPOSTGRES_MIGRATION_LOCK_TIMEOUT,POSTGRES_MIGRATION_STATEMENT_TIMEOUT,POSTGRES_CONVERGENCE_LOCK_TIMEOUT,POSTGRES_CONVERGENCE_STATEMENT_TIMEOUT(allPLAIN_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) raiseMigrationSchemaErrorwith scaffold guidance instead of emitting anAlterFieldthat would compile to a blindALTER 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 dropallow_null=Truevia convergence. TheMigrationQuestioner.ask_not_null_*prompts are gone. (091bac7) AddField/AlterFieldno longer acceptpreserve_default. The argument is removed from both operation classes and fromProjectState.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"raisesValueErrorat construction to prevent spurious DEFAULT drift on every convergence run. (f8b6227) choices=is now only accepted onTextField(andTimeZoneField). Other fields (IntegerField,BooleanField, etc.) rejectchoices=at call time. (01584dc)- Removed
IntegerChoicesand theChoicesbase class. OnlyTextChoicesremains; it now subclassesstr, enum.Enumdirectly. (96acf13) max_length=is now only accepted onTextField,BinaryField, andEncryptedTextField. Other fields reject it. (aaa0fb6)default=is no longer accepted onForeignKeyField,ManyToManyField,BinaryField,EncryptedTextField, orEncryptedJSONField. (60299dc, 99ba5c2)ManyToManyFieldsignature is now explicit — it rejectsrequired=,allow_null=,default=, andvalidators=withTypeError. (be7fd86)- Removed
error_messages=from model fields andModelForm.Meta. Form-fielderror_messagesis unchanged; this only affects the model layer. (4dee5ec) PrimaryKeyFieldtakes no arguments. It is alwaysbigint GENERATED BY DEFAULT AS IDENTITY NOT NULL. Removed kwargs forrequired,allow_null,default, andvalidators; the type stub now matches the runtime signature. (ca122c9, 0ecd71e)plain postgres sync --checknow prints pending work. Previously--checkonly 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|LASTcolumns. Indexes likeIndex(fields=["-created_at"])were rebuilt on everypostgres syncbecause the introspection parser misread the sort direction as an opclass. (07cb500) - Fixed
Field.deconstruct()over-shortening import paths —plain.postgres.fields.<submod>.Xnow shortens toplain.postgres.Xonly whenXis actually re-exported at the top level. (34858ab) ModelFormno longer marks DB-expression-default and auto-filled fields asrequired. Fields withdb_returning=True(e.g.create_now=True,generate=True,RandomStringField) andauto_fills_on_save=True(update_now=True) produce form fields withrequired=Falseand preserve theDATABASE_DEFAULTsentinel throughconstruct_instanceso INSERT emitsDEFAULTinstead of NULL on empty submissions.modelfield_to_formfieldnow returnsNonefor non-column-backed fields (M2M, etc.). (6ed95fe)- Internal restructuring.
Fieldis split intoColumnField→DefaultableField→ChoicesFieldwith 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).PrimaryKeyFieldmoved off theBigIntegerField → IntegerFieldchain ontoColumnField[int]directly.non_db_attrsrenamed tonon_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=Truewithcreate_now=Trueon everyDateTimeField. - Replace
auto_now=Truewithupdate_now=True. If the field wasNOT NULL, also setcreate_now=True(orallow_null=True) — preflight will fail otherwise. - Replace
DateTimeField(default=timezone.now)/default=Now()withDateTimeField(create_now=True).DateTimeField(default=...)is no longer accepted. - Replace
UUIDField(default=uuid.uuid4)andUUIDField(default=GenRandomUUID())withUUIDField(generate=True).UUIDField(default=...)is no longer accepted. - Replace
default=secrets.token_hex/default=secrets.token_urlsafewithRandomStringField(length=N)(hex output only). - Replace
default=dict/default=listwithdefault={}/default=[]. Any other callable passed asdefault=will now raiseTypeError. - Remove
choices=from non-text fields (IntegerField,BooleanField, etc.). - Replace
IntegerChoicesusages withTextChoicesor a plainenum.IntEnum.Choices(the base class) is also gone. - Remove
max_length=from any field that isn'tTextField,BinaryField, orEncryptedTextField. - Remove
default=fromForeignKeyField,BinaryField,EncryptedTextField, andEncryptedJSONField. - Remove
required=,allow_null=,default=, andvalidators=fromManyToManyField— 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 andModelForm.Meta. (Form-fielderror_messageson 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=...toAddField/AlterField— the kwarg was removed. - Rename
non_db_attrstonon_migration_attrsin any custom field subclass. - If your migrations hit the new 3s
statement_timeoutagainst a large dev/staging DB, raise it for that run viaPLAIN_POSTGRES_MIGRATION_STATEMENT_TIMEOUT=30s, or passRunSQL(sql, no_timeout=True)on individual long-running operations. - Run
plain postgres syncafter 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 DELETEclauses. The PythonCollector(which walked relationships in Python to fire per-table DELETEs) has been removed.Model.delete()andQuerySet.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()andQuerySet.delete()now returnint(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_deleteconstants are nowOnDeleteinstances, not bare functions.ForeignKeyFieldrejects any non-OnDeletevalue at construction, and the declared action is emitted as the FK's PostgresON DELETEclause. (29e10dba51d9)- Removed
PROTECT,SET(),SET_DEFAULT,ProtectedError, andRestrictedError.PROTECTandSET(callable)had no Postgres equivalent (preferRESTRICT).SET_DEFAULTwas removed because Plain does not currently persist Python model defaults as DB-level column defaults — emittingON DELETE SET DEFAULTwould set children toNULLon bypass-the-ORM deletes, contradicting the model's intent.SET_DEFAULTwill return once DB-level defaults are supported.RESTRICTnow surfaces aspsycopg.errors.IntegrityErrordirectly. (670dab428ad2, 29e10dba51d9) - Renamed
DO_NOTHINGtoNO_ACTIONto match Postgres's SQL term. No behavior change. (5fcf8aa9ced3) - Convergence now owns FK
on_deletedrift.plain postgres syncintrospectspg_constraint.confdeltype, compares it to the declaredon_delete, and replaces the constraint when they drift. Replacement usesADD CONSTRAINT … NOT VALID+VALIDATEto minimize lock time. Existing databases auto-upgrade on their next sync. (211840197e1e) - Preflight rejects
db_constraint=Falsewith a non-NO_ACTIONon_delete. Without a constraint there is no place to attach a deletion action. (29e10dba51d9) - Tightened types.
on_deleteis now typed asOnDeleteeverywhere (wasAny).ForeignKeyField.remote_fieldnarrows toForeignKeyRelsoremote_field.on_deleteis non-optional.ForeignObjectRel,ForeignKeyRel, andManyToManyRel__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 ofpostgres sync). ARunPythondata migration that calls.delete()on a parent with cascading children will orphan the children, and the subsequent convergenceVALIDATEwill fail. Existing databases are unaffected. Documented with a workaround in the Postgres README (delete children explicitly first, or useRunSQL). (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 anint, not a(count, by_label)tuple.- Before:
count, _ = qs.delete()orcount = qs.delete()[0] - After:
count = qs.delete()
- Before:
- Rename
DO_NOTHINGtoNO_ACTIONat all import and usage sites. Regenerate or hand-edit migration files that referenceDO_NOTHING. - Replace
PROTECTwithRESTRICT. Catchpsycopg.errors.IntegrityErrorinstead ofProtectedError/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_DEFAULTusages. Pick a differenton_delete, or set the default explicitly in application code before deletion.SET_DEFAULTwill return once Plain persists column defaults. - Run
plain postgres syncafter upgrading. Convergence will install the correctON DELETEclauses on existing FKs — no migration file, no manual step. - If you set
db_constraint=Falseon a FK with a non-NO_ACTIONon_delete, change the action toNO_ACTION— preflight will now fail otherwise. - Review
RunPythonmigrations that call.delete()on parents with cascading children. On a freshmigrations applybefore convergence runs, children become orphans and break the subsequentVALIDATE. Delete children explicitly first, or useRunSQL.
0.94.2 (2026-04-13)
What's changed
- Updated internal references to use the fixed
app.users.models.Userconvention. (0861c9915cb6) - Migrated type suppression comments to
ty: ignorefor the new ty checker version. (4ec631a7ef51)
Upgrade instructions
- No changes required.
0.94.1 (2026-04-05)
What's changed
- Removed deprecated
db.userattribute from query spans. The attribute was removed from the OTel semconv with no replacement. (b56a9edc9c7d) - Switched
DbSystemValuesto stableDbSystemNameValues. Migrated from the deprecatedopentelemetry.semconv.tracemodule to the stableopentelemetry.semconv.attributes.db_attributes. (b56a9edc9c7d) - Added
error.typeattribute 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 syncandpostgres 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-undeclaredflag has been removed from both commands. (a74b6ab30c14)
Upgrade instructions
- Remove
--drop-undeclaredfrom any scripts or Procfiles that useplain postgres syncorplain 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@deconstructiblepaths pointing toplain.postgresbut weren't exported from it, breaking migration serialization.Fis now exported fromplain.postgres(alongsideQ), and the other classes use their actual module paths. (5fdcb040)Fixed false-positive convergence mismatches for expression-based unique constraints. PostgreSQL's
pg_get_indexdefadds 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 duringpostgres 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.durationOTel histogram for database query timing. Every query executed throughdb_span()now records its duration as an OpenTelemetry histogram metric, following the semantic conventions for database client metrics. Attributes includedb.system.name,db.operation.name, anddb.collection.name. Without a configuredMeterProvider, 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
UniqueConstraintusing both expressions (e.g.Lower("username")) and acondition(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 INDEXstatements, convergence now parsespg_get_indexdefoutput 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 VALIDfollowed byVALIDATE 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 NULLpattern 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 INDEXdefinitions 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 —
TableStatenow uses a unifiedconstraintsdict keyed by constraint name withConTypeenum, replacing the separateunique_constraints,check_constraints, andforeign_keysdicts. (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, orTableState.foreign_keys, update it to use the unifiedTableState.constraintsdict withConTypefiltering 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 syncwill 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
syncandconvergesub-items under section headers for readability in environments without ANSI colors (e.g. Heroku deploy logs). (b6b494dcc698) syncnow usesMigrationExecutordirectly 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 synccommand — 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--checkin 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, orRemoveConstraintoperations — these classes have been removed. Convergence (postgres syncorpostgres converge) creates, renames, rebuilds, and validates indexes and constraints using safe strategies:CREATE INDEX CONCURRENTLY,NOT VALID+VALIDATE CONSTRAINTfor check constraints, andCONCURRENTLY+USING INDEXfor unique constraints. (c58b4ba1fec9, f6506d263f3f, 1f15538b008f)Command renames:
makemigrations→migrations create,migrate→migrations apply. The old top-levelmakemigrationsandmigrateshortcuts have been removed. (adf021688bf3)Removed
--backupflag frommigrations apply— database backups have moved toplain-dev. (50773a50f674)Removed
PositiveIntegerField,PositiveBigIntegerField, andPositiveSmallIntegerField— useIntegerField,BigIntegerField, orSmallIntegerFieldwith aCheckConstraintif you need positivity enforcement. Thedb_checkpipeline 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
--pruneflag 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
Replace
migratewithpostgres syncin deploy scripts and CI.postgres syncapplies migrations and runs convergence in a single step. For CI checks, usepostgres sync --checkinstead ofmigrate --check/makemigrations --check. The lower-level commands are still available asmigrations createandmigrations apply.Remove index/constraint operations from migration files. Delete any
AddIndex,RemoveIndex,RenameIndex,AddConstraint, andRemoveConstraintoperations from your migration files — these classes no longer exist and will cause import errors. It's fine to leave a migration withoperations = []. Indexes and constraints declared on your models will be created automatically by convergence.Replace
PositiveIntegerField(andPositiveBigIntegerField,PositiveSmallIntegerField) withIntegerField(orBigIntegerField,SmallIntegerField) in both models and migration files. Add aCheckConstraintif you need to enforce positive values.Run
plain postgres syncafter upgrading to create indexes and constraints via convergence.If you used
plain postgres backups, installplain-dev>=0.60.0— backups have moved toplain dev backups.
0.90.0 (2026-03-28)
What's changed
- Removed
CharField— useTextFieldfor all string fields. PostgreSQL treatsvarcharandtextidentically (same storage, same performance), so the distinction was unnecessary.TextFieldnow accepts an optionalmax_lengthfor Python-side validation viaMaxLengthValidator, without affecting the database column type. (5062ee4dd1fd) EmailFieldandURLFieldnow extendTextFieldinstead ofCharField. Their defaultmax_lengthvalues (254 and 200 respectively) have been removed — passmax_lengthexplicitly if you need validation. (5062ee4dd1fd)- Simplified field class internals — removed the
get_internal_type()method and 6 lookup dicts fromdialect.py. Each field class now declares its SQL type directly viadb_type_sqlclass attribute. String-based type comparisons replaced withisinstance()checks throughout. (3ffdebe22250) - Added
postgres convergecommand — detects and fixes safe schema mismatches between models and the database. Currently handlescharacter varying→textconversions. (fe8cf3995e95)
Upgrade instructions
- Replace
CharFieldwithTextFieldin model code (e.g.types.CharField(max_length=100)→types.TextField(max_length=100)) - Replace
CharFieldwithTextFieldin migration files (e.g.postgres.CharField(max_length=255)→postgres.TextField(max_length=255)) - If you subclass
CharField, change the parent class toTextField EmailFieldno longer defaultsmax_length=254andURLFieldno longer defaultsmax_length=200— remove these from migration files if present (e.g.postgres.EmailField(max_length=254)→postgres.EmailField())- Run
plain postgres convergeto convert existingcharacter varyingcolumns totext(in development and production). The conversion is instant and safe — PostgreSQL treats them identically. Use--yesto skip confirmation in CI/deploy scripts.
0.89.2 (2026-03-27)
What's changed
- Fixed
schemacommand miscategorizing expression-based unique constraints as missing columns (93ab244416f8) - Used canonical Postgres type names in
DATA_TYPESmapping, removing the_normalize_typehelper (f581fe6009bd) - Moved
diagnose/module tointrospection/, consolidated into 2 files, added schema introspection functions used byschemaanddrop-unknown-tablescommands (86f7f5b85a87) diagnose --jsonnow 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
CharFieldtoTextFieldin model forms (4e29f5d6cade) - Changed CLI confirmation flags to
--yes/-yacross all commands (0af36e101f03)
Upgrade instructions
- Requires
plain>=0.129.0. If you useplain postgres diagnose --jsonexit 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
schemacommand type mismatches fortime,timestamp, andDecimalFieldtypes that caused false drift reports (187e39e3faeb) - Fixed
schemacommand crash on expression-based unique constraints (e.g.UniqueConstraintwithexpressionsinstead offields) (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_indexfromForeignKeyField— FK fields no longer create indexes automatically. Declare an explicitIndex(fields=["field"], name="...")for any FK column that needs one. Thedb_indexparameter has been removed entirely. (061b97f5d538) - Removed
Index.set_name_with_model()— the hash-based auto-naming machinery is gone.Index.nameis 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 schemacommand — 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--checkfor CI (exits non-zero on drift). (ee336078483f)
Upgrade instructions
Remove any
db_index=Falsefrom FK fields in models and migration files — the parameter no longer exists.For each
ForeignKeyField, check if it's covered by an explicitIndexorUniqueConstraint(with the FK as the leading field). Most FK columns should have an index.If uncovered, add an explicit index:
model_options = postgres.Options( indexes=[ postgres.Index(name="myapp_mymodel_author_id_idx", fields=["author"]), ], )Run
makemigrations. Before theAddIndexoperation, add aRunSQLto drop the orphan auto-index left behind by the olddb_index=Truedefault: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 runningplain postgres schema.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"'), ]Run
migrate.
0.88.2 (2026-03-25)
What's changed
- Actually enforce
nameas a required keyword argument onIndex.__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
Indexnow requires anameargument — auto-naming (set_name_with_model) is no longer used for new indexes. Use the{table}_{column(s)}_idxconvention (e.g.,plainjobs_jobrequest_priority_idx). (74aa8b76aa40)- Raised
Index.max_name_lengthfrom 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 allIndexobjects in your models. Use the{table}_{column}_idxconvention. Runmakemigrations— it will auto-generateRenameIndexoperations (instantALTER INDEX RENAME, no locks). Then runmigrate.
0.87.0 (2026-03-25)
What's changed
- Renamed
plain dbCLI toplain postgres— all subcommands (migrate,diagnose,wait,backups, etc.) are now underplain postgres(a639aeacbf8d) - Extracted diagnose checks into
plain.postgres.diagnosepackage — 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 dbwithplain postgresin all scripts, CI configs, and documentation. The oldplain dbcommand no longer exists.
0.86.0 (2026-03-24)
What's changed
- New
plain db diagnosecommand — 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 (viapg_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 checkand flag issues before they hit production (3703fe8ab38d) - New
plain-postgres-diagnoseAI 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 andconnection.set_read_only()— enforcesSET default_transaction_read_only = ONso any write attempt raises a database error (69d23b04fde9) - Removed PEP-249 exception mirror —
IntegrityError,OperationalError,ProgrammingError, etc. are no longer re-exported fromplain.postgres. Usepsycopgexceptions directly (e.g.psycopg.IntegrityError) (d4b170e60a2c) - Removed
DatabaseErrorWrappercontext 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(orOperationalError,ProgrammingError, etc.) withimport psycopgand usepsycopg.IntegrityErrordirectly. - Replace any usage of
plain.postgres.db.DatabaseErrorWrapperwith standardtry/excepton psycopg exceptions.
0.84.2 (2026-03-20)
What's changed
- Migrated all internal logging to structured format using
get_framework_logger()and flatextra={}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_cursorto_maintenance_cursorfor clarity — it now always connects to thepostgresdatabase directly instead of falling back through multiple connection strategies (27bdee72d03e, f15b46ede57d) DATABASEconfig key is now required (validated at configure time) rather than allowingNone/empty string with runtime fallbacks (f15b46ede57d)
Upgrade instructions
- No changes required.
0.84.0 (2026-03-12)
What's changed
- Renamed package from
plain-modelstoplain-postgres— the pip package, module path, and package label (plainmodelstoplainpostgres) all reflect the PostgreSQL-only scope. - All internal imports updated from
plain.modelstoplain.postgres. - Flattened
plain.models.postgressubpackage into top-levelplain.postgres.
Upgrade instructions
- Update imports:
from plain.modelstofrom plain.postgres,from plain import modelstofrom plain import postgres. - In
pyproject.toml, changeplain-modelstoplain-postgresandplain.modelstoplain.postgresin dependencies. - In
INSTALLED_PACKAGES, change"plain.models"to"plain.postgres".
0.83.0 (2026-03-12)
What's changed
- Renamed
plain.modelstoplain.postgres— the package name now reflects its PostgreSQL-only scope. - Flattened the
plain.models.postgressubpackage — all internal modules now live at the top level.
Upgrade instructions
- Change
from plain import models→from plain import postgresand update allmodels.Xusages topostgres.X(e.g.postgres.Model,postgres.register_model,postgres.CASCADE). - Change
from plain.models import ...→from plain.postgres import .... - In
pyproject.toml, changeplain.models→plain.postgresandplain-models→plain-postgresin dependencies. - In
INSTALLED_PACKAGES, change"plain.models"→"plain.postgres".
0.82.3 (2026-03-10)
What's changed
- Removed
type: ignorecomments onPOSTGRES_PASSWORDdefault values, now thatSecretis 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_SetOnDeleteclass to eliminatetype: ignorecomments for dynamic attribute assignment (deconstruct,lazy_sub_objs) (cda461b1b4f6) - Replaced
lazy_sub_objsfunction attribute onSET_NULLandSET_DEFAULTwith a module-level_LAZY_ON_DELETEset (cda461b1b4f6) - Narrowed
_relation_treetype and usedget_forward_fieldin 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
EncryptedTextFieldandEncryptedJSONFieldfor transparent encryption at rest using Fernet (AES-128-CBC + HMAC-SHA256) with keys derived fromSECRET_KEY(73f3534f9334) - Encrypted fields support key rotation via
SECRET_KEY_FALLBACKSand 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
cryptographypackage 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 queries —QuerySet.iterator()now uses psycopg3's native server-side streaming instead offetchmany()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
DatabaseWrapper→DatabaseConnectionand moved frompostgres/wrapper.pytopostgres/connection.pyto better reflect the class's purpose (7f17a96a7f8e, 4a79279d01dd) - Replaced
db_connectionproxy withget_connection()— the statelessDatabaseConnectionproxy class is removed in favor of module-levelget_connection()andhas_connection()functions, giving type checkers direct access to the realDatabaseConnectionclass and eliminating proxy overhead (4a79279d01dd) - Replaced
threading.local()withContextVarfor DB connection storage — database connections are now stored per-context instead of per-thread, enabling proper async support (cc2469b1260a) - Removed
validate_thread_sharing()fromDatabaseConnection— thread sharing validation is no longer needed with ContextVar-based connection storage (3a6d6efd09d2) - Extracted
get_converters()andapply_converters()as standalone functions fromSQLCompilerand added type annotations (ed18d3c97142)
Upgrade instructions
- Replace
from plain.models import db_connectionwithfrom plain.models import get_connection, and changedb_connection.cursor()toget_connection().cursor()(and similar attribute access). - If you imported
DatabaseWrapper, it is nowDatabaseConnectionfromplain.models.postgres.connection. - PostgreSQL 16 or higher is now required.
0.80.0 (2026-02-25)
What's changed
- Replaced the
DATABASEdict setting with individualPOSTGRES_*settings (POSTGRES_HOST,POSTGRES_PORT,POSTGRES_DATABASE,POSTGRES_USER,POSTGRES_PASSWORD, etc.) configurable viaPLAIN_POSTGRES_*environment variables orapp/settings.py(e3c5a32d4da6) DATABASE_URLstill works and takes priority — individual settings are parsed from it automatically (e3c5a32d4da6)- Added
DATABASE_URL=noneto explicitly disable the database (e.g. during Docker builds) (e3c5a32d4da6) - Removed the
AUTOCOMMITconfig 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
DATABASEdict in settings, replace it with individualPOSTGRES_*settings:# Before DATABASE = {"NAME": "mydb", "USER": "me", "HOST": "localhost"} # After POSTGRES_DATABASE = "mydb" POSTGRES_USER = "me" POSTGRES_HOST = "localhost"The
DATABASEdict key"NAME"is now"DATABASE"internally — update any code that accessedsettings_dict["NAME"]directly.Remove any
AUTOCOMMITsetting from your database config — it is no longer recognized.
0.79.0 (2026-02-24)
What's changed
- Added
plain db drop-unknown-tablescommand to remove database tables not associated with any Plain model (108b0bce59e6) - The unknown-tables preflight warning now suggests running
plain db drop-unknown-tablesinstead 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
ENGINEkey has been removed from theDATABASEsetting — it is no longer needed since PostgreSQL is implicit (6f3a066bf80f) - Database backends consolidated from
backends/base/,backends/postgresql/,backends/mysql/, andbackends/sqlite3/into a singlepostgres/module (6f3a066bf80f) - Removed
DatabaseOperationsindirection layer — compilers are now created directly byQuery.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 yourDATABASEsetting — it will be ignored - If you were using MySQL or SQLite, you must migrate to PostgreSQL
- Update any imports from
plain.models.backends.baseorplain.models.backends.postgresqltoplain.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-runnow shows a SQL preview of the statements each migration would execute, making it easier to review schema changes before writing migration files (c994703f9a28)makemigrationsnow warns when packages have models but nomigrations/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.pyfiles (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, andmodel_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 toexpressionsmodule for explicit public API boundaries (e7164d3891b2) - Refactored internal imports to use explicit module paths instead of the
sqlnamespace (e7164d3891b2) - Updated agent rules to use
--apiinstead of--symbolsforplain docscommand (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.querypattern (02e11328dbf5)
Upgrade instructions
- No changes required.
0.76.2 (2026-01-28)
What's changed
- Converted the
plain-modelsskill to a passive.claude/rules/file (512040ac51)
Upgrade instructions
- Run
plain agent installto update your.claude/directory.
0.76.1 (2026-01-28)
What's changed
- Added Settings section to README (803fee1ad5)
Upgrade instructions
- No changes required.
0.76.0 (2026-01-22)
What's changed
- Removed the
db_columnfield parameter - column names are now always derived from the field name (eed1bb6) - Removed the
db_collationfield parameter fromCharFieldandTextField- use raw SQL or database-level collation settings instead (49b362d) - Removed the
Collatedatabase function fromplain.models.functions(49b362d) - Removed the
db_commentfield parameter anddb_table_commentmodel option - database comments are no longer supported (eb5aabb) - Removed the
AlterModelTableCommentmigration operation (eb5aabb) - Added
BaseDatabaseSchemaEditorandStateModelsRegistryexports fromplain.models.migrationsfor use in type annotations inRunPythonfunctions (672aa88)
Upgrade instructions
- Remove any
db_columnarguments from field definitions - the column name will always match the field's attribute name (with_idsuffix for foreign keys) - Remove
db_columnfrom all migrations - Remove any
db_collationarguments fromCharFieldandTextFielddefinitions - Replace any usage of
Collate()function with raw SQL queries or configure collation at the database level - Remove any
db_commentarguments from field definitions - Remove
db_commentfrom all migrations - Remove any
db_table_commentfrommodel_optionsdefinitions - Replace
AlterModelTableCommentmigration operations withRunSQLif database comments are still needed
0.75.0 (2026-01-15)
What's changed
- Added type annotations to
CursorWrapperfetch methods (fetchone,fetchmany,fetchall) for better type checker support (7635258) - Internal cleanup: removed redundant
tzinfoclass attribute fromTruncBase(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
TimezoneFielddeconstruct path to correctly resolve toplain.modelsinstead ofplain.models.fields.timezones, preventing migration churn when usingTimezoneField(03cc263)
Upgrade instructions
- No changes required
0.71.0 (2026-01-13)
What's changed
TimeZoneFieldchoices are no longer serialized in migrations, preventing spurious migration diffs when timezone data differs between machines (0ede3aae)TimeZoneFieldno longer accepts custom choices - the field's purpose is to provide the canonical timezone list (0ede3aae)- Simplified
plain migrateoutput - 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
TimeZoneFieldwith serializedchoices, you can safely remove thechoicesparameter from those migrations as they are now computed dynamically - If you were passing custom
choicestoTimeZoneField, this is no longer supported - use a regularCharFieldwith choices instead
0.70.0 (2025-12-26)
What's changed
- Added
TimeZoneFieldfor storing timezone information - stores timezone names as strings in the database but provideszoneinfo.ZoneInfoobjects when accessed, similar to howDateFieldworks withdatetime.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
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 listcommand displays this information along with source and size in a table format (287fa89f) - Added
--branchoption toplain db backups listto filter backups by git branch (287fa89f) ReverseForeignKeyandReverseManyToManynow 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 anySequencefor 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(), anddifference()combinator methods from QuerySet - use raw SQL for set operations instead (0bae6abd) - Removed
dates()anddatetimes()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), orqueryset.difference(other_qs)with raw SQL queries usingModel.query.raw()or database cursors - Replace
queryset.dates(field, kind)with equivalent annotate/values_list queries usingTruncandDateField - Replace
queryset.datetimes(field, kind)with equivalent annotate/values_list queries usingTruncandDateTimeField - 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)withqueryset.filter(id=obj.id).exists()
0.65.1 (2025-12-04)
What's changed
- Fixed type annotations for
get_rhs_opmethod in lookup classes to acceptstr | list[str]parameter, resolving type checker errors when usingRangeand 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
ReverseForeignKeyandReverseManyToManydescriptors - 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()andbulk_update()now accept anySequencetype (e.g., tuples, generators) instead of requiring alist(6c7469f)
Upgrade instructions
- No changes required
0.63.1 (2025-11-21)
What's changed
- Fixed
ManyToManyFieldpreflight checks that could fail when the intermediate model contained non-related fields (e.g.,CharField,IntegerField) by properly filtering to only checkRelatedFieldinstances when counting foreign keys (4a3fe5d)
Upgrade instructions
- No changes required
0.63.0 (2025-11-21)
What's changed
ForeignKeyhas been renamed toForeignKeyFieldfor consistency with other field naming conventions (8010204)- Improved type annotations for
ManyToManyField- now returnsManyToManyManager[T]instead ofAnyfor better IDE support (4536097) - Related managers (
ReverseForeignKeyManagerandManyToManyManager) are now generic classes with proper type parameters for improved type checking (3f61b6e) - Added
ManyToManyManagerandReverseForeignKeyManagerexports toplain.models.typesfor use in type annotations (4536097)
Upgrade instructions
- Replace all usage of
models.ForeignKeywithmodels.ForeignKeyField(e.g.,category = models.ForeignKey("Category", on_delete=models.CASCADE)becomescategory = models.ForeignKeyField("Category", on_delete=models.CASCADE)) - Replace all usage of
types.ForeignKeywithtypes.ForeignKeyFieldin typed model definitions - Update migrations to use
ForeignKeyFieldinstead ofForeignKey
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_fieldattribute (60b1bcc)
Upgrade instructions
- No changes required
0.62.0 (2025-11-20)
What's changed
- The
namedparameter has been removed fromQuerySet.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
isinstancechecks 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=Trueparameter invalues_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_transformdecorator has been removed fromModelBaseto avoid type checker issues (e0dbedb) - Documentation and examples no longer suggest using
ClassVarfor QuerySet type annotations - the simplerquery: models.QuerySet[Model] = models.QuerySet()pattern is now recommended (1c624ff, 99aecbc)
Upgrade instructions
- If you were using
ClassVarannotations for thequeryattribute, you can optionally remove theClassVarwrapper and thefrom typing import ClassVarimport. Both patterns work, but the simpler version withoutClassVaris now recommended.
0.61.0 (2025-11-14)
What's changed
- The
related_nameparameter has been removed fromForeignKeyandManyToManyField- reverse relationships are now declared explicitly usingReverseForeignKeyandReverseManyToManydescriptors on the related model (a4b630969d) - Added
ReverseForeignKeyandReverseManyToManydescriptor classes toplain.models.typesfor declaring reverse relationships with full type support (a4b630969d) - The new reverse descriptors are exported from
plain.modelsfor easy access (97fa112975) - Renamed internal references from
ManyToOnetoForeignKeyfor 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_nameparameters fromForeignKeyandManyToManyFielddefinitions - Remove
related_namefrom all migrations - On the related model, add explicit reverse relationship descriptors using
ReverseForeignKeyorReverseManyToManyfromplain.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")
- For the reverse side of a
- Remove any
TYPE_CHECKINGblocks that were used to declare reverse relationship types - the new descriptors provide full type support without these hacks - The
toparameter accepts either a string (model name) or the model class itself - The
fieldparameter 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
ClassVarto improve type checking when accessingModel.query(c3b00a6) - The
idfield 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
ClassVartype annotations to custom QuerySets on your models usingquery: 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.typeswith type annotations (f95d32d)
Upgrade instructions
- Optionally (but recommended) move to typed model field definitions by using
name: str = types.CharField(...)instead ofname = models.CharField(...). Types can be imported withfrom plain.models import types.
0.59.0 (2025-11-13)
What's changed
- Added a new
plain.models.typesmodule with type stub support (.pyi) for improved IDE and type checker experience when defining models (c8f40fc) - Added
@dataclass_transformdecorator toModelBaseto enable better type checking for model field definitions (c8f40fc)
Upgrade instructions
- No changes required
0.58.0 (2025-11-12)
What's changed
- Internal base classes have been converted to use Python's ABC (Abstract Base Class) module with
@abstractmethoddecorators, improving type checking and making the codebase more maintainable (b1f40759, 7146cabc, 74f9a171, b647d156, 6f3e35d9, 95620673, 7ff5e98c, 78323300, df82434d, 16350d98, 066eaa4b, 60fabefa, 9f822ccc, 6b31752c) - Type annotations have been improved across database backends, query compilers, and migrations for better IDE support (f4dbcefa, dc182c2e)
Upgrade instructions
- No changes required
0.57.0 (2025-11-11)
What's changed
- The
plain.modelsimport 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
ValueErrorinstead 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
- Aggregates:
- Remove any positional arguments in model instantiation and use keyword arguments instead (e.g.,
User("John", "Doe")becomesUser(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 shellandplain migrations prune) instead of the oldplain modelscommands (b293750)
Upgrade instructions
- No changes required
0.56.0 (2025-11-03)
What's changed
- The CLI has been reorganized into separate
plain dbandplain migrationscommand groups for better organization (7910a06) - The
plain modelscommand group has been removed - useplain dbandplain migrationsinstead (7910a06) - The
plain backupscommand group has been removed - useplain db backupsinstead (dd87b76) - Database backup output has been simplified to show file size and timestamp on a single line (765d118)
Upgrade instructions
- Replace
plain models db-shellwithplain db shell - Replace
plain models db-waitwithplain db wait - Replace
plain models listwithplain db list(note: this command was moved to the main plain package) - Replace
plain models show-migrationswithplain migrations list - Replace
plain models prune-migrationswithplain migrations prune - Replace
plain models squash-migrationswithplain migrations squash - Replace
plain backupscommands withplain db backups(e.g.,plain backups listbecomesplain db backups list) - The shortcuts
plain makemigrationsandplain migratecontinue 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
plainmodelspackage label instead ofmodels(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-groupsstandard instead oftool.uv.dev-dependencies(1b43a3a)
Upgrade instructions
- No changes required
0.53.0 (2025-10-12)
What's changed
- Added new
plain models prune-migrationscommand to identify and remove stale migration records from the database (998aa49) - The
--pruneoption has been removed fromplain migratecommand in favor of the dedicatedprune-migrationscommand (998aa49) - Added new preflight check
models.prunable_migrationsthat warns about stale migration records in the database (9b43617) - The
show-migrationscommand no longer displays prunable migrations in its output (998aa49)
Upgrade instructions
- Replace any usage of
plain migrate --prunewith the newplain models prune-migrationscommand
0.52.0 (2025-10-10)
What's changed
- The
plain migratecommand now shows detailed operation descriptions and SQL statements for each migration step, replacing the previous verbosity levels with a cleaner--quietflag (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/--verbosityoption has been removed fromplain migratein favor of the simpler--quietflag for suppressing output (d6b041bd24)
Upgrade instructions
- Replace any usage of
-vor--verbosityflags inplain migratecommands with--quietif you want to suppress migration output
0.51.1 (2025-10-08)
What's changed
- Fixed a bug in
SubqueryandExistsexpressions that was using the oldqueryattribute name instead ofsql_querywhen 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_optionsfor user-defined configuration and_model_metafor internal metadata (73ba469, 17a378d) - The
_metaattribute has been replaced withmodel_optionsfor user-defined options like indexes, constraints, and database settings (17a378d) - Custom QuerySets are now assigned directly to the
queryclass attribute instead of usingMeta.queryset_class(2578301) - Added comprehensive type improvements to model metadata and related fields for better IDE support (3b477a0)
Upgrade instructions
- Replace
Meta.queryset_class = CustomQuerySetwithquery = CustomQuerySet()as a class attribute on your models - Replace
class Meta:withmodel_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
QuerySetclass is now generic and themodelparameter is now required in the__init__method (719e792) - Database wrapper classes have been renamed for consistency:
DatabaseWrapperclasses are now namedMySQLDatabaseWrapper,PostgreSQLDatabaseWrapper, andSQLiteDatabaseWrapper(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 fromplain.exceptionstoplain.models.exceptions(1c02564) - The
get_FOO_display()methods for fields with choices have been replaced with a singleget_field_display(field_name)method (e796e71) - The
get_next_by_*andget_previous_by_*methods for date fields have been removed (3a5b8a8) - The
idprimary key field is now defined directly on the Model base class instead of being added dynamically via Options (e164dc7) - Model
DoesNotExistandMultipleObjectsReturnedexceptions now use descriptors for better performance (8f54ea3)
Upgrade instructions
- Update imports for model exceptions from
plain.exceptionstoplain.models.exceptions(e.g.,from plain.exceptions import ObjectDoesNotExistbecomesfrom plain.models.exceptions import ObjectDoesNotExist) - Replace any usage of
instance.get_FOO_display()withinstance.get_field_display("FOO")where FOO is the field name - Remove any usage of
get_next_by_*andget_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-batchoptions toplain migrateto 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.queryproperty has been renamed toQuerySet.sql_queryto better distinguish it from theModel.querymanager interface (d250eea)
Upgrade instructions
- If you directly accessed the
QuerySet.queryproperty in your code (typically for advanced query manipulation or debugging), rename it toQuerySet.sql_query
0.46.1 (2025-09-25)
What's changed
- Fixed
prefetch_relatedfor 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
PreflightResultclass that unifies messages and hints into a singlefixfield, 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.E003becomesmodels.duplicate_many_to_many_relations) (cd96c97) - Removed deprecated field types:
CommaSeparatedIntegerField,IPAddressField, andNullBooleanField(345295dc) - Removed
system_check_deprecated_detailsandsystem_check_removed_detailsfrom fields (e3a7d2dd)
Upgrade instructions
- Remove any usage of the deprecated field types
CommaSeparatedIntegerField,IPAddressField, andNullBooleanField- useCharField,GenericIPAddressField, andBooleanField(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
varcharcolumns instead ofvarchar()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
_metatype annotation to theModelclass for improved type checking and IDE support (387b92e)
Upgrade instructions
- No changes required
0.43.0 (2025-09-12)
What's changed
- The
related_nameparameter 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_setsuffixes 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
queryproperty 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 therelated_nameparameter entirely - Update any code that relied on automatic
_setsuffixes - these are no longer generated, so you must use explicitrelated_namevalues - Add explicit
related_namearguments 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_namearguments that are not used in practice
0.42.0 (2025-09-12)
What's changed
- The model manager interface has been renamed from
.objectsto.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
objectsmanager 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.objectswithModel.queryin your codebase (e.g.,User.objects.filter()becomesUser.query.filter()) - If you have custom managers, convert them to custom QuerySets and set them using
Meta.queryset_classinstead 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, managercreation_counter,use_in_migrations,auto_created, and database routing hints - Any reverse accessors (typically
<related_model>_setor defined byrelated_name) will now return a manager class for the additionaladd(),remove(),clear(), etc. methods and the regular queryset methods will be available via.query(e.g.,user.articles.first()becomesuser.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(), andget_latest_bymodel meta option - useorder_by().first()andorder_by().last()instead (b6093a8) - Removed automatic ordering in
first()andlast()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 modelMetaget_latest_byqueryset methods with equivalentorder_by().first()ororder_by().last()calls - The
first()andlast()methods no longer automatically add ordering byid- explicitly add.order_by()to your querysets ororderingto your modelsMetaclass 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
SimpleLazyObjectused forrequest.user) by automatically evaluating them (eb78dcc76d) - Added
--no-inputoption toplain migratecommand to skip user prompts (0bdaf0409e) - Removed the
plain models optimize-migrationcommand (6e4131ab29) - Removed the
--fake-initialoption fromplain migratecommand (6506a8bfb9) - Fixed CLI help text to reference
plaincommands instead ofmanage.py(8071854d61)
Upgrade instructions
- Remove any usage of
plain models optimize-migrationcommand - it is no longer available - Remove any usage of
--fake-initialoption fromplain migratecommands - 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 likerequest.user. These will now be automatically evaluated.
0.39.2 (2025-07-25)
What's changed
- Fixed remaining
to_field_nameattribute usage inModelMultipleChoiceFieldvalidation to useiddirectly (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
idfield as the primary key, replacing the previouspkalias and automatic field system (4b8fa6a) - Removed the
to_fieldoption for ForeignKey - foreign keys now always reference the primary key of the related model (7fc3c88) - Removed the internal
from_fieldsandto_fieldssystem used for multi-column foreign keys (0e9eda3) - Removed the
parent_linkparameter on ForeignKey and ForeignObject (6658647) - Removed
InlineForeignKeyFieldfrom 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
pkwithidin your models and queries (e.g.,user.pkbecomesuser.id) - Remove any
to_fieldarguments from ForeignKey definitions - they are no longer supported - Remove any
parent_link=Truearguments from ForeignKey definitions - they are no longer supported - Replace any usage of
InlineForeignKeyFieldin forms with standard form fields models.BigAutoField(auto_created=True, primary_key=True)need to be replaced withmodels.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
--mergeoption from themakemigrationscommand (d366663) - Improved error handling in the
restore-backupcommand using Click's error system (88f06c5)
Upgrade instructions
- No changes required
0.35.0 (2025-07-07)
What's changed
- Added the
plain models listCLI 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-onlyflag to only show first-party app models (1bc40ce). - The MySQL backend no longer enforces a strict
mysqlclient >= 1.4.3version check and had several unused constraint-handling methods removed, reducing boilerplate and improving compatibility with a wider range ofmysqlclientversions (6322400, 67f21f6).
Upgrade instructions
- No changes required
0.34.4 (2025-07-02)
What's changed
- The built-in
on_deletebehaviors (CASCADE,PROTECT,RESTRICT,SET_NULL,SET_DEFAULT, and the callables returned bySET(...)) no longer receive the legacyusingargument. Their signatures are now(collector, field, sub_objs)(20325a1). - Removed the unused
interprets_empty_strings_as_nullsbackend 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_cursorfallback that could raiseTypeError: __init__() got an unexpected keyword argument 'alias'when the maintenance database wasn't available (3e49683). - Restored support for the
USINGclause when creating PostgreSQL indexes; custom index types such asGINandGISTare 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
DATABASEsetting instead ofDATABASESand removedDATABASE_ROUTERS. A helper still automatically populatesDATABASEfromDATABASE_URLjust like before (d346d81). - The
plain.models.dbmodule now exposes adb_connectionobject that lazily represents the active database connection. Previousconnections,router, andDEFAULT_DB_ALIASexports were removed (d346d81).
Upgrade instructions
- Replace any
DATABASESdefinition in your settings with a singleDATABASEdict (keys are identical to the inner dict you were previously using). - Remove any
DATABASE_ROUTERSconfiguration – multiple databases are no longer supported. - Update import sites:
from plain.models import connections→from plain.models import db_connectionfrom plain.models import router→ (no longer needed; remove usage or switch todb_connectionwhere appropriate)from plain.models.connections import DEFAULT_DB_ALIAS→ (constant removed; default database is implicit)