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.
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.
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.
Trailing slashes are one setting in Plain. URLS_TRAILING_SLASH picks the canonical form for the whole project, and the slash you type in a route string is ignored — path("about") and path("about/") are the same route.
https://t.co/WxABDhgJ2n now manages development databases so they track your worktrees automatically. A new checkout gets its own copy of your main database, data included, the first time you run the dev server there — nothing to configure, nothing to name.
Plain drops the _id half of foreign keys. `https://t.co/p90OMjlGtV` gives you an Author instance with only its id loaded — free to read, free to assign, and it loads the rest only when you ask for it.
Replaced https://t.co/H9IaC5J2gu() with create() and update() in Plain.
The old version branched on whether a primary key was set, which meant reading a save() call told you nothing about whether it wrote a new row.
Two new packages in Plain: plain.mcp and plain.oauthserver.
Together they mean an AI client can connect to your app as a real, authenticated user — PKCE, dynamic client registration, token rotation.
Worker recycling in Plain got smarter: the default went from every 1,000 requests to every 10,000 (production data showed constant churn with flat memory), and a recycling worker now serves requests that arrive mid-shutdown instead of closing the socket on them.
Plain's HTTP server now protects itself while receiving request bodies: oversized uploads get a 413 straight from the headers, an upload flood load-sheds with a 503, and slow-drip (R.U.D.Y.) attacks that trickle bytes to pin a connection get a 408. All on by default.
Plain splits schema management in two.
Migrations handle what only you can do safely — data backfills, risky type changes.
Everything derivable from your models — indexes, constraints, NOT NULL, FK actions — moves to a "convergence" layer that reconciles the database.
Plain doesn't auto-index foreign keys — you declare the indexes you want. Add a composite index starting with that FK and the single-column one is redundant anyway: Postgres uses the composite's leftmost prefix. Explicit indexes, no overlap by default.
Plain's HTTP server used to close idle keep-alive connections after 2s, while routers like Heroku's pool them for minutes — the mismatch behind H13 "Connection closed without response". Now there's SERVER_KEEPALIVE_TIMEOUT (default 5 min), so the balancer always closes first.
A lot of Plain's recent design is about being easy for an AI agent to work in. `plain request /path --json` returns a traced request as structured JSON — query counts, N+1s, the span tree — so an agent can see what a page actually did, not just its HTML.
Plain no longer has CharField. In Postgres, varchar and text store and perform identically, so a max-length field wasn't really buying anything. It's just TextField now.
Plain ships its own HTTP server, and 0.159.0 gives it a new request-body pipeline.
A 25MB upload went from 24s to 0.24s, and a slow upload no longer ties up a worker thread.
Plain dev used to modify your etc/hosts so you could use myapp.localhost for local dev... turns out this isn't always necessary anymore?!
Importantly, this gets rid of a sudo prompt which agents can get hung up on...
I ripped async out of Plain very early on. I'm finally starting to consider how to bring it back...
How about, purely for SSE and websockets?
The plain server is completely vertically integrated now, and you can opt-in to async python *just* for these specific kinds of views 🤔