Free Ebook cover Django Fundamentals: From First App to a Complete Backend

Django Fundamentals: From First App to a Complete Backend

New course

12 pages

Django Models and Database Design with the ORM

Capítulo 3

Estimated reading time: 10 minutes

+ Exercise

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 App

Download the app

python manage.py makemigrations
python manage.py migrate

Migrations 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=True affects the database: the column can store SQL NULL.
  • blank=True affects validation/forms: the field can be empty in user input.

Typical guidance:

  • For text fields (CharField, TextField), prefer blank=True and keep null=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 default

Choices 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 optimization

Computed 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.author

If 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_name

From 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_related for single-valued relationships (ForeignKey/OneToOne).
  • prefetch_related for 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, not Posts.
  • Use clear relationship names: author, post, tags.
  • Set related_name explicitly for relationships to avoid clashes and to make reverse access readable (e.g., author.posts, post.comments).
  • Prefer stable identifiers: use slug for 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 (requires null=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=True creates 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_set makes code harder to read and can break when you add another relationship to the same model.
  • Unbounded strings: always set max_length for CharField; choose sizes that reflect real limits.
  • Hidden N+1 queries: iterating over objects and accessing related fields without select_related/prefetch_related can 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 default or add it as nullable first, backfill data, then make it non-nullable in a later migration.
  • Use UniqueConstraint and CheckConstraint to 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")

Now answer the exercise about the content:

You notice a performance issue where iterating through a list of posts and accessing each post’s author causes many extra database queries. Which ORM approach best addresses this for loading the author data efficiently?

You are right! Congratulations, now go to the next page

You missed! Try again.

select_related is designed for single-valued relations (ForeignKey/OneToOne) and performs a join so related objects (like author) are loaded efficiently, preventing N+1 queries.

Next chapter

Migrations and Schema Evolution in Django

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.