Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • ForeignKey, on_delete and related_name
  • ManyToManyField and through models
  • OneToOneField and when it fits
  • Reverse lookups from Post to Comment

Lesson notes

1. One model is a lonely model

Your blog has exactly one table. Every post is an island: it has a title, a text, an author and a date, and nothing in the database knows that two posts are about the same subject or that a reader wrote a reply. Relations are how you tell the database "these rows belong together", and they are the single most valuable thing the ORM does for you.

Three field types cover almost everything:

FieldMeansExample in the blog
ForeignKey many rows point to one row many posts in one category
ManyToManyField many rows on both sides a post has many tags, a tag has many posts
OneToOneField at most one row on each side a user has one profile

The question to ask is always the same, and you ask it in both directions: "how many Y can one X have?" Post to Category: one. Category to Post: many. That is a ForeignKey on Post - the foreign key always lives on the "many" side.

2. The blog we are building

Category name, slug 1 : N Post title, slug, text category_id, author_id N : M Tag name, slug 1 : N Comment post_id, author, body User auth.User 1 : N 1 : 1 Profile bio, avatar_url

3. ForeignKey: on_delete and related_name

# blog/models.py
from django.conf import settings
from django.db import models


class Category(models.Model):
    name = models.CharField(max_length=60, unique=True)
    slug = models.SlugField(max_length=60, unique=True)

    class Meta:
        verbose_name_plural = "categories"
        ordering = ["name"]

    def __str__(self):
        return self.name


class Post(models.Model):
    title = models.CharField(max_length=200)
    text = models.TextField()
    published_date = models.DateTimeField(blank=True, null=True)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="posts",
    )
    category = models.ForeignKey(
        Category,
        on_delete=models.PROTECT,
        related_name="posts",
        null=True,
        blank=True,
    )

    def __str__(self):
        return self.title

on_delete is not optional and there is no sensible default, because only you know what "the category was deleted" should mean for its posts.

on_deleteWhat happens to the postUse it for
CASCADEdeleted with the parentdata that cannot exist alone: comments of a post
PROTECTthe delete is refuseddata you must not lose by accident: categories
SET_NULLcategory_id becomes NULLoptional links (null=True required)
SET_DEFAULTfalls back to the defaultan "Uncategorised" row that always exists
RESTRICTrefused, unless the same delete also removes the childa softer PROTECT

related_name is the name of the reverse accessor. Without it Django invents post_set; with related_name="posts" you write category.posts.all(), which reads like English and works in templates.

4. Comment: a ForeignKey you read backwards

class Comment(models.Model):
    post = models.ForeignKey(
        Post,
        on_delete=models.CASCADE,
        related_name="comments",
    )
    author_name = models.CharField(max_length=80)
    body = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    approved = models.BooleanField(default=False)

    class Meta:
        ordering = ["created_at"]

    def __str__(self):
        return f"{self.author_name} on {self.post}"

The reverse side is a manager, so it is filterable and countable - not a plain list:

post.comments.all()
post.comments.filter(approved=True)
post.comments.count()
post.comments.create(author_name="Ada", body="Nice post")   # post is set for you

And in a template the same accessor works, including a filtered count:

<h2>{{ post.comments.count }} comments</h2>
{% for comment in post.comments.all %}
  <p><strong>{{ comment.author_name }}</strong> {{ comment.body }}</p>
{% endfor %}

5. ManyToManyField and the table you never see

class Tag(models.Model):
    name = models.CharField(max_length=40, unique=True)
    slug = models.SlugField(max_length=40, unique=True)

    def __str__(self):
        return self.name


class Post(models.Model):
    ...
    tags = models.ManyToManyField(Tag, related_name="posts", blank=True)

There is no tags column in blog_post. Django creates a third table with two foreign keys and a unique constraint on the pair:

CREATE TABLE "blog_post_tags" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "post_id" bigint NOT NULL REFERENCES "blog_post" ("id"),
    "tag_id" bigint NOT NULL REFERENCES "blog_tag" ("id")
);
CREATE UNIQUE INDEX "blog_post_tags_post_id_tag_id_uniq"
    ON "blog_post_tags" ("post_id", "tag_id");
django = Tag.objects.create(name="django", slug="django")
post.tags.add(django)          # idempotent - adding twice changes nothing
post.tags.remove(django)
post.tags.set([django, python_tag])
django.posts.all()             # the reverse side, thanks to related_name

6. through: when the relation has its own data

The moment you want to store something about the link - who tagged it, when, how relevant it is - the generated table is not enough. Declare it yourself:

class Post(models.Model):
    ...
    tags = models.ManyToManyField(Tag, through="Tagging", related_name="posts", blank=True)


class Tagging(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    tag = models.ForeignKey(Tag, on_delete=models.CASCADE)
    tagged_at = models.DateTimeField(auto_now_add=True)
    tagged_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True
    )

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["post", "tag"], name="unique_post_tag"),
        ]

With a through model you may still call add() and set() in modern Django as long as every extra field has a default or allows NULL; when it does not, create the row explicitly:

Tagging.objects.create(post=post, tag=django, tagged_by=request.user)

7. OneToOneField and when it fits

A one-to-one is a foreign key with a unique constraint. Use it to extend a model you do not own - the classic case being the built-in user:

# accounts/models.py
class Profile(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="profile",
    )
    bio = models.TextField(blank=True)
    website = models.URLField(blank=True)
request.user.profile.bio       # forward and reverse look identical
profile.user.username

8. Traversing relations in queries

The double underscore follows a relation, in either direction, as deep as you like:

# forward: post -> category
Post.objects.filter(category__slug="django")

# forward through many-to-many
Post.objects.filter(tags__name="orm")

# backward: category -> posts (lowercased model name, or the related_name)
Category.objects.filter(posts__title__icontains="django")

# two steps at once: posts that have an approved comment by Ada
Post.objects.filter(comments__approved=True, comments__author_name="Ada").distinct()

Note the distinct(). Joining across a to-many relation multiplies rows: a post with three matching comments comes back three times unless you ask the database to collapse duplicates.

9. Exercise

  1. Add Category, Tag and Comment to blog/models.py exactly as above, then run python manage.py makemigrations blog and migrate.
  2. Register all three in blog/admin.py and create two categories, four tags and a handful of comments through the admin.
  3. In python manage.py shell, print the number of posts per category using only the reverse accessor category.posts.count().
  4. Change Post.category from PROTECT to SET_NULL, migrate, and try deleting a category that still has posts. Explain the difference in behaviour in one sentence.
  5. Show every post that has at least one unapproved comment, in a single query.

Further reading

After this lesson you will

  • Pick the right relation type for a given problem
  • Add Category, Tag and Comment to the blog
  • Follow relations in both directions