The full-stack Python framework designed for humans and agents. Follow for development updates. Follow @davegaeddert for more background.

Kansas
Plain now requires Python 3.14. The first payoff is sql() in plain-postgres: the SQL is the literal parts of a t-string, every interpolated value binds as a parameter, and passing a plain str is a type error.
20
Starting to rework some of the postgres APIs
1
22
A Plain package can now ship top-level `plain` commands that run without loading your app — a new entry point group that skips runtime setup. That matters exactly when the app is the problem: `plain env` still runs on a fresh clone with no encryption key, right when you need it.
15
We're getting rid of plain-redirection, for now. Agents are getting better by the day and the value prop of official framework packages is shifting — some things are better left to the user (agent). The 404 log was the valuable piece; that's solved by tracing now.
1
19
We recently renamed ORM internals across three releases, no deprecation cycle. Plain changelogs ship "Upgrade instructions" written for agents — /plain-upgrade reads the span you're crossing, fixes your code, and proves it with plain check.
35
plain docs cache --api prints the real signatures for the version you have installed. Docs ship inside each Plain package — your agent reads them instead of guessing from training data.
18
plain.loginlink: link expiration is now a class attribute — link_expires_in = 60 * 15 on your form, no method override needed. Docs now spell out the real semantics: links stay valid until expiry, deliberately, so mail scanners can't burn them before your user clicks.
16
The Plain CLI now works even when your app won't boot. `plain --help` still shows your commands, and `plain docs`, `plain install`, and `plain upgrade` don't load the app at all.
14
RedirectResponse refuses external URLs unless you pass allow_external=True. That check now blocks any scheme-prefixed URL: browsers normalize `http:/evil.com` to `evil.com`, so matching on `://` alone let single-slash URLs through.
1
30
Plain packages ship their own AI agent context. `plain agent install` syncs rules and skills from every installed package into your project's .claude/ — install plain.jobs and your coding agent learns how to write jobs.
1
31
Dev secrets can now live in git. `plain env set` encrypts a value into your committed .env.dev, one key per project. A fresh clone — or a hosted agent sandbox — just needs DEV_ENV_KEY and every dev credential comes with the repo.
1
28
A typing pass on Plain's ORM internals: `type: ignore`s replaced with real narrowing, `Field.concrete` removed, Meta's field collections collapsed to `fields` + `many_to_many`. The type checker kept pointing at design debt, so we removed the debt instead of ignoring it.
1
36
plain dev runs your whole local setup in one command: preflight checks, pending migrations, an auto-reloading server, Tailwind watch, and Postgres. It serves HTTPS by default with locally-trusted certs, so dev behaves like production from the first request.
1
2
33
Unique, check, and now foreign key violations in Plain all surface the same way: create()/update() attempt the write, Postgres rejects it, and you get a ValidationError on the field — "Author with id 5 does not exist." No pre-check SELECTs, no raced-duplicate 500s.
2
30
We found server connection errors dying on asyncio's default handler — a logger Plain never configured, invisible to any APM. 0.160.0 closes that: every exception in a Plain app lands in an OTel entry span or an ERROR log record. Both export. There is no third place.
1
36
plain portal gives you a Python shell into your production app over an encrypted tunnel. Starting one now requires an explicit --read-only or --read-write — no default. The database mode is right there in the command, so a human (or an agent) can judge it before it runs.
1
31
Response.status_code is read-only in Plain now. The status decides things everything downstream builds on — whether a body is allowed, how the response is framed on the wire — so it's fixed at construction. Renderers take it directly: self.render(form=form, status_code=422)
1
26
Every constraint in Plain is now checked at the write that violates it — never at COMMIT. FKs are NOT DEFERRABLE (deferral was a 2007 Django fixtures thing; Plain has no fixtures), and a bad FK in create()/update() is a ValidationError on the field, not a commit-time traceback.
2
68
We were seeing intermittent 503s behind Heroku's router that never showed up in our app logs. Root cause: a 204 response with a body. The body bytes go onto the keep-alive connection unframed, and the router reads them as the start of the next response.
1
1
328
plain 0.160.0 makes that bug unrepresentable instead of just catching it: bodiless responses (204, 304, HEAD) refuse content at construction, and Response.status_code is read-only — so you can't build a valid response and mutate it into an invalid one.
31