Featured image of post Relasi ForeignKey di Django

Relasi ForeignKey di Django

Memahami ForeignKey di Django untuk relasi one-to-many, opsi on_delete, related_name, traversal dari Post ke Category dan sebaliknya, serta contoh chain ke Comment

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…PilihContoh
“Child ini nggak ada artinya tanpa parent”CASCADEPost tanpa category. Hapus aja
“Child harus survive, larang penghapusan”PROTECTJangan biarkan saya hapus category yang masih punya post
“Child bisa eksis sendiri, clear saja link-nya”SET_NULLSimpan post-nya, set category jadi NULL (butuh null=True)
“Reassign ke value default kalau parent dihapus”SET_DEFAULTPindahkan 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.

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

ArahMengembalikanContoh
Forward (child → parent)Satu objectpost.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
python manage.py 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

Dibawah Lisensi CC BY-NC-SA 4.0
Dibangun dengan Hugo
Tema Stack dirancang oleh Jimmy