Ada satu momen yang bikin deg-degan di tiap proyek Django: menambah field baru di models.py, menjalankan makemigrations, lalu menunggu migrate jalan mulus di production. Kalau salah langkah, tabel bisa berubah dengan cara yang bikin data lama ikut hilang. Saya sendiri pernah nyaris kehilangan satu kolom karena buru-buru menulis field tanpa default. Tulisan ini membongkar cara membuat model yang benar sejak awal, lengkap dengan relasi antar tabel dan strategi mengubah schema yang aman.

Mulai dari Proyek Kosong

Kalau kamu belum punya proyek Django, buat dulu environment-nya. Saya pakai venv bawaan Python supaya dependency proyek ini tidak bentrok dengan proyek lain di mesin yang sama.

python -m venv venv
source venv/bin/activate
pip install django
django-admin startproject myblog .
python manage.py startapp posts

Setelah startproject dan startapp, daftarkan aplikasi posts di settings.py. Tanpa langkah ini Django tidak mengenali model yang kamu tulis, dan makemigrations akan diam saja tanpa menghasilkan apa pun.

python
# myblog/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "posts",  # tambahkan baris ini
]

Kalau langkah-langkah di atas masih terasa asing, saya sudah menulis panduan lengkapnya di Membuat Website Pertamamu dengan Django.

Model Pertama: Field dan Tipe Data

Sebuah model adalah kelas Python yang mewakili satu tabel. Field pada kelas itu menjadi kolom pada tabel. Kita mulai dari model Author, lalu nanti Post akan menunjuk ke author lewat relasi.

python
# posts/models.py
from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)
    bio = models.TextField(blank=True)
    joined = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name

CharField wajib diberi max_length karena di PostgreSQL dan kebanyakan database kolom VARCHAR butuh batas panjang. TextField tidak punya batas, cocok untuk isi artikel. Parameter unique membuat database menolak email yang sama muncul dua kali. auto_now_add mengisi tanggal otomatis saat baris pertama kali dibuat. Perhatikan juga blank — parameter ini mengatur validasi di level form, bukan di database, jadi field dengan blank=True boleh kosong saat diisi lewat form admin.

Django otomatis menambahkan kolom id sebagai primary key kalau kamu tidak mendefinisikan primary key sendiri. Jadi tabel Author di atas sebenarnya punya lima kolom: id, name, email, bio, dan joined.

Relasi Antar Tabel: ForeignKey dan ManyToMany

Satu author bisa menulis banyak post. Di Django relasi seperti ini ditulis dengan ForeignKey di sisi yang banyak.

python
class Post(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    body = models.TextField()
    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="posts",
    )
    status = models.CharField(
        max_length=10,
        choices=[("draft", "Draft"), ("published", "Published")],
        default="draft",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return self.title


class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name

Untuk tag, satu post bisa punya banyak tag dan satu tag bisa dipakai banyak post. Ini relasi banyak-ke-banyak, ditulis dengan ManyToManyField di salah satu sisi saja.

python
class Post(models.Model):
    # ... field lain tetap ...
    tags = models.ManyToManyField(Tag, related_name="posts", blank=True)

on_delete menentukan apa yang terjadi pada post saat author-nya dihapus. CASCADE artinya post ikut terhapus. Pilihan lain seperti PROTECT akan memblokir penghapusan author selama masih ada post yang menunjuk ke dia. related_name membuat akses balik author.posts terbaca lebih natural daripada author.post_set bawaan.

Hati-hati dengan CASCADE pada data penting. Kalau kamu tidak sengaja menghapus author, seluruh post miliknya ikut hilang tanpa konfirmasi tambahan. Untuk data yang tidak boleh hilang, pakai PROTECT atau SET_NULL.

makemigrations vs migrate: Apa Bedanya?

Dua perintah ini sering disebut bersamaan padahal tugasnya berbeda. makemigrations membaca perubahan di models.py lalu menulis file migrasi baru di folder migrations milik app. migrate menjalankan file migrasi yang belum diterapkan ke database.

bash
python manage.py makemigrations posts
python manage.py migrate

File migrasi yang dihasilkan berisi daftar operasi seperti CreateModel atau AddField. Kamu boleh membukanya — isinya Python biasa, bukan sihir. Kalau kamu pernah bekerja dengan SQLAlchemy, konsep ini mirip dengan yang dilakukan Alembic, bedanya Django menuliskan file migrasi ini otomatis untukmu.

Mengubah Schema Tanpa Kehilangan Data

Ini bagian yang paling sering bikin masalah di production. Kamu menambah field baru ke model yang tabelnya sudah berisi ribuan baris. Kalau field itu tidak boleh NULL dan tidak punya default, database akan menolak migrasinya karena tidak tahu harus diisi apa pada baris-baris lama.

python
class Post(models.Model):
    # field lain tetap di tempatnya
    view_count = models.IntegerField(default=0)
    published_at = models.DateTimeField(null=True, blank=True)

default=0 membuat Django mengisi kolom baru dengan angka 0 pada semua baris lama, jadi migrasi jalan tanpa error. null=True mengizinkan nilai NULL di database untuk field yang memang belum punya nilai, sedangkan blank=True mengizinkan kosong di form.

Sebelum menjalankan migrate di production, jalankan dulu python manage.py makemigrations --check dan python manage.py migrate --plan di environment staging. Dua perintah itu memperlihatkan perubahan yang akan terjadi tanpa benar-benar mengeksekusinya.

Data Migration: Mengisi Field Baru pada Baris Lama

Kadang nilai default saja tidak cukup. Bayangkan kamu menambah field slug yang harus unik untuk setiap post. Field baru dibiarkan kosong dulu (null=True), lalu kamu isi slug dari judul lewat data migration, baru kemudian constraint unique diterapkan.

python
# posts/migrations/0003_backfill_slug.py
from django.db import migrations
from django.utils.text import slugify


def set_slug(apps, schema_editor):
    Post = apps.get_model("posts", "Post")
    for post in Post.objects.all():
        if not post.slug:
            post.slug = slugify(post.title)[:50]
            post.save(update_fields=["slug"])


def undo_slug(apps, schema_editor):
    Post = apps.get_model("posts", "Post")
    Post.objects.filter(slug__isnull=False).update(slug=None)


class Migration(migrations.Migration):

    dependencies = [
        ("posts", "0002_post_slug"),
    ]

    operations = [
        migrations.RunPython(set_slug, undo_slug),
    ]

Perhatikan apps.get_model. Kamu tidak mengimpor model Post langsung dari models.py karena saat migrasi dijalankan, model itu bisa saja sudah berbeda dari versi historisnya. apps.get_model mengambil versi model yang sesuai dengan titik waktu migrasi tersebut.

Query Singkat

Setelah schema rapi, menulis dan membaca data lewat ORM jadi langsung terasa. Berikut contoh lengkapnya.

python
from posts.models import Author, Post, Tag

author = Author.objects.create(
    name="Rina",
    email="rina@example.com",
)

post = Post.objects.create(
    title="Menguasai Django Models",
    slug="menguasai-django-models",
    body="Isi artikel...",
    author=author,
)
post.tags.add(Tag.objects.create(name="django"))

# Semua post milik Rina
for p in author.posts.all():
    print(p.title)

# Post yang sudah published
Post.objects.filter(status="published")

Untuk proyek yang sudah mulai ramai, query yang menyentuh relasi bisa memicu N+1 problem. Cara membasminya dengan select_related dan prefetch_related sudah saya bahas terpisah di Optimasi QuerySet Django.

Mulai dari proyek kosong tadi, coba tambahkan model Comment dengan ForeignKey ke Post, jalankan makemigrations, lalu buka file migrasi yang dihasilkan. Dari situ kamu langsung paham bagaimana Django menerjemahkan field Python menjadi kolom SQL.

Kenapa makemigrations tidak mendeteksi perubahan model saya?

Hampir selalu karena app belum terdaftar di INSTALLED_APPS, atau kamu lupa menyimpan file models.py. Pastikan juga folder migrations milik app berisi file __init__.py supaya Django mengenalinya sebagai package.

Apakah aman menghapus file migrasi yang sudah di-migrate?

Jangan. File migrasi yang sudah diterapkan tidak boleh dihapus karena Django mencatat namanya di tabel django_migrations. Kalau file migrasi sudah menumpuk, gunakan python manage.py squashmigrations untuk menggabungkannya jadi satu.