Plain is headed towards 1.0! Subscribe for development updates →

Forwards-only migrations, new urls, and more

Development update by @davegaeddert • 2025-03-07

As we get closer to the 1.0 release, I've decided to bite off a few more of the larger, backwards-incompatible changes that I've been considering. Some of these may even be controversial 😱

There's no time like the present!

Removed backwards migrations

In my experience, backwards migrations can be helpful in development, but in production you would never use them.

This deserves an entire blog post (and I'd love to hear the counter arguments), but the migrations are code (committed in your repo!) and coordinating the rollback of migrations + code can be ridiculously complex in production. You can't simply git revert or heroku rollback because of the way everything is intertwined. Instead, you're probably going to deploy more code (and update/add migrations), and possibly restore database backups or manually fix data.

Development db backups

Ok, so backwards migrations are gone. What do we do in development if we need to undo something? What if we used backups instead?!

The plain migrate command will now prompt to make a local backup.

You can also manually create, list, and restore backups using the plain backups command.

Turns out, this is incredibly useful if you work on git branches that have new migrations. When you switch branches, just restore the backup for that branch!

We'll continue to polish the experience here, but I like the direction it's headed.

New url Router, updated url functions

There are a number of things that I consistenly find confusing with urls, url namespaces, the path() and include() functions, and even the params of the reverse function. Lots of small details. This is what it looks like now!

from plain.assets.urls import AssetsRouter
from plain.urls import Router, include, path

from . import views


# The primary entrypoint for your app
class AppRouter(Router):
    namespace = ""  # A required attribute!
    urls = [
        # Use include() by itself, not path(include())
        include("assets/", AssetsRouter),
        path("login/", views.LoginView, name="login"),
        path("", views.HomeView),
    ]


# The primary router is pointed to in settings.py
URLS_ROUTER = "app.urls.AppRouter"

# And reversing urls is now like this (simpler args/kwargs)
reverse("users:detail", pk=1)

I will admit that the Router looks more complex than simply defining a list named urlpatterns. But being more explicit is one of Plain's design goals. Doing it this way means:

  • urls.py is no longer a special module
  • urlpatterns is no longer a special module-level variable
  • app_name is no longer a special module-level variable (which is not a good name anyway IMO)
  • namespaces are front and center, and strongly encouraged (basically everything should have a namespace except the root router)

@register everything

In the name of explicitness and clarity being a good thing, I've decided to go all-in on the @register decorator pattern.

When you create a model, you'll now need to @register_model it.

from plain import models


@models.register_model
class User(models.Model):
    email = models.EmailField()

You'll see a similar pattern in worker jobs, package configs, Jinja extensions and filters, custom CLIs, admin views, and preflight checks.

Why do this? I learned Python by learning Django, over 12 years ago. There was so much "magic" involved that I didn't really understand what was Django and what was Python. My Model classes were gigantic, because I honestly didn't know that you could write classes and objects that weren't models! There's a lot to blame for this, including me and my ways of learning, but I think there is also a lot of value to saying "look, here's the line that makes that happen!" which is something you get by explicitly writing things like @register_model. This is a pattern that already existed for system checks and a couple other things, and it's one that I think works well.

Sidenote: this takes us one step closer to delivering a "single file application" experience, at least in principle. The models.py (and most filename conventions) don't matter much anymore, so you could start to envision the example app.py that defines a model, router, and a couple views which make up a small but useful app.

Removed swappable models

One of the first things I did with Plain was change how the User model works. In Django, the django.contrib.auth package comes with a User model, but how you go forward from there is the source of lots of debate.

My solution is simply to put the User model in the user's code! We still have an AUTH_USER_MODEL setting so we can refer to it across the framework (the concept of a user is a crucial thing), but otherwise it's a regular model like anything else.

# app/users/models.py
from plain import models
from plain.models.functions import Lower
from plain.passwords.models import PasswordField


@models.register_model
class User(models.Model):
    email = models.EmailField()
    password = PasswordField()
    is_admin = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                Lower("email"),
                name="unique_lower_email",
            ),
        ]

Anyway, Django has a concept of "swappable" models, and as far as I know it's really only used by for this user-modeling situation. "Swapping" a built-in User for your own is not how Plain does it, so I removed the swappable models feature entirely.

This kind of change is mostly internal, but I mention it because it is one of those key design decisions that has a surprising number of trickle-down effects. You'd be surprised how many places AUTH_USER_MODEL was special-cased in the migrations code.

Tailwind v4

Since Tailwind is a core element of Plain (any Plain package with templates can use Tailwind), it's important to note that we're now using Tailwind v4! I do have one workaround in place related to the .venv gitignore behavior, but I'm hoping that will get resolved soon.

The way this works under the hood is that your app has a tailwind.css, and we @source the Plain packages themselves.

/* tailwind.css */
@import 'tailwindcss';

/* Add Plain packages to the sources */
@import "./.plain/tailwind.css";

/* Form styling plugin (optional) */
@plugin "@tailwindcss/forms";
/* .plain/tailwind.css */
@source "../.venv/lib/python3.12/site-packages/plain/admin";
@source "../.venv/lib/python3.12/site-packages/plain/auth";
@source "../.venv/lib/python3.12/site-packages/plain/htmx";
@source "../.venv/lib/python3.12/site-packages/plain/sessions";
@source "../.venv/lib/python3.12/site-packages/plain/models";
@source "../.venv/lib/python3.12/site-packages/plain/elements";
@source "../.venv/lib/python3.12/site-packages/plain/tailwind";
@source "../.venv/lib/python3.12/site-packages/plain/passwords";

The end result is a single stylesheet across the application that covers both your code and the installed packages you're using.

Other notable changes

  • Removed a bunch of signals (not my favorite pattern...)
  • Fully separated the concept of models from the core plain.packages registry
  • Renamed the plain compile command to plain build
  • Vultured tons of dead code (mostly from plain.utils)
  • Removed support for "unmanaged" models (for now anyway)

Next steps

There are only a few more things on my list that have to change before 1.0, but the nature of working through them tends to reveal others. I really enjoy that process but I also know it could go on forever! I do think we're getting close though. Honestly the documentation is the thing I'm dreading the most — I think there's a lot of other cool stuff buried in Plain that you have no way to know about.

x.com/davegaeddert