Model or dataset
dropseed/plain avatar
dropseed/plain

Plain, a Django fork rebuilt around typed models and htmx

The Python web framework for building apps.

1,042 stars28 forksPythonLicense varies

At a glance

What is it?
Plain is a Python web framework that started as a fork of Django in 2023 and was reshaped by real use at PullApprove. It keeps the Django lineage but swaps much of the surface: Postgres-only models declared as annotated attributes, Jinja templates, a Router instead of URL patterns, htmx on the front end, and a toolchain built around uv, ruff and ty with agent-facing documentation in the box.
Who is it for?
Plain fits a team that wants Django's shape with a typed, Postgres-only core and an htmx front end, and that is willing to be early: Python 3.14 or newer is required, and the framework is one fork into a large body of its own packages. It does not fit a project that needs SQLite, MySQL or another database, and it does not fit anyone who needs a documented licence, because no licence is recorded in the repository metadata and the root of the tree shows no LICENSE file.
Can I use it commercially?
Not without permission. GitHub finds no licence file in the repository, and without a licence all rights are reserved by default: you may read the code but not reuse it. Check the README, or ask the authors, before using it.
Is it still maintained?
Yes. The repository last received commits 3 days ago.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on November 7, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • dropseed/plain1,042 ★ · -2
Star history
Repository2026-10-212026-11-07
dropseed/plain1k1k

-2 Stars in the last 17 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

A fork of Django, driven by one company's usage

Plain describes itself as the Python web framework for building apps, and then adds the two sentences that explain its existence: it was originally a fork of Django, and it was reshaped over years of real use. The About section puts a date on it, saying the fork was started in the stone age of 2023 and driven by real use at PullApprove.

That origin is the most useful thing about the project for an evaluating engineer, because it says where the conventions come from. Django's ideas about models, class-based views, migrations and an admin are still the skeleton, and the familiarity is a real asset for anyone who has shipped Django. The changes are the interesting part, and they are concentrated: the ORM is Postgres-only, the templates are Jinja rather than the Django template language, the URL configuration is a Router class rather than a list of patterns, and the front end is htmx with Tailwind rather than a JavaScript bundler and a component library.

The stack is stated as opinionated, and the list is short enough to hold in your head: Python 3.14 or newer, Postgres for the database, Jinja2 for templates, htmx and Tailwind CSS for the front end, uv for packages, ruff for linting and formatting, ty for type checking and oxc for the JavaScript side. The claim in the README is that what is good for humans is good for AI, and the framework is presented as ready for the era of agents.

Models as annotated attributes, with the type carried forward

The model layer is where the fork departs from Django most visibly, and the example in the README shows the whole shape of a model:

python
@postgres.register_model
class User(postgres.Model):
    email: Field[str] = types.EmailField()
    password: Field[str] = PasswordField()
    display_name: Field[str] = types.TextField(max_length=100)
    is_admin: Field[bool] = types.BooleanField(default=False)
    created_at: Field[datetime] = types.DateTimeField(create_now=True)

    model_options = postgres.Options(
        constraints=[
            postgres.UniqueConstraint(fields=["email"], name="unique_email"),
        ],
    )

The fields are annotated attributes, with the Python type written in the annotation and the database type in the assigned value. Field[str] with a TextField, Field[bool] with a BooleanField, Field[datetime] with a DateTimeField. The result is that the column type and the Python type are visible in the same line, and a type checker can reason about the model as a typed object rather than as a bag of runtime descriptors.

Constraints are declared through a model_options attribute rather than through a separate migrations module in the example. The UniqueConstraint on the email field is spelled out with the field list and a name.

The README makes the claim that matters for how you work: models declare fields as annotated attributes, and that typing carries through views, forms and URLs. If that holds, then a view that reads a model attribute is typed all the way to the template, and a mistake in a field name is a type error rather than a runtime surprise. The enforcement mechanism is described in the same paragraph, with plain check running ty on every pass, so what your editor shows, what CI enforces and what an agent reads all come from the same signatures.

A Router for URLs, class-based views, Jinja templates

URLs are configured through a Router class, and the example is short enough to see the idea:

python
class UsersRouter(Router):
    namespace = "users"
    urls = (path("<int:pk>/", views.UserDetail),)

The route parameters are typed in the path itself, with the int converter written inline, and the router carries a namespace. This is a different shape from a flat list of URL patterns, and it reads more like a set of per-app objects than a central registry.

Views are class-based, and they reach into the request through small hooks:

python
class UserDetail(DetailView):
    template_name = "users/detail.html"

    def get_object(self):
        return User.query.get(pk=self.url_kwargs["pk"])

The get_object override is where the typed model pays off. The query is expressed against the model, the keyword argument comes from the url_kwargs, and the return type is the model instance rather than a dictionary. That is the connection the README points at when it says the typing carries through views, forms and URLs.

Templates are Jinja, which is a deliberate break from Django's own template language:

html
{% extends "base.html" %}

{% block content %}
<h1>{{ user.display_name }}</h1>
<p>Joined {{ user.created_at.strftime("%B %Y") }}</p>
{% endblock %}

The template receives a typed user object and calls a method on it, which is why the annotation matters downstream. A Django developer can read all of this comfortably; a Django developer who wants to reuse existing templates will find the template syntax has to change.

Observability in the box, before the N+1 shows up

The observability section is short, and it is one of the stronger arguments in the README. OpenTelemetry traces and slow-query detection ship in the box, and the framing is: the first time an N+1 matters, you already have the tools to see it.

That is a different pitch from the usual optional-integration story. Instead of adding instrumentation after an incident, the tracing is there from the first deploy, which means the baseline exists before there is anything to compare it against. The slow-query detection is the part that pays off soonest, because the most common performance bug in an ORM application is the query that runs once per row inside a loop.

The rest of the package list supports the story rather than repeating it. plain.connect exports OTLP data to Plain Cloud. plain.toolbar is a debug toolbar, the standard local tool for inspecting what a request actually did. plain.scan is a security scanning package, and plain.flags provides feature flags, so the operational surface is assembled from first-party parts rather than from a mixture of vendors.

None of this is exotic, and that is the point. The design decision is that tracing and query detection are defaults rather than a package you add later, and the README frames that choice in terms of the moment you need them.

Rules, on-demand docs and slash-command skills for agents

The agent story is the most distinctive part of the project, and it comes in three pieces.

Rules are always-on guardrails stored in project rules files, with the example given as a .claude/rules/ directory for Claude Code. The README describes them as short files of roughly 50 lines that prevent the most common mistakes. The repository has a .claude/ directory at the root, so this is not an aspiration.

Docs are the full framework documentation, accessible on demand from the command line:

bash
plain docs models
plain docs models --api
plain docs models --search "queryset"
plain docs --search "queryset"

The subcommands are the interesting part. A plain docs call gives the full documentation for a package, --api narrows it to typed signatures only, and --search filters by term within a package or across all of them. The claim that follows is that predictable APIs, typed signatures and on-demand docs happen to be what both people and coding agents need, and the same typed signatures that your editor shows are what the agent reads.

Skills are end-to-end workflows triggered by slash commands. There are four: /plain-install adds a new package and walks through setup, /plain-upgrade bumps versions, reads changelogs, applies breaking changes and runs checks, /plain-optimize captures performance traces, identifies slow queries and N+1 problems and applies fixes, and /plain-bug collects context and submits a bug report as a GitHub issue.

The design bet is that the same artefacts serve both audiences. A person reading plain docs models --api and an agent reading the same signatures get the same information, and the difference is that one of them also has the shell.

Thirty-one packages in one repository

The repository is a monorepo of first-party packages, and the README frames the collection as 31 packages, one framework, all with built-in docs, with the claim that decisions that usually take a sprint are already made.

The list is organised by role. The foundation is plain, plain.postgres, plain.auth and plain.sessions. The backend group adds plain.api, plain.mcp for an MCP server for AI clients, plain.jobs for background jobs, plain.email, plain.cache and plain.vendor for vendored dependencies. The frontend group is plain.htmx, plain.tailwind, plain.elements for HTML components and plain.pages for static pages. Development covers plain.dev for the local server, plain.testing for the test client and runner, plain.toolbar, plain.code for formatting, plain.portal for a remote shell and file transfer, plain.tunnel for dev tunnelling and plain.start for the project starter. Production adds plain.admin, plain.connect, plain.flags and plain.scan. The user-facing group is plain.passwords, plain.oauth and plain.loginlink for magic links.

The root listing matches, with a directory per package plus a plainx-dev directory, a scripts/ directory, a tools/ directory, an example/ directory, a uv.lock and a pyproject.toml. There is also a CLAUDE.md, a CODEREVIEW.toml, CONTRIBUTING.md and SECURITY.md at the root.

The workspace manifest is where the project's engineering standards become visible. The root package is not itself a package, with package set to false in the uv configuration so that a sync does not install it through the legacy setuptools path. Every first-party package is listed in the dev dependency group as a workspace source, and the comment on the type checker version explains the policy: the exact version of ty is pinned in uv.lock because every release so far has moved which diagnostics fire on the typed model surface, so a bump is a deliberate change that has to pass the typing tests rather than a silent lockfile update. That is a small comment, and it is a good description of how the project treats its own type surface.

Where Plain is the wrong tool

The first limit is the database. Models are Postgres-only, and plain.postgres is the ORM, so a project that needs SQLite for a local file, or MySQL, or an existing database with a driver you already trust, is out of scope. That is a defensible choice for a framework that wants to use Postgres features properly, and it is a real constraint for anyone whose hosting is fixed.

The second is the Python version. Requiring 3.14 or newer means the framework tracks the newest Python, which keeps the typing story sharp and modern, and it also means some third-party packages may not have wheels for it yet. It is the price of the stated approach.

The third is the migration cost from Django, which is larger than a fork usually suggests. The models, the URLs, the templates and the front end have all changed shape, so the familiarity that makes Plain appealing to a Django developer is familiarity with the ideas rather than with the code. Existing Django templates do not port, existing URL configuration does not port, and the ORM query syntax is not the one you already know.

The fourth is the licensing position. No licence is recorded in the repository metadata, and the root of the tree shows no LICENSE file, which is not a formality for a company that plans to depend on it. It is a question to ask the maintainers rather than to infer.

The fifth is the size of the surface. Thirty-one packages is a lot of documentation to read before you know the framework, and the fact that the agent skills exist, including /plain-upgrade for applying breaking changes, is a hint that the surface moves.

Editorial conclusion

Plain fits a team that wants Django's shape with a typed, Postgres-only core and an htmx front end, and that is willing to be early: Python 3.14 or newer is required, and the framework is one fork into a large body of its own packages. It does not fit a project that needs SQLite, MySQL or another database, and it does not fit anyone who needs a documented licence, because no licence is recorded in the repository metadata and the root of the tree shows no LICENSE file. Before adopting it, read plain.postgres and plain.views to see how far the fork has moved from Django, run the type checker on your own models to see whether the annotated-field style fits your team, and plan for a framework that ships 31 packages rather than one. The last push was on 2026-10-12 and there are no tagged releases.

Frequently asked questions

What is Plain, the Python web framework?

It is a Python web framework that started as a fork of Django in 2023 and was reshaped over years of real use at PullApprove. The stack is opinionated: Python 3.14 or newer, Postgres, Jinja2 templates, htmx and Tailwind on the front end, uv for packages, ruff for linting and ty for type checking.

Which databases does Plain support?

Postgres only. The model layer is plain.postgres, models are registered with a postgres.register_model decorator, and the README describes the stack as Python 3.14 or newer with Postgres for the database, with no other database listed.

How do I start a new Plain project?

With uv directly, using `uvx plain-start my-app`, or by starting with an agent, using a command that fetches https://plainframework.com/start.md and hands it to the agent. A full walkthrough is at https://plainframework.com/start/.

How do I read Plain's documentation from the command line?

With the plain docs command, which prints the full documentation for a package, such as plain docs models. The --api option narrows the output to typed signatures only, and --search filters by a term within a package or across all of them.

Is Plain suitable for a project that needs another database or a stable release?

The database is the hard limit, since models are Postgres-only. The repository also has no tagged releases, so there is no version to pin, and no licence is recorded in the repository metadata and the root shows no LICENSE file, so check both with the maintainers before depending on it.

Official sources

  1. dropseed/plain on GitHub
  2. Issues
  3. Project website
  4. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/dropseed-plain.svg)](https://hysenlabs.com/projects/dropseed-plain)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/dropseed-plain.svg)](https://hysenlabs.com/projects/dropseed-plain)

Was this useful?