QuerySets and Model Relationships

Introduction

Real applications connect models: a post belongs to an author, tags attach to many posts. This chapter adds ForeignKey and ManyToManyField, explores reverse relations, filters across joins, and introduces select_related and prefetch_related to avoid N+1 query problems.

Prerequisites

Extend Models with Relationships

Author (ForeignKey)

blog/models.py:

Migrate:

bash
python manage.py makemigrations blog
python manage.py migrate

Code explanation:

RelationDB effect
ForeignKey(User)Column author_id on blog_post
on_delete=CASCADEDelete user → delete their posts
related_name="posts"user.posts.all() instead of post_set
ManyToManyFieldJunction table blog_post_tags

Use settings.AUTH_USER_MODEL string ("auth.User") so custom user models stay compatible.

Forward and Reverse Access

Default reverse name without related_name: post_set — explicit related_name reads cleaner.

Filtering Across Relations

Double underscore spans joins:

python
Post.objects.filter(author__username="alice")
Post.objects.filter(tags__slug="django")
Post.objects.filter(author__is_active=True, published=True)
 
User.objects.filter(posts__published=True).distinct()

.distinct() needed when M2M joins duplicate parent rows.

Lookups: __icontains, __gte, __in, __isnull.

Create with Relations

python
user = User.objects.get(username="admin")
tag, _ = Tag.objects.get_or_create(name="Django", slug="django")
 
post = Post.objects.create(
    title="Relations demo",
    slug="relations-demo",
    body="Content",
    author=user,
    published=True,
)
post.tags.add(tag)
post.tags.set([tag])  # replace all tags

Without optimization, listing posts may query the user table once per row:

python
# N+1: 1 query for posts + N for each author
posts = Post.objects.filter(published=True)
for p in posts:
    print(p.author.username)

Fix with select_related (SQL JOIN):

python
posts = Post.objects.filter(published=True).select_related("author")
for p in posts:
    print(p.author.username)  # no extra query

Use for ForeignKey and OneToOneField (single-valued joins).

M2M and reverse relations use separate queries batched in Python:

python
posts = (
    Post.objects.filter(published=True)
    .select_related("author")
    .prefetch_related("tags")
)
for p in posts:
    print(p.author.username, list(p.tags.all()))

For deep trees:

python
.prefetch_related("tags", "comments__author")

Aggregation Preview

python
from django.db.models import Count, Max
 
Post.objects.filter(published=True).count()
Post.objects.aggregate(latest=Max("created_at"))
Tag.objects.annotate(post_count=Count("posts")).order_by("-post_count")

Full aggregation patterns appear in project chapters; annotate + Count is common for tag clouds.

Update Views with Relations

blog/views.py:

python
def post_list(request):
    posts = (
        Post.objects.filter(published=True)
        .select_related("author")
        .prefetch_related("tags")
    )
    return render(request, "blog/post_list.html", {"posts": posts})

Template:

django
{% for post in posts %}
  <article>
    <h2><a href="{% url 'post-detail' pk=post.id %}">{{ post.title }}</a></h2>
    <p>By {{ post.author.username }}</p>
    <p>
      {% for tag in post.tags.all %}
        <span class="tag">{{ tag.name }}</span>
      {% endfor %}
    </p>
  </article>
{% endfor %}

on_delete Options

ValueBehavior
CASCADEDelete related rows
PROTECTBlock delete if related exist
SET_NULLSet FK NULL (null=True required)
SET_DEFAULTSet default= value
DO_NOTHINGDB-level only ( risky )

Choose PROTECT for categories you must not orphan accidentally.

Custom User Model (Awareness)

Projects often start with:

python
# users/models.py
from django.contrib.auth.models import AbstractUser
 
class User(AbstractUser):
    bio = models.TextField(blank=True)

Set AUTH_USER_MODEL = "users.User" before first migrate. Changing later is painful—plan early for production apps.

Debugging Queries

Development logging in settings/development.py:

python
LOGGING = {
    "version": 1,
    "handlers": {"console": {"class": "logging.StreamHandler"}},
    "loggers": {"django.db.backends": {"level": "DEBUG", "handlers": ["console"]}},
}

Or:

python
print(Post.objects.filter(published=True).query)

Use django-debug-toolbar in real projects (not required for this track).

Common Mistakes

MistakeSymptomFix
N+1 queriesSlow list pagesselect_related / prefetch_related
Forgot .distinct()Duplicate users in listAfter filtering on M2M
Wrong related_nameAttributeErrorCheck model definition
CASCADE on critical FKAccidental mass deletePROTECT or soft delete

Post-Chapter Checklist

  • Post.author ForeignKey and Post.tags M2M migrated
  • You query user.posts.all() and post.tags.all()
  • You filter with author__username, tags__slug
  • List view uses select_related("author") and prefetch_related("tags")
  • You understand on_delete choices

FAQ

related_query_name defaults to related_name; used in filter(posts__...) from User side.

Nullable author?

null=True, blank=True on FK — posts without author allowed.

Through model for M2M extra fields?

Add through="PostTag" with created_at on junction — eCommerce carts use this pattern.

GenericForeignKey?

Contenttypes framework — comments on any model — advanced.

PostgreSQL only features?

ArrayField, JSONField history — modern Django supports JSONField on MySQL too.