plain.models
Model your data and store it in a database.
- Overview
- Database connection
- Querying
- Migrations
- Fields
- Reverse relationships
- Typing
- Validation
- Indexes and constraints
- Custom QuerySets
- Forms
- Sharing fields across models
- Installation
Overview
# app/users/models.py
from datetime import datetime
from plain import models
from plain.models import types
from plain.passwords.models import PasswordField
@models.register_model
class User(models.Model):
email: str = types.EmailField()
password = PasswordField()
is_admin: bool = types.BooleanField(default=False)
created_at: datetime = types.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return self.email
Every model automatically includes an id field which serves as the primary
key. The name id is reserved and can't be used for other fields.
Create, update, and delete instances of your models:
from .models import User
# Create a new user
user = User.query.create(
email="[email protected]",
password="password",
)
# Update a user
user.email = "[email protected]"
user.save()
# Delete a user
user.delete()
# Query for users
admin_users = User.query.filter(is_admin=True)
Database connection
To connect to a database, you can provide a DATABASE_URL environment variable:
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
Or you can manually define the DATABASE setting:
# app/settings.py
DATABASE = {
"ENGINE": "plain.models.backends.postgresql",
"NAME": "dbname",
"USER": "user",
"PASSWORD": "password",
"HOST": "localhost",
"PORT": "5432",
}
Multiple backends are supported, including Postgres, MySQL, and SQLite.
Querying
Models come with a powerful query API through their QuerySet interface:
# Get all users
all_users = User.query.all()
# Filter users
admin_users = User.query.filter(is_admin=True)
recent_users = User.query.filter(created_at__gte=datetime.now() - timedelta(days=7))
# Get a single user
user = User.query.get(email="[email protected]")
# Complex queries with Q objects
from plain.models import Q
users = User.query.filter(
Q(is_admin=True) | Q(email__endswith="@example.com")
)
# Ordering
users = User.query.order_by("-created_at")
# Limiting results
first_10_users = User.query.all()[:10]
For more advanced querying options, see the QuerySet class.
Migrations
Migrations track changes to your models and update the database schema accordingly:
# Create migrations for model changes
plain makemigrations
# Apply migrations to the database
plain migrate
# See migration status
plain migrations list
Migrations are Python files that describe database schema changes. They're stored in your app's migrations/ directory.
Fields
Plain provides many field types for different data:
from plain import models
class Product(models.Model):
# Text fields
name = models.CharField(max_length=200)
description = models.TextField()
# Numeric fields
price = models.DecimalField(max_digits=10, decimal_places=2)
quantity = models.IntegerField(default=0)
# Boolean fields
is_active = models.BooleanField(default=True)
# Date and time fields
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Relationships
category = models.ForeignKeyField("Category", on_delete=models.CASCADE)
tags = models.ManyToManyField("Tag")
Common field types include:
CharFieldTextFieldIntegerFieldDecimalFieldBooleanFieldDateTimeFieldEmailFieldURLFieldUUIDField
Reverse relationships
When you define a ForeignKey or ManyToManyField, Plain automatically creates a reverse accessor on the related model (like author.book_set). You can explicitly declare these reverse relationships using ReverseForeignKey and ReverseManyToMany:
from plain import models
@models.register_model
class Author(models.Model):
name = models.CharField(max_length=200)
# Explicit reverse accessor for all books by this author
books = models.ReverseForeignKey(to="Book", field="author")
@models.register_model
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKeyField(Author, on_delete=models.CASCADE)
# Usage
author = Author.query.get(name="Jane Doe")
for book in author.books.all():
print(book.title)
# Add a new book
author.books.create(title="New Book")
For many-to-many relationships:
@models.register_model
class Feature(models.Model):
name = models.CharField(max_length=100)
# Explicit reverse accessor for all cars with this feature
cars = models.ReverseManyToMany(to="Car", field="features")
@models.register_model
class Car(models.Model):
model = models.CharField(max_length=100)
features = models.ManyToManyField(Feature)
# Usage
feature = Feature.query.get(name="Sunroof")
for car in feature.cars.all():
print(car.model)
Why use explicit reverse relations?
- Self-documenting: The reverse accessor is visible in the model definition
- Better IDE support: Autocomplete works for reverse accessors
- Type safety: When combined with type annotations, type checkers understand the relationship
- Control: You choose the accessor name instead of relying on automatic
_setnaming
Reverse relations are optional - if you don't declare them, the automatic {model}_set accessor still works. You can also use both approaches in the same codebase.
Typing
For better IDE support and type checking, use plain.models.types with type annotations:
from plain import models
from plain.models import types
@models.register_model
class User(models.Model):
email: str = types.EmailField()
username: str = types.CharField(max_length=150)
is_admin: bool = types.BooleanField(default=False)
For nullable fields, add | None to the annotation:
published_at: datetime | None = types.DateTimeField(allow_null=True, required=False)
Foreign keys are typed with the related model:
author: Author = types.ForeignKeyField(Author, on_delete=models.CASCADE)
All field types from the Fields section are available through types. Typed and untyped fields can be mixed in the same model. The database behavior is identical - typed fields only add type checking.
Reverse relationships can also be typed - see the Reverse relationships section for details.
Typing QuerySets
For better type checking of query results, you can explicitly type the query attribute:
from __future__ import annotations
from plain import models
from plain.models import types
@models.register_model
class User(models.Model):
email: str = types.EmailField()
is_admin: bool = types.BooleanField(default=False)
query: models.QuerySet[User] = models.QuerySet()
With this annotation, type checkers will know that User.query.get() returns a User instance and User.query.filter() returns QuerySet[User]. This is optional - the query attribute works without the annotation, but adding it improves IDE autocomplete and type checking.
Validation
Models can be validated before saving:
class User(models.Model):
email = models.EmailField(unique=True)
age = models.IntegerField()
def clean(self):
if self.age < 18:
raise ValidationError("User must be 18 or older")
def save(self, *args, **kwargs):
self.full_clean() # Runs validation
super().save(*args, **kwargs)
Field-level validation happens automatically based on field types and constraints.
Indexes and constraints
Optimize queries and ensure data integrity with indexes and constraints:
class User(models.Model):
email = models.EmailField()
username = models.CharField(max_length=150)
age = models.IntegerField()
model_options = models.Options(
indexes=[
models.Index(fields=["email"]),
models.Index(fields=["-created_at"], name="user_created_idx"),
],
constraints=[
models.UniqueConstraint(fields=["email", "username"], name="unique_user"),
models.CheckConstraint(check=models.Q(age__gte=0), name="age_positive"),
],
)
Custom QuerySets
With the Manager functionality now merged into QuerySet, you can customize QuerySet classes to provide specialized query methods.
Define a custom QuerySet and assign it to your model's query attribute:
from typing import Self
class PublishedQuerySet(models.QuerySet["Article"]):
def published_only(self) -> Self:
return self.filter(status="published")
def draft_only(self) -> Self:
return self.filter(status="draft")
@models.register_model
class Article(models.Model):
title = models.CharField(max_length=200)
status = models.CharField(max_length=20)
query = PublishedQuerySet()
# Usage - all methods available on Article.query
all_articles = Article.query.all()
published_articles = Article.query.published_only()
draft_articles = Article.query.draft_only()
Custom methods can be chained with built-in QuerySet methods:
# Chaining works naturally
recent_published = Article.query.published_only().order_by("-created_at")[:10]
Programmatic QuerySet usage
For internal code that needs to create QuerySet instances programmatically, use from_model():
class SpecialQuerySet(models.QuerySet["Article"]):
def special_filter(self) -> Self:
return self.filter(special=True)
# Create and use the QuerySet programmatically
special_qs = SpecialQuerySet.from_model(Article)
special_articles = special_qs.special_filter()
Forms
Models integrate with Plain's form system:
from plain import forms
from .models import User
class UserForm(forms.ModelForm):
class Meta:
model = User
fields = ["email", "is_admin"]
# Usage
form = UserForm(request=request)
if form.is_valid():
user = form.save()
Sharing fields across models
To share common fields across multiple models, use Python classes as mixins. The final, registered model must inherit directly from models.Model and the mixins should not.
from plain import models
# Regular Python class for shared fields
class TimestampedMixin:
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Models inherit from the mixin AND models.Model
@models.register_model
class User(TimestampedMixin, models.Model):
email = models.EmailField()
password = PasswordField()
is_admin = models.BooleanField(default=False)
@models.register_model
class Note(TimestampedMixin, models.Model):
content = models.TextField(max_length=1024)
liked = models.BooleanField(default=False)
Installation
Install the plain.models package from PyPI:
uv add plain.models
Then add to your INSTALLED_PACKAGES:
# app/settings.py
INSTALLED_PACKAGES = [
...
"plain.models",
]