Plain is headed towards 1.0! Subscribe for development updates →

Changes

The gist of what's been removed or changed from Django.

Multiple packages

Plain is split into multiple, official packages.

With plain itself, you can write views to handle requests and responses, render templates, and process forms.

Other optional features like plain.models are available as independent packages.

Database optional

As mentioned above, plain.models and database integration is optional. This means you can start projects without a database (more like an alternative to Flask) and add the database later.

Removal of "old" code

A lot of django.contrib has been removed (gis, sites, sitemaps, etc.). Most of those concepts could be added back via independent packages.

Leveraging Python packages

Plain replaces some Django features with extremely common Python packages:

  • Jinja for templates (exclusively — no Django Template Language)
  • Click for CLIs (available as plain)
  • Pytest for testing (based on pytest-django)
  • Gunicorn (instead of development "runserver")

Jinja for templates

Jinja is the exclusive template language in Plain. In part this is because Jinja is so prevalent in the Python ecosystem. But it's also a personal preference of mine at this point — I've found in recent years that I was spending way too time and energy working around the simplicity of the Django template language. The lack of function(param1, param2) syntax in Django templates meant that I'd create all kinds of tags, filters, and 0-parameter methods as solutions that started to feel like workarounds.

Rewritten views

Views are entirely class-based, and rewritten with the hopes of being simpler.

Views can also return simple types (str -> text, dict -> JSON, etc.) which are converted to HTTP responses.

from plain.views import View


class ExampleView(View):
    def get(self):
        return "Hello, world!"  # A plain text response

.env integration

Plain can automatically load environment variables from a .env file from the top of your repo.

To specify a different .env file, use PLAIN_ENV. For example, PLAIN_ENV=dev will load .env.dev.

Removed form rendering

Plain is not responsible for form rendering — you are! This can mean a little more work on the HTML side, but also emphasizes more control over the frontend appearance and behavior of forms.

<form method="post">
    {{ csrf_input }}

    <!-- Render general form errors -->
    {% for error in form.non_field_errors %}
    <div>{{ error }}</div>
    {% endfor %}

    <!-- Render form fields individually (or with Jinja helps or other concepts) -->
    <label for="{{ form.email.html_id }}">Email</label>
    <input
        type="email"
        name="{{ form.email.html_name }}"
        id="{{ form.email.html_id }}"
        value="{{ form.email.value() or '' }}"
        autocomplete="email"
        autofocus
        required>
    {% if form.email.errors %}
    <div>{{ form.email.errors|join(', ') }}</div>
    {% endif %}

    <button type="submit">Save</button>
</form>

To re-use form rendering across your app, you can make use of Jinja feautres like macros and includes, plain-elements, or anything else you can come up with!

Apps -> Packages

"Apps" have been renamed to "packages". Plain considers your entire project to be an "app".

Settings changes

Any package in INSTALLED_PACKAGES can define their own default_settings.py, which will be loaded automatically.

Settings can also use type annotations, which will be checked at runtime.

# github/default_settings.py
GITHUB_API_BASE_URL: str = "https://api.github.com"
GITHUB_TOKEN: str  # A setting is required if it has a type annotation, but no default value

Project file structure

A Plain project has a more opinionated file structure.

  • The app directory for your code.
  • Settings at app/settings.py.
  • No manage.py, wsgi.py, or asgi.py.

Combined with a lot of other changes in Plain, a simple web server can look more like this:

.
├── app
│   ├── settings.py
│   ├── templates
│   │   ├── 404.html
│   │   ├── base.html
│   │   └── example.html
│   └── urls.py
├── uv.lock
└── pyproject.toml

2 directories, 7 files

Async removed

Async and ASGI have been removed, for now.

i18n removed

Internationalization has been removed, for now.

No base auth.User

The plain-auth package doesn't come with a base User model anymore. This gives you complete control over the fields on your User model, and also requires you to create one.

Here's an example from the plain-starter-app:

from plain.models import models
from plain.passwords.models import PasswordField


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

    def __str__(self):
        return self.email

No media, STORAGES, or FileField

I expect some version of these things to come back, but for now they've been removed.

In practice I've found myself skipping these in favor of uploading to S3 (or similar) directly, sometimes in the frontend via a signed-url, and then storing the URL in the database and managing the files myself...

Other notable changes

  • Requests have a unique_id.
  • Removed SCRIPT_NAME and script_prefix.
  • Removed fixtures.
  • Static files are served automatically (Whitenoise is integrated).