Relasi ForeignKey di Django (One-to-Many)
Gambaran Umum
Kali ini kita akan fokus ke satu konsep saja yaitu field ForeignKey, yang merepresentasikan relasi one-to-many. Di akhir pembahasan kali ini, kita akan bisa menghubungkan dua model sekaligus dan melakukan traversal relasinya dari kedua arah.
Kita sudah pernah melihat ForeignKey sebelumnya saat menghubungkan Article ke User. Kali ini kita akan membahasnya lebih detail lagi. Kita akan menghubungkan model buatan kita sendiri satu sama lain dan mengeksplorasinya.
Analogi
Bayangkan folder dan file di komputer kita. Satu folder bisa berisi banyak file, tapi setiap file hanya ada di satu folder. Itulah relasi one-to-many.
1
2
3
4
5
6
7
8
| Folder: "Foto Liburan"
├── foto_001.jpg
├── foto_002.jpg
└── foto_003.jpg
Folder: "Dokumen Kerja"
├── laporan.pdf
└── invoice.pdf
|
Dalam istilah database: banyak baris di tabel “file” menunjuk ke satu baris di tabel “folder”. child (file) yang menyimpan referensi ke parent-nya (folder).
Lesson ini menggunakan contoh app blog: satu Category bisa berisi banyak Post, dan setiap Post hanya milik satu Category.
1
2
3
4
5
6
7
8
| Category: "Python"
├── Post: "Understanding Lists"
├── Post: "Dictionary Tricks"
└── Post: "F-Strings Explained"
Category: "Django"
├── Post: "Your First View"
└── Post: "URL Routing Basics"
|
Buat App Blog
1
2
3
| cd my-drf-project
source .venv/bin/activate
python manage.py startapp blog
|
Register ke config/settings.py:
1
2
3
4
5
6
7
8
9
10
11
| INSTALLED_APPS = [
# ... Django built-ins ...
# Third-party
'rest_framework',
# Your apps
'books',
'articles',
'blog',
]
|
Model Parent: Category
Model parent adalah yang menjadi “one” pada “one to many”. Parent ini tidak perlu tahu soal child-nya, cukup ada aja.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # blog/models.py
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
class Meta:
verbose_name_plural = 'categories'
def __str__(self):
return self.name
|
Tidak ada yang spesial di sini, hanya model sederhana. Perhatikan verbose_name_plural = 'categories', itu didefinisikan sedimikian rupa karena kalau dibiarkan, Django akan memanggilnya “Categorys” (Django hanya menambahkan akhiran “s” seenaknya).
Model Child: Post (dengan ForeignKey)
Model child adalah sisi “many”-nya. Dia-lah yang menyimpan ForeignKey, pointer ke parent-nya.
1
2
3
4
5
6
7
8
9
10
11
12
| class Post(models.Model):
category = models.ForeignKey(
Category,
on_delete=models.CASCADE,
related_name='posts',
)
title = models.CharField(max_length=200)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
|
Mari kita bedah baris per baris untuk bagian ForeignKey-nya:
Category — model target
Argumen paling awal pada model Post adalah “model yang ditunjuk field ini”. Sebagai catatan, karena Category didefinisikan di atas Post dalam file yang sama, kita bisa langsung mereferensinya dengan menggunakan nama modelnya.
Tapi jika model Category didefinisikan di bawah Post pada file yang sama, kita perlu menggunakan string seperti contoh dibawah:
1
2
| # String reference — berfungsi walaupun modelnya belum didefinisikan di file ini (tetapi ada di app yang sama)
category = models.ForeignKey('Category', on_delete=models.CASCADE, related_name='posts')
|
Kalau model target ada di app yang berbeda, kita referensikan dengan cara nama_app.nama_model seperti dibawah ini:
1
2
| # reference yang cross apps — 'nama_app.nama_model'
category = models.ForeignKey('blog.Category', on_delete=models.CASCADE, related_name='posts')
|
Untuk model kita sendiri, direct reference maupun string reference sama saja. Format cross-app berupa string seperti ('blog.Category') dibutuhkan hanya kalau modelnya tidak bisa di-import di file yang sekarang.
on_delete=models.CASCADE — Yang terjadi kalau parent dihapus
Menjawab pertanyaan: “Kalau category ‘Python’ dihapus, apa yang terjadi dengan seluruh post-nya?”
Dengan CASCADE: semua post di category tersebut ikut terhapus.
Ini cara menghafal mengenai bagaimana pengambilan keputusannya:
| Tanyakan ke diri sendiri… | Pilih | Contoh |
|---|
| “Child ini nggak ada artinya tanpa parent” | CASCADE | Post tanpa category. Hapus aja |
| “Child harus survive, larang penghapusan” | PROTECT | Jangan biarkan saya hapus category yang masih punya post |
| “Child bisa eksis sendiri, clear saja link-nya” | SET_NULL | Simpan post-nya, set category jadi NULL (butuh null=True) |
| “Reassign ke value default kalau parent dihapus” | SET_DEFAULT | Pindahkan post yang jadi orphan ke category “Uncategorized” |
Untuk blog app, sebenarnya PROTECT lebih masuk akal untuk penggunaan yang sebenarnya. Kita kemungkinan besar tidak mau kalau post-post kita ikut terhapus hanya karena tidak sengaja menghapus category. Tapi untuk belajar, kita gunakan CASCADE agar lebih mudah dipahami.
related_name=‘posts’ — reverse accessor
Ini mendefinisikan cara kita mengakses child dari sisi parent-nya:
1
2
| category = Category.objects.get(name='Python')
category.posts.all() # semua post di category Python
|
Tanpa related_name, Django auto-generate post_set:
1
| category.post_set.all() # bisa seperti ini, tapi bacanya kurang oke
|
Rule of thumb: Selalu set related_name secara eksplisit. Gunakan bentuk plural dari nama model child-nya.
Yang Terjadi di Database
Saat Django membuat tabel blog_post, dia menambahkan kolom bernama category_id:
1
2
3
4
5
6
7
8
| tabel blog_post:
+----+------------------------+------+---------------------+-------------+
| id | title | body | created_at | category_id |
+----+------------------------+------+---------------------+-------------+
| 1 | Understanding Lists | ... | 2026-04-07 10:00:00 | 1 |
| 2 | Dictionary Tricks | ... | 2026-04-07 11:00:00 | 1 |
| 3 | Your First View | ... | 2026-04-07 12:00:00 | 2 |
+----+------------------------+------+---------------------+-------------+
|
Django secara otomatis menambahkan _id di belakang nama field. Ini membuat kita jadi punya dua cara untuk mengakses relasinya:
1
2
3
4
5
6
7
8
| post = Post.objects.get(pk=1)
# Akses object Category lengkap (trigger database query)
post.category # <Category: Python>
post.category.name # "Python"
# Akses hanya integer ID-nya (tanpa query tambahan)
post.category_id # 1
|
Perbedaan ini penting dari sisi performa. Kalau kita hanya butuh ID-nya saja (umum di API), post.category_id menghindari database query yang tidak perlu.
Traversal Relasi
ForeignKey membuat koneksi dua arah. Kita bisa pergi dari child ke parent (forward) dan dari parent ke children (reverse).
Forward: Child → Parent
1
2
3
4
| post = Post.objects.get(title='Understanding Lists')
post.category # object Category-nya
post.category.name # "Python"
post.category.description # deskripsi dari category-nya
|
Ini selalu mengembalikan satu object karena setiap post hanya milik satu category.
Reverse: Parent → Children
1
2
3
4
5
| category = Category.objects.get(name='Python')
category.posts.all() # QuerySet semua post di category ini
category.posts.count() # jumlah post (3)
category.posts.first() # post pertama
category.posts.filter(title__contains='List') # subset yang difilter
|
Ini mengembalikan sebuah manager — karena satu category bisa punya banyak post. Kita pakai .all(), .filter(), .count(), dll., persis seperti Model.objects.
Perbedaan kunci yang perlu diingat
| Arah | Mengembalikan | Contoh |
|---|
| Forward (child → parent) | Satu object | post.category → <Category: Python> |
| Reverse (parent → children) | Manager (QuerySet) | category.posts.all() → <QuerySet [...]> |
Chain ForeignKey (Model → Model → Model)
ForeignKey bisa di-chain. Mari tambahkan Comment. Setiap post bisa punya banyak komentar:
1
2
3
4
5
6
7
8
9
10
11
12
| class Comment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name='comments',
)
author_name = models.CharField(max_length=100)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Comment by {self.author_name} on {self.post.title}"
|
Sekarang kita punya chain tiga tingkat: Category → Post → Comment.
1
2
3
4
| Category: "Python"
└── Post: "Understanding Lists"
├── Comment by Alice: "Great post!"
└── Comment by Bob: "Very helpful."
|
Kita bisa traversal seluruh chain-nya:
1
2
3
4
5
6
7
8
9
10
11
| # Mulai dari comment, naik ke atas
comment = Comment.objects.first()
comment.post.title # "Understanding Lists"
comment.post.category.name # "Python"
# Mulai dari category, turun ke bawah
category = Category.objects.get(name='Python')
for post in category.posts.all():
print(f"\n{post.title}:")
for comment in post.comments.all():
print(f" - {comment.author_name}: {comment.body}")
|
File Models Lengkap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| # blog/models.py
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
class Meta:
verbose_name_plural = 'categories'
def __str__(self):
return self.name
class Post(models.Model):
category = models.ForeignKey(
Category,
on_delete=models.CASCADE,
related_name='posts',
)
title = models.CharField(max_length=200)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.title
class Comment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name='comments',
)
author_name = models.CharField(max_length=100)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Comment by {self.author_name} on {self.post.title}"
|
Latihan Praktik
Buat app dan register
1
| python manage.py startapp blog
|
Tambahkan 'blog' ke INSTALLED_APPS di config/settings.py.
Tulis model-nya
Copy model lengkap dari Bagian 9 ke blog/models.py.
Buat dan apply migrations
1
2
| python manage.py makemigrations blog
python manage.py migrate
|
Coba di Django shell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| from blog.models import Category, Post, Comment
# --- Buat categories ---
python_cat = Category.objects.create(name='Python', description='All things Python')
django_cat = Category.objects.create(name='Django', description='Django web framework')
# --- Buat posts (masing-masing milik satu category) ---
post1 = Post.objects.create(
category=python_cat,
title='Understanding Lists',
body='Lists are ordered collections...',
)
post2 = Post.objects.create(
category=python_cat,
title='Dictionary Tricks',
body='Dictionaries map keys to values...',
)
post3 = Post.objects.create(
category=django_cat,
title='Your First View',
body='A view is a Python function that takes a request...',
)
# --- Forward: child → parent ---
print(post1.category) # <Category: Python>
print(post1.category.name) # "Python"
print(post1.category_id) # 1 (hanya integer, tanpa query tambahan)
# --- Reverse: parent → children ---
print(python_cat.posts.all()) # <QuerySet [<Post: Understanding Lists>, ...]>
print(python_cat.posts.count()) # 2
print(django_cat.posts.count()) # 1
# --- Tambah comments ---
Comment.objects.create(post=post1, author_name='Alice', body='Great post!')
Comment.objects.create(post=post1, author_name='Bob', body='Very helpful.')
Comment.objects.create(post=post3, author_name='Charlie', body='Thanks for this!')
# --- Traversal chain ---
comment = Comment.objects.first()
print(comment.post.title) # "Understanding Lists"
print(comment.post.category.name) # "Python"
# --- Reverse chain ---
for cat in Category.objects.all():
print(f"\n=== {cat.name} ===")
for post in cat.posts.all():
comment_count = post.comments.count()
print(f" {post.title} ({comment_count} comments)")
# Output:
# === Python ===
# Understanding Lists (2 comments)
# Dictionary Tricks (0 comments)
# === Django ===
# Your First View (1 comments)
|
Test constraint-nya
1
2
3
4
5
6
7
8
9
| # Apa yang terjadi kalau kita hapus sebuah category?
python_cat.delete()
print(Post.objects.all()) # Post-post Python ikut hilang! (CASCADE)
# Apa yang terjadi kalau kita buat post tanpa category?
try:
Post.objects.create(title='Orphan Post', body='No category')
except Exception as e:
print(f"Error: {e}") # IntegrityError — NOT NULL constraint failed
|
Rangkuman
1
2
3
4
5
6
| ForeignKey → "banyak children, satu parent" — child yang menyimpan referensinya
on_delete → menentukan apa yang terjadi pada children saat parent dihapus
related_name → nama untuk reverse access (parent → children)
post.category → mengembalikan satu object (forward)
category.posts.all() → mengembalikan QuerySet (reverse)
post.category_id → hanya integer, tanpa query database tambahan
|
Foto cover oleh drunkenchimp dari Unsplash