Introduction
The first part of this guide covered Django’s foundations: project structure, apps, views, templates, models, and the admin interface. With those pieces in place, we can now explore how they connect and expand.
This second part focuses on how data relates to other data and how to retrieve it efficiently. These aren’t advanced topics — they’re essential ones that the first part deliberately postponed to keep things focused.
The jump from “I understand the pieces” to “I can build something real” begins here.
Relationships Between Models
In the first part, we created a simple Post model that existed in isolation. But real applications rarely work with isolated data. A blog post has an author. A comment belongs to a post. A user might have a profile with additional information. The power of a relational database — and Django’s ORM — lies in expressing these connections.
Thinking Before Coding
Before writing any relationship code, pause and ask yourself some questions. If I delete this object, what should happen to related objects? Should they disappear too, or should they remain orphaned? Can one object belong to many others, or just one? Is the relationship optional or mandatory?
These aren’t implementation details — they’re domain decisions that affect how your application behaves. Getting them wrong means restructuring your database later, which is always painful. A few minutes of thought upfront saves hours of migration headaches.
Django provides three relationship types: ForeignKey for one-to-many, OneToOneField for one-to-one, and ManyToManyField for many-to-many. Each solves a different problem, and choosing the right one depends on your answers to those questions above.
One-to-Many with ForeignKey
The most common relationship in web applications is one-to-many. One author writes many posts, but each post has exactly one author. One category contains many products, but each product belongs to one category. Whenever you find yourself saying “belongs to” or “has many,” you’re looking at a ForeignKey.
from django.db import models
from django.contrib.auth.models import User
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
created_at = models.DateTimeField(auto_now_add=True)
The on_delete parameter is deceptively important. It defines what happens when the related object — in this case, the User — gets deleted. This isn’t a technical detail; it’s a policy decision about your data.
CASCADE means “delete the post if the author is deleted.” This makes sense for content that has no meaning without its creator — if the author account disappears, their posts go too. But imagine applying this to an e-commerce order: deleting a customer would delete their entire order history, which is probably not what your accounting department wants.
PROTECT takes the opposite approach: it prevents deletion entirely if related objects exist. You can’t delete a user who has posts until you deal with those posts first. This forces administrators to make conscious decisions about orphaned data rather than accidentally destroying it.
SET_NULL offers a middle ground. The post remains, but its author field becomes empty. This requires adding null=True to the field definition, and it makes sense for optional relationships — the post can exist without a known author. News articles sometimes use this approach when an author leaves the organization but their work should remain published.
The related_name parameter solves a readability problem. Without it, Django creates a default accessor called post_set, so you’d write user.post_set.all() to get a user’s posts. With related_name='posts', it becomes user.posts.all() — cleaner and more intuitive. This seems minor, but code readability compounds over time. A year from now, user.posts will be immediately clear; user.post_set requires a moment of mental translation.
One-to-One for Extending Models
Sometimes you need exactly one related object — not “many,” not “zero or more,” but precisely one. The canonical example is user profiles.
Django’s built-in User model handles authentication: username, password, email, permissions. It’s deliberately minimal because authentication requirements are universal, but profile information varies wildly between applications. A social network needs bio and avatar fields. An e-commerce site needs shipping addresses. A B2B platform needs company information.
Rather than modifying the User model (which creates maintenance headaches with Django updates), you create a separate Profile model linked by OneToOneField:
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
website = models.URLField(blank=True)
This creates a one-to-one correspondence: each user has exactly one profile, each profile belongs to exactly one user. Access flows naturally in both directions:
user.profile.bio # from user to profile
profile.user.email # from profile to user
The elegance here is separation of concerns. Authentication logic stays in User, where Django maintains it. Your application-specific data lives in Profile, where you control it completely. When Django releases a security update to the User model, your Profile remains untouched.
One subtlety: the Profile doesn’t create itself. When a new User registers, you need to create their Profile too — typically using Django signals or by handling it in your registration view. Forgetting this leads to “RelatedObjectDoesNotExist” errors that confuse beginners.
Many-to-Many for Complex Connections
Some relationships resist the “belongs to” framing entirely. A blog post can have multiple tags. A tag applies to multiple posts. Neither side owns the other; they simply reference each other.
class Tag(models.Model):
name = models.CharField(max_length=50)
class Post(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
tags = models.ManyToManyField(Tag, related_name='posts')
Behind the scenes, Django creates a third table — an intermediary that stores pairs of post IDs and tag IDs. You never see this table directly (unless you need to add extra fields to the relationship, which is an advanced topic), but understanding it exists helps you reason about performance.
Interacting with many-to-many relationships feels natural:
post.tags.add(tag1, tag2) # connect tags to post
post.tags.remove(tag1) # disconnect a tag
post.tags.all() # get all tags for this post
tag.posts.all() # get all posts with this tag
The mental model is simpler than the underlying implementation. You’re not thinking about junction tables and foreign keys; you’re thinking about posts and tags, and Django handles the translation.
One common question: which model should define the ManyToManyField? In our example, Post has the tags field, but we could have put a posts field on Tag instead. Functionally, they’re equivalent — the intermediary table is the same either way. Choose based on which direction feels more natural to query. Posts having tags feels right; tags having posts feels slightly awkward.
After any model changes, remember the migration dance:
python manage.py makemigrations
python manage.py migrate
This isn’t optional. Django doesn’t modify your database automatically — it creates migration files that describe the changes, then applies them when you’re ready. This two-step process lets you review what’s about to happen, which matters enormously in production.
Retrieving Data with QuerySets
Defining models creates structure. But structure without retrieval is useless — you need to get data out of the database and into your views and templates. Django’s QuerySet API is how you do this, and understanding its behavior deeply will save you from both bugs and performance disasters.
The Crucial Concept of Lazy Evaluation
Here’s something that surprises every Django beginner: QuerySets don’t execute database queries when you create them. They’re lazy — they wait until you actually need the results.
# No database query yet — this just builds a description of what we want
qs = Post.objects.filter(author=user)
qs = qs.filter(created_at__year=2024)
qs = qs.order_by('-created_at')
# NOW the query executes, when we iterate
for post in qs:
print(post.title)
Why does this matter? Because it enables flexible, composable query building. Consider a view that filters posts based on URL parameters:
def post_list(request):
qs = Post.objects.all()
if request.GET.get('author'):
qs = qs.filter(author__username=request.GET['author'])
if request.GET.get('tag'):
qs = qs.filter(tags__name=request.GET['tag'])
return render(request, 'blog/list.html', {'posts': qs})
Each if block adds a filter conditionally, but no database queries happen until the template actually loops over posts. This isn’t just elegant — it’s efficient. Django combines all the conditions into a single SQL query with the appropriate WHERE clauses. Without lazy evaluation, you’d either run multiple queries or write complicated logic to build SQL strings manually.
Lazy evaluation also means you can pass QuerySets around without worrying about premature execution. A function can accept a QuerySet, add filters, and return it for further modification. The query only runs when something finally demands results.
Filtering with Field Lookups
Django’s lookup system uses double underscores to specify matching behavior. At first, this syntax looks cryptic — title__icontains is not how most programming languages express “case-insensitive contains.” But the system is consistent and powerful once you internalize it.
# Text matching
Post.objects.filter(title__contains='django') # case-sensitive
Post.objects.filter(title__icontains='django') # case-insensitive
# Date components
Post.objects.filter(created_at__year=2024)
Post.objects.filter(created_at__month=6)
# Traversing relationships
Post.objects.filter(author__username='alice')
Post.objects.filter(tags__name='python')
# Comparison
Post.objects.filter(created_at__gte=start_date) # greater than or equal
Post.objects.filter(created_at__lt=end_date) # less than
The relationship traversal deserves special attention. author__username='alice' doesn’t just filter — it tells Django to JOIN the User table and match against the username column there. You’re describing what you want in terms of your Python models, and Django figures out the SQL joins. This abstraction is why the ORM exists.
You can chain these traversals arbitrarily deep: post.comments.filter(author__profile__country='Italy') would find comments by authors whose profile indicates they’re from Italy. Each double-underscore crosses a relationship boundary.
The N+1 Problem
One performance trap catches almost every Django beginner, usually in production when someone notices the site is slow.
Consider this innocent-looking code:
posts = Post.objects.all()
for post in posts:
print(post.author.username) # this triggers a query EACH iteration
If you have 100 posts, this executes 101 database queries: one to fetch all posts, then one per post to fetch its author. This is the N+1 problem — N queries for related objects plus 1 for the main objects.
The template makes this worse because it’s hidden:
{% for post in posts %}
<p>{{ post.title }} by {{ post.author.username }}</p>
{% endfor %}
Accessing post.author.username in the template triggers the same N queries, but you don’t see a loop in Python code, so the problem is invisible.
The solution is select_related, which tells Django to fetch related objects upfront using a SQL JOIN:
posts = Post.objects.select_related('author').all()
Now there’s one query that fetches posts and their authors together. For ManyToMany relationships, use prefetch_related instead — it runs a second query but caches the results intelligently:
posts = Post.objects.prefetch_related('tags').all()
The rule of thumb: if your template or loop accesses related objects, you probably need one of these methods. Django Debug Toolbar is invaluable for spotting N+1 problems — it shows you exactly how many queries each page executes.
What Comes Next
This part covered how to structure relationships between your data and how to query that data efficiently. These are foundational concepts that affect every Django application.
The next part will address user interaction: forms and validation, user authentication, class-based views, and an introduction to building APIs with Django REST Framework.
Conclusion - Staff Note
The concepts introduced here — especially relationships and the ORM — require hands-on practice to internalize. Create some models with different relationship types. Query them in the Django shell. Use print(qs.query) to see what SQL Django generates.
The N+1 problem will bite you until checking query counts becomes habit. Install Django Debug Toolbar early and watch those numbers. When you see 50 queries on a page that should need 2, you’ll know exactly where to look.
The Frontek.dev Team