Module 4: Django Database Relationships Deep Dive
🎯 What You Will Learn in This Module
By the end of this tutorial, you will:
- Master the three types of Django relationships: One-to-Many, Many-to-Many, and One-to-One
- Understand
on_deletebehaviors and choose the right one for every situation - Use
related_nameto query relationships in reverse - Build self-referential models (e.g., nested comments with replies)
- Create intermediate models for Many-to-Many relationships with extra data
- Prevent duplicate relationships using
unique_together - Design a complete Library Management System from scratch
📖 1. Why Relationships Matter
In the real world, data is connected, not isolated.
- A blog post has one author, but an author writes many posts
- A student enrolls in many courses, and a course has many students
- A user has exactly one profile, and a profile belongs to exactly one user
Without relationships, you would duplicate data everywhere. With Django relationships, you keep data normalized, consistent, and queryable.
🔗 2. One-to-Many: The ForeignKey
The Rule: One parent, many children.
A ForeignKey lives on the "many" side. If one category has many posts, the ForeignKey goes on the Post model.
The Comment System: A Real-World Example
Let's build a comment system where:
- A post has many comments
- A comment has one author
- A comment can have replies (nested comments)
1from django.db import models 2from django.contrib.auth import get_user_model 3 4User = get_user_model() 5 6 7class Comment(models.Model): 8 """ 9 A comment on a blog post. 10 Supports nested replies (one level deep). 11 """ 12 13 # One Post → Many Comments 14 # When a post is deleted, delete all its comments 15 post = models.ForeignKey( 16 'blog.Post', # String reference avoids circular imports 17 on_delete=models.CASCADE, 18 related_name='comments' # post.comments.all() 19 ) 20 21 # One User → Many Comments 22 author = models.ForeignKey( 23 User, 24 on_delete=models.CASCADE, 25 related_name='user_comments' # user.user_comments.all() 26 ) 27 28 # Self-referential: One Comment → Many Replies 29 # A reply points to its parent comment 30 parent = models.ForeignKey( 31 'self', # Points to the Comment model itself 32 on_delete=models.CASCADE, 33 null=True, # Top-level comments have no parent 34 blank=True, 35 related_name='replies' # comment.replies.all() 36 ) 37 38 content = models.TextField() 39 is_approved = models.BooleanField(default=False) 40 41 class Meta: 42 ordering = ['created_at'] 43 44 def __str__(self): 45 return f'Comment by {self.author} on {self.post}'
How to Use This in Practice
1# Create a comment 2comment = Comment.objects.create( 3 post=post, 4 author=user, 5 content='Great article!' 6) 7 8# Create a reply 9reply = Comment.objects.create( 10 post=post, 11 author=another_user, 12 parent=comment, # This makes it a reply 13 content='Thanks for reading!' 14) 15 16# Query all comments on a post 17post.comments.all() 18 19# Query all replies to a comment 20comment.replies.all() 21 22# Query all comments by a user 23user.user_comments.all()
🔗 3. Many-to-Many: When Both Sides Are "Many"
The Rule: Many students, many courses. Neither side owns the other exclusively.
Simple Many-to-Many
1class Post(models.Model): 2 title = models.CharField(max_length=200) 3 4 # A post can have many tags 5 # A tag can belong to many posts 6 tags = models.ManyToManyField( 7 'Tag', 8 related_name='posts', # tag.posts.all() 9 blank=True 10 )
Behind the scenes: Django creates a hidden table like this:
| id | post_id | tag_id |
|---|---|---|
| 1 | 5 | 2 |
| 2 | 5 | 3 |
| 3 | 7 | 2 |
Advanced Many-to-Many: The Through Model
What if you need to store extra data about the relationship? For example, when a student enrolled, their progress, and completion status.
You need an intermediate model (also called a "through" model or junction table).
1class Course(models.Model): 2 title = models.CharField(max_length=200) 3 4 # Many-to-Many WITH extra fields via through model 5 students = models.ManyToManyField( 6 User, 7 through='Enrollment', # Use the Enrollment model 8 through_fields=('course', 'student'), # Which fields link to what 9 related_name='enrolled_courses' # user.enrolled_courses.all() 10 ) 11 12 # Another Many-to-Many: instructors (no extra fields needed) 13 instructors = models.ManyToManyField( 14 User, 15 related_name='teaching_courses', 16 limit_choices_to={'is_staff': True} # Only staff can be instructors 17 ) 18 19 20class Enrollment(models.Model): 21 """ 22 Intermediate model storing extra data about the student-course relationship. 23 """ 24 student = models.ForeignKey(User, on_delete=models.CASCADE) 25 course = models.ForeignKey(Course, on_delete=models.CASCADE) 26 27 # Extra fields about the relationship 28 enrolled_at = models.DateTimeField(auto_now_add=True) 29 completed = models.BooleanField(default=False) 30 progress = models.PositiveSmallIntegerField(default=0) # 0% to 100% 31 32 class Meta: 33 # CRITICAL: Prevent a student from enrolling twice in the same course 34 unique_together = ['student', 'course'] 35 36 def __str__(self): 37 return f'{self.student} in {self.course} ({self.progress}%)'
How to Use Through Models
1# Enroll a student (creates an Enrollment record) 2course.students.add(user) # Or use Enrollment.objects.create() 3 4# Query all courses a student is taking 5user.enrolled_courses.all() 6 7# Query all students in a course 8course.students.all() 9 10# Access enrollment details 11enrollment = Enrollment.objects.get(student=user, course=course) 12print(enrollment.progress) # 75 13print(enrollment.enrolled_at) # 2026-08-14
💡 Tip:
limit_choices_torestricts which users appear in dropdowns. Here, onlyis_staff=Trueusers can be selected as instructors.
🔗 4. One-to-One: Strictly One-to-One
The Rule: One user, one profile. No exceptions.
Use OneToOneField when you want to extend a model without modifying it directly. This is perfect for adding fields to Django's built-in User model.
1class Profile(models.Model): 2 """ 3 Extends the User model with additional personal information. 4 One User → Exactly One Profile 5 """ 6 user = models.OneToOneField( 7 User, 8 on_delete=models.CASCADE, 9 related_name='profile' # user.profile 10 ) 11 12 bio = models.TextField(max_length=500, blank=True) 13 birth_date = models.DateField(null=True, blank=True) 14 phone = models.CharField(max_length=15, blank=True) 15 avatar = models.ImageField(upload_to='avatars/', blank=True) 16 website = models.URLField(blank=True) 17 18 def __str__(self): 19 return f"{self.user.username}'s Profile"
How It Differs from ForeignKey
| Feature | ForeignKey | OneToOneField |
|---|---|---|
| Relationship | One → Many | One → One |
| Reverse access | user.posts.all() (QuerySet) | user.profile (Single object) |
| Duplicate allowed? | Yes (many posts per user) | No (one profile per user) |
| Database constraint | None extra | UNIQUE constraint on the column |
1# Accessing a One-to-One relationship 2user = User.objects.get(username='john') 3profile = user.profile # Direct access, no .all() needed 4print(profile.bio) 5 6# This raises Profile.DoesNotExist if no profile exists 7# Use: hasattr(user, 'profile') or try/except to handle this
🛡️ 5. Mastering on_delete Behaviors
When the object on the "one" side of a relationship is deleted, what happens to the objects on the "many" side?
| Behavior | What Happens | Best Used When |
|---|---|---|
CASCADE | Delete the child object automatically | Comments when a post is deleted |
PROTECT | Raise an error, prevent deletion | Category with existing posts |
SET_NULL | Set the foreign key to NULL | Post survives if author is deleted |
SET_DEFAULT | Set the foreign key to its default value | Assign "Uncategorized" automatically |
DO_NOTHING | Take no database action | Advanced use, manual cleanup required |
RESTRICT | Like PROTECT, but allows if referenced through another path | Complex deletion rules |
Visual Examples
1# CASCADE: If the post is deleted, all comments disappear 2post = models.ForeignKey(Post, on_delete=models.CASCADE) 3 4# PROTECT: You cannot delete a category if posts still reference it 5category = models.ForeignKey(Category, on_delete=models.PROTECT) 6 7# SET_NULL: If the author is deleted, the post becomes "orphaned" but survives 8author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) 9 10# SET_DEFAULT: If category is deleted, post moves to "General" category 11category = models.ForeignKey( 12 Category, 13 on_delete=models.SET_DEFAULT, 14 default=1 # ID of "General" category 15) 16 17# RESTRICT: Delete user only if they have no posts OR posts are also being deleted 18author = models.ForeignKey(User, on_delete=models.RESTRICT)
🚨 Warning:
DO_NOTHINGis dangerous. It leaves broken foreign keys in your database, which can causeIntegrityErrorwhen you try to access related objects later.
🔄 6. Migrations: Managing Schema Changes
Relationships add complexity to migrations. Here's your complete toolkit:
1# Create migration files after changing models 2python manage.py makemigrations 3 4# Apply migrations to the database 5python manage.py migrate 6 7# View the actual SQL Django will run (great for learning!) 8python manage.py sqlmigrate blog 0001 9 10# Check migration status 11python manage.py showmigrations 12 13# Fake a migration (mark as applied without running SQL) 14python manage.py migrate --fake blog 0002 15 16# Rollback to a previous migration 17python manage.py migrate blog 0001
What Happens During a Relationship Migration?
When you add a ForeignKey, Django:
- Adds a new column to the child table (e.g.,
post_idin thecommentstable) - Creates a database index on that column for fast lookups
- Adds a foreign key constraint to maintain data integrity
When you add a ManyToManyField, Django:
- Creates a new junction table with two foreign key columns
- Adds a composite unique constraint (prevents duplicate pairs)
🧪 7. Practice Task — Build a Library System
Apply everything you learned by designing a complete library management database.
Requirements
Create these models in a new app called library (python manage.py startapp library):
Author
first_name(CharField, max_length=100)last_name(CharField, max_length=100)birth_date(DateField, optional)bio(TextField, optional)
Book
title(CharField, max_length=200, unique)slug(SlugField, unique)isbn(CharField, max_length=13, unique)summary(TextField, optional)published_date(DateField)pages(PositiveIntegerField)cover_image(ImageField, optional)is_available(BooleanField, default=True)authors(ManyToManyField to Author)genre(ForeignKey to a Genre model)
Genre
name(CharField, unique)slug(SlugField, unique)description(TextField, optional)
Member
user(OneToOneField to User)membership_date(DateField, auto_now_add)phone(CharField, optional)address(TextField, optional)is_active(BooleanField, default=True)
BorrowRecord (Through Model)
book(ForeignKey to Book)member(ForeignKey to Member)borrowed_at(DateTimeField, auto_now_add)due_date(DateTimeField)returned_at(DateTimeField, null=True, blank=True)fine_amount(DecimalField, default=0.00, max_digits=6, decimal_places=2)
Constraints:
- A member cannot borrow the same book twice without returning it first (
unique_togetheronbookandmemberwherereturned_atis null — advanced, but try it!) - Order BorrowRecords by
-borrowed_at
Solution
1# library/models.py 2 3from django.db import models 4from django.contrib.auth import get_user_model 5 6User = get_user_model() 7 8 9class Genre(models.Model): 10 name = models.CharField(max_length=100, unique=True) 11 slug = models.SlugField(max_length=100, unique=True) 12 description = models.TextField(blank=True) 13 14 class Meta: 15 ordering = ['name'] 16 17 def __str__(self): 18 return self.name 19 20 21class Author(models.Model): 22 first_name = models.CharField(max_length=100) 23 last_name = models.CharField(max_length=100) 24 birth_date = models.DateField(null=True, blank=True) 25 bio = models.TextField(blank=True) 26 27 class Meta: 28 ordering = ['last_name', 'first_name'] 29 30 def __str__(self): 31 return f"{self.first_name} {self.last_name}" 32 33 34class Book(models.Model): 35 title = models.CharField(max_length=200, unique=True) 36 slug = models.SlugField(max_length=200, unique=True) 37 isbn = models.CharField(max_length=13, unique=True) 38 summary = models.TextField(blank=True) 39 published_date = models.DateField() 40 pages = models.PositiveIntegerField() 41 cover_image = models.ImageField(upload_to='books/covers/', blank=True, null=True) 42 is_available = models.BooleanField(default=True) 43 44 # Many-to-Many: A book can have multiple authors 45 authors = models.ManyToManyField(Author, related_name='books') 46 47 # ForeignKey: A book belongs to one genre 48 genre = models.ForeignKey( 49 Genre, 50 on_delete=models.SET_NULL, 51 null=True, 52 related_name='books' 53 ) 54 55 class Meta: 56 ordering = ['-published_date'] 57 58 def __str__(self): 59 return self.title 60 61 62class Member(models.Model): 63 user = models.OneToOneField( 64 User, 65 on_delete=models.CASCADE, 66 related_name='library_profile' 67 ) 68 membership_date = models.DateField(auto_now_add=True) 69 phone = models.CharField(max_length=15, blank=True) 70 address = models.TextField(blank=True) 71 is_active = models.BooleanField(default=True) 72 73 def __str__(self): 74 return f"Member: {self.user.username}" 75 76 77class BorrowRecord(models.Model): 78 book = models.ForeignKey( 79 Book, 80 on_delete=models.CASCADE, 81 related_name='borrow_records' 82 ) 83 member = models.ForeignKey( 84 Member, 85 on_delete=models.CASCADE, 86 related_name='borrow_records' 87 ) 88 borrowed_at = models.DateTimeField(auto_now_add=True) 89 due_date = models.DateTimeField() 90 returned_at = models.DateTimeField(null=True, blank=True) 91 fine_amount = models.DecimalField(max_digits=6, decimal_places=2, default=0.00) 92 93 class Meta: 94 ordering = ['-borrowed_at'] 95 # Prevent duplicate active borrow records 96 # Note: For full "no duplicate unreturned" logic, use model validation 97 unique_together = ['book', 'member'] 98 99 def __str__(self): 100 return f"{self.member.user.username} borrowed {self.book.title}" 101 102 @property 103 def is_overdue(self): 104 from django.utils import timezone 105 if not self.returned_at and timezone.now() > self.due_date: 106 return True 107 return False
After creating these models, run:
1python manage.py makemigrations library 2python manage.py migrate
🧠 8. Querying Relationships
Now that you have relationships, here's how to query them efficiently:
1# Forward queries (from child to parent) 2comments = Comment.objects.filter(post__slug='django-tutorial') 3books = Book.objects.filter(genre__name='Science Fiction') 4 5# Reverse queries (from parent to child) 6comments = post.comments.filter(is_approved=True) 7books = author.books.filter(is_available=True) 8 9# Many-to-Many queries 10books = Book.objects.filter(authors__last_name='Orwell') 11students = User.objects.filter(enrolled_courses__title='Python 101') 12 13# One-to-One queries 14profile = user.profile 15member = Member.objects.get(user__username='john') 16 17# Chained lookups 18comments = Comment.objects.filter( 19 post__category__slug='tech', 20 author__is_active=True 21)
💡 Performance Tip: Always use
select_related()for ForeignKey/OneToOne andprefetch_related()for ManyToMany to avoid the N+1 query problem.
❌ Common Mistakes & How to Fix Them
| Mistake | Error / Symptom | Solution |
|---|---|---|
Missing on_delete | TypeError: ForeignKey missing on_delete | Always provide on_delete |
Missing null=True with SET_NULL | IntegrityError: null value in column | Add null=True to the field |
unique_together not in Meta | SyntaxError or ignored | Place inside class Meta: |
| Circular import | ImportError | Use string references like 'blog.Post' |
related_name collision | Reverse accessor conflict | Ensure unique related_name across models |
| Forgetting to register app | Models not detected | Add 'library' to INSTALLED_APPS |
ManyToManyField on both sides | Confusion about where to place it | Only define it on ONE model, not both |
✅ Module 4 Summary
| Concept | Key Takeaway |
|---|---|
| ForeignKey | One-to-Many. Lives on the "many" side. |
| ManyToManyField | Many-to-Many. Django creates a junction table. |
| OneToOneField | One-to-One. Use to extend existing models. |
through | Add extra fields to Many-to-Many relationships. |
through_fields | Specify which fields in the intermediate model link to which models. |
unique_together | Prevent duplicate relationship records. |
related_name | Name for reverse queries (parent.children.all()). |
on_delete=CASCADE | Delete children when parent is deleted. |
on_delete=PROTECT | Prevent parent deletion if children exist. |
on_delete=SET_NULL | Keep child, set reference to NULL. |
limit_choices_to | Restrict available choices in forms/admin. |
| Self-referential FK | Use 'self' to create nested structures (comments, categories). |
🚪 What's Next?
In Module 5, we will build a Custom User Model and set up the complete Authentication & Authorization system. You'll learn why you should always start projects with a custom user model, how to use email instead of username for login, and how to extend the user with profiles and roles.
Before proceeding, make sure:
- You created the Library system models successfully
- You ran
makemigrationsandmigratewithout errors - You understand when to use CASCADE vs. PROTECT vs. SET_NULL
- You can explain the difference between a simple ManyToMany and a through model
- You understand what
related_namedoes and how to use it in queries
Your database relationships are rock-solid. Ready to build the authentication layer? 🔐🚀