What a Django Model Represents
A Django model is a Python class that describes a database table: its columns (fields), constraints, and relationships to other tables. Django’s ORM (Object-Relational Mapper) lets you create, read, update, and delete rows using Python objects instead of writing SQL directly.
Good model design matters because it affects data integrity, query performance, and how easy your backend is to evolve. The goal is to represent real entities (users, profiles, products, orders) and the relationships between them in a way that is explicit and maintainable.
Designing Entities and Relationships
Choosing the Right Relationship Type
- OneToOneField: use when each row in A corresponds to exactly one row in B (e.g., a user has exactly one profile).
- ForeignKey (many-to-one): use when many rows in A belong to one row in B (e.g., many posts belong to one author).
- ManyToManyField: use when many rows in A relate to many rows in B (e.g., posts can have many tags, tags can be used by many posts).
Example Domain: A Simple Publishing Backend
We’ll model: Author, AuthorProfile, Post, Tag, and Comment. This covers all three relationship types and common field/constraint patterns.
Step-by-Step: Creating Models
1) Define the Models
Create or edit models.py in your app (e.g., blog/models.py).
from django.conf import settings
from django.db import models
from django.db.models import Q
from django.utils import timezone
class Author(models.Model):
# If you have authentication enabled, you can link to the user model.
# This is optional; you could also store name/email directly.
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="author",
)
pen_name = models.CharField(max_length=80, unique=True)
def __str__(self):
return self.pen_name
class AuthorProfile(models.Model):
author = models.OneToOneField(
Author,
on_delete=models.CASCADE,
related_name="profile",
)
bio = models.TextField(blank=True)
website = models.URLField(blank=True)
def __str__(self):
return f"Profile for {self.author.pen_name}"
class Tag(models.Model):
name = models.CharField(max_length=40, unique=True)
slug = models.SlugField(max_length=50, unique=True)
def __str__(self):
return self.name
class Post(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
PUBLISHED = "published", "Published"
ARCHIVED = "archived", "Archived"
author = models.ForeignKey(
Author,
on_delete=models.PROTECT,
related_name="posts",
)
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220)
body = models.TextField()
status = models.CharField(
max_length=12,
choices=Status.choices,
default=Status.DRAFT,
)
published_at = models.DateTimeField(null=True, blank=True)
tags = models.ManyToManyField(
Tag,
related_name="posts",
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
# Ensure slug is unique per author (allows different authors to reuse the same slug)
models.UniqueConstraint(fields=["author", "slug"], name="uniq_post_slug_per_author"),
# Example of a conditional constraint: published posts must have published_at
models.CheckConstraint(
check=Q(status__in=["draft", "archived"]) | Q(published_at__isnull=False),
name="published_posts_require_published_at",
),
]
indexes = [
models.Index(fields=["status", "published_at"]),
]
ordering = ["-created_at"]
def __str__(self):
return self.title
def publish(self):
self.status = self.Status.PUBLISHED
self.published_at = timezone.now()
@property
def is_published(self):
return self.status == self.Status.PUBLISHED
class Comment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name="comments",
)
name = models.CharField(max_length=80)
email = models.EmailField()
body = models.TextField()
is_public = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Comment by {self.name} on {self.post_id}"2) Create and Apply Migrations
After defining models, generate and apply migrations.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
python manage.py makemigrations
python manage.py migrateMigrations are Django’s way of versioning your schema. Keep them in version control; avoid editing applied migrations unless you know exactly what you’re doing.
Fields: null, blank, default, and Common Choices
null vs blank
null=Trueaffects the database: the column can store SQL NULL.blank=Trueaffects validation/forms: the field can be empty in user input.
Typical guidance:
- For text fields (
CharField,TextField), preferblank=Trueand keepnull=False(store empty string rather than NULL) unless you have a strong reason. - For dates/times where “unknown” is meaningful, use
null=True, blank=True(e.g.,published_at).
Defaults
default=... sets a value when none is provided. Use callable defaults for dynamic values (e.g., timezone.now) and constants for fixed defaults (e.g., status).
from django.utils import timezone
published_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(default=timezone.now) # callable defaultChoices with TextChoices
Use TextChoices to keep status-like fields consistent and readable.
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
PUBLISHED = "published", "Published"Constraints, Indexes, and Data Integrity
Uniqueness
Use unique=True for single-field uniqueness (e.g., tag slug). Use UniqueConstraint for multi-field uniqueness (e.g., post slug per author).
Check Constraints
CheckConstraint enforces rules at the database level. This is valuable because it protects data integrity even if data is inserted outside Django.
In the example, we enforce: if a post is published, it must have published_at.
Indexes
Add indexes for fields used frequently in filters/orderings (e.g., status, published_at). Indexes speed up reads but add overhead to writes, so add them intentionally.
Model Methods and __str__
__str__
__str__ should return a human-friendly identifier. It improves readability in the admin, shell, logs, and debugging.
Domain Methods
Put business actions on the model when they are core to the entity. For example, Post.publish() sets status and timestamp together, reducing the chance of inconsistent updates.
post.publish()
post.save(update_fields=["status", "published_at"]) # optional optimizationComputed Properties
Use @property for simple derived values (e.g., is_published). For values that need database queries, prefer queryset annotations to avoid hidden N+1 query patterns.
Working with Relationships in Practice
One-to-One Access
With Author and AuthorProfile:
author = Author.objects.get(pen_name="Ada")
# Forward access (AuthorProfile via related_name)
profile = author.profile
# Reverse access (Author from profile)
author2 = profile.authorIf a profile might not exist, handle AuthorProfile.DoesNotExist:
try:
bio = author.profile.bio
except AuthorProfile.DoesNotExist:
bio = ""ForeignKey Access
From a post to its author:
post = Post.objects.get(id=1)
post.author.pen_nameFrom an author to their posts (reverse relation via related_name="posts"):
author = Author.objects.get(pen_name="Ada")
qs = author.posts.all()Many-to-Many Access
Add and remove tags:
post = Post.objects.get(id=1)
tag = Tag.objects.get(slug="django")
post.tags.add(tag)
post.tags.remove(tag)
post.tags.set([tag]) # replace all
post.tags.clear()Query posts by tag:
Post.objects.filter(tags__slug="django")Typical ORM Queries (Filtering, Ordering, Related Objects)
Basic Retrieval
# All posts
Post.objects.all()
# Single object (raises DoesNotExist if missing)
Post.objects.get(author__pen_name="Ada", slug="my-first-post")
# First match (returns None if missing)
Post.objects.filter(status="published").first()Filtering with Lookups
# Case-insensitive contains
Post.objects.filter(title__icontains="django")
# Date comparisons
Post.objects.filter(published_at__gte=timezone.now() - timezone.timedelta(days=7))
# In list
Post.objects.filter(status__in=[Post.Status.DRAFT, Post.Status.PUBLISHED])
# Excluding
Post.objects.exclude(status=Post.Status.ARCHIVED)Combining Conditions with Q Objects
from django.db.models import Q
Post.objects.filter(
Q(status=Post.Status.PUBLISHED) & (Q(title__icontains="orm") | Q(body__icontains="orm"))
)Ordering and Slicing
# Newest first (also set by Meta.ordering)
Post.objects.order_by("-published_at")
# Top 10
Post.objects.filter(status=Post.Status.PUBLISHED).order_by("-published_at")[:10]Efficient Related Loading: select_related and prefetch_related
Use these to avoid N+1 queries:
select_relatedfor single-valued relationships (ForeignKey/OneToOne).prefetch_relatedfor multi-valued relationships (ManyToMany/reverse ForeignKey).
# Fetch posts and their authors in one query
posts = Post.objects.select_related("author", "author__user").all()
# Fetch posts, tags, and comments efficiently
posts = Post.objects.select_related("author").prefetch_related("tags", "comments")Related-Object Filtering
# Posts by authors with a website in their profile
Post.objects.filter(author__profile__website__isnull=False).exclude(author__profile__website="")
# Public comments for published posts
Comment.objects.filter(post__status=Post.Status.PUBLISHED, is_public=True)Basic Aggregations and Annotations
Counting and Aggregating
from django.db.models import Count, Avg
# Total published posts
Post.objects.filter(status=Post.Status.PUBLISHED).count()
# Number of comments per post
Post.objects.annotate(comment_count=Count("comments")).order_by("-comment_count")
# Tags ordered by usage
Tag.objects.annotate(post_count=Count("posts")).order_by("-post_count")Filtering on Annotations
# Posts with at least 5 public comments
Post.objects.annotate(
public_comment_count=Count("comments", filter=Q(comments__is_public=True))
).filter(public_comment_count__gte=5)Naming Conventions and Maintainability Guidelines
Model and Field Naming
- Use singular model names:
Post,Comment, notPosts. - Use clear relationship names:
author,post,tags. - Set
related_nameexplicitly for relationships to avoid clashes and to make reverse access readable (e.g.,author.posts,post.comments). - Prefer stable identifiers: use
slugfor URLs, but enforce uniqueness with constraints appropriate to your domain.
Choosing on_delete Carefully
CASCADE: deleting parent deletes children (good for comments when a post is deleted).PROTECT: prevents deletion if children exist (good for authors referenced by posts).SET_NULL: keeps child but nulls reference (requiresnull=True).
A common pitfall is using CASCADE everywhere and accidentally deleting large parts of your data. Pick behavior that matches business rules.
Avoiding Common Modeling Pitfalls
- Overusing nullable fields: excessive
null=Truecreates ambiguity (NULL vs empty). Be intentional. - Missing constraints: enforce uniqueness and invariants at the database level (e.g., multi-field uniqueness, required timestamps for published content).
- Implicit reverse names: relying on default
modelname_setmakes code harder to read and can break when you add another relationship to the same model. - Unbounded strings: always set
max_lengthforCharField; choose sizes that reflect real limits. - Hidden N+1 queries: iterating over objects and accessing related fields without
select_related/prefetch_relatedcan cause performance issues. - Premature denormalization: don’t duplicate data across tables unless you have a measured performance need and a plan to keep it consistent.
- ManyToMany with extra data: if the relationship needs attributes (e.g., “tagged_at”, “added_by”), use a through model instead of a plain
ManyToManyField.
Many-to-Many with Extra Fields (Through Model)
If you need metadata on a many-to-many relationship, model it explicitly.
class PostTag(models.Model):
post = models.ForeignKey("Post", on_delete=models.CASCADE)
tag = models.ForeignKey("Tag", on_delete=models.CASCADE)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["post", "tag"], name="uniq_post_tag")
]
class Post(models.Model):
# ... other fields ...
tags = models.ManyToManyField("Tag", through="PostTag", related_name="posts", blank=True)Practical Workflow Tips for Evolving Schemas
Changing Fields Safely
- When adding a non-nullable field to a table with existing rows, provide a
defaultor add it as nullable first, backfill data, then make it non-nullable in a later migration. - Use
UniqueConstraintandCheckConstraintto encode rules that should never be violated.
Keeping Queries Readable
As queries grow, prefer building them step-by-step and using meaningful variable names.
published = Post.objects.filter(status=Post.Status.PUBLISHED)
recent = published.filter(published_at__gte=timezone.now() - timezone.timedelta(days=30))
recent = recent.select_related("author").prefetch_related("tags")
recent = recent.order_by("-published_at")