Kamu sedang membangun fitur registrasi. Tiap kali ada user baru, aplikasi harus membuat profil, mengirim email sambutan, dan menulis log audit. Kalau semuanya kamu tumpuk di view registrasi, view itu jadi gendut — dan user yang lahir dari admin panel atau manage.py createsuperuser tidak akan pernah memicu logika itu. Django Signals memecah masalah ini: kode yang peduli pada suatu kejadian cukup mendengarkan sinyalnya, tanpa perlu tahu siapa yang mengirim.

Apa Itu Django Signals?

Signal adalah pemberitahuan yang dipancarkan Django setiap kali sesuatu terjadi di ORM atau di framework. Model disimpan, model dihapus, request masuk — semuanya memancarkan sinyal. Kamu menulis receiver (fungsi yang mendengarkan) lalu menyambungkannya ke sinyal tertentu. Polanya mirip observer pattern: sender tidak peduli siapa receivernya, dan receiver tidak peduli dari mana sinyal itu datang. Django membawa sejumlah sinyal bawaan — post_save, pre_save, pre_delete, post_delete, m2m_changed, pre_init, post_init, request_started, sampai request_finished.

Setup Proyek

Kita pakai dua app: accounts untuk contoh profil, dan orders untuk contoh custom signal nanti. Buat proyeknya dulu:

python -m venv venv && source venv/bin/activate
pip install django
django-admin startproject config .
python manage.py startapp accounts
python manage.py startapp orders

Daftarkan kedua app di INSTALLED_APPS, lalu buat model Profile yang menempel ke User lewat OneToOneField. Kalau relasi model masih terasa asing, mampir dulu ke panduan Models & Migrations.

Profil Otomatis dengan post_save

post_save dipancarkan tepat setelah save() dipanggil — tidak peduli dari mana: view, admin, shell, atau sinyal lain. Argumen created bernilai True hanya saat objek baru dibuat pertama kali. Itu sinyal yang pas untuk membuat data pendamping seperti profil.

# accounts/models.py
from django.conf import settings
from django.db import models


class Profile(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    bio = models.TextField(blank=True)

    def __str__(self):
        return self.user.username
python
# accounts/signals.py
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Profile


@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def buat_profil(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)
python
# accounts/apps.py
from django.apps import AppConfig


class AccountsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "accounts"

    def ready(self):
        from . import signals  # noqa: F401

Receiver harus terdaftar sebelum Django selesai loading, dan tempat yang tepat untuk itu adalah method ready() di AppConfig. Kalau app-mu cuma punya satu AppConfig, tulis "accounts" di INSTALLED_APPS dan Django otomatis memanggil ready(). Ada lebih dari satu AppConfig? Tulis path lengkapnya: "accounts.apps.AccountsConfig".

Kalau kamu memilih tidak memakai dekorator @receiver, sambungkan manual dengan post_save.connect(buat_profil, sender=User, dispatch_uid="buat_profil"). Selalu beri dispatch_uid unik saat menghubungkan manual, supaya receiver tidak terdaftar dua kali ketika ready() terpanggil lebih dari sekali — misalnya saat dev server melakukan autoreload.

Menghindari Loop Rekursif

Bug paling umum saat memakai post_save adalah memanggil save() pada model yang sama di dalam receiver-nya sendiri. Setiap save() memancarkan post_save lagi, dan begitu seterusnya. Contoh klasik: mengisi slug otomatis dari judul.

python
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils.text import slugify

from .models import Article


@receiver(post_save, sender=Article)
def isi_slug_otomatis(sender, instance, created, **kwargs):
    if not instance.slug:
        instance.slug = slugify(instance.title)[:50]
        instance.save(update_fields=["slug"])

Kenapa ini aman? save() kedua memancarkan post_save lagi, tapi sekarang instance.slug sudah terisi sehingga guard if not instance.slug menghentikan rekursi. update_fields=["slug"] juga membatasi query ke satu kolom saja, tidak menimpa field lain.

Jangan memanggil instance.save() tanpa guard di dalam post_save untuk model yang sama. Selain loop tak berujung, kamu bisa memicu ratusan query yang tidak perlu. Kalau memang harus mengubah field, periksa dulu apakah perubahannya benar-benar dibutuhkan, lalu pakai update_fields.

Membuat Custom Signal

Signal bawaan menutupi event di ORM. Untuk kejadian domain-mu sendiri — pesanan dibayar, stok habis, akun di-suspend — definisikan sinyal sendiri. Deklarasikan sebagai objek Signal di satu modul supaya mudah diimpor.

python
# orders/signals.py
import django.dispatch


pesanan_dibayar = django.dispatch.Signal()

Kirim sinyalnya dari tempat yang kamu anggap sebagai sumber kebenaran — biasanya view atau service layer, tepat setelah status berubah di database:

python
# orders/views.py
from orders.models import Order
from orders.signals import pesanan_dibayar


def konfirmasi_pembayaran(request, order_id):
    order = Order.objects.get(id=order_id)
    order.status = "paid"
    order.save()

    pesanan_dibayar.send(sender=Order, order=order)
python
# orders/handlers.py
from django.dispatch import receiver

from .signals import pesanan_dibayar


@receiver(pesanan_dibayar)
def kirim_invoice(sender, order, **kwargs):
    # kirim email + generate PDF invoice di sini
    print(f"Invoice untuk pesanan #{order.id} terkirim")

Konvensinya, sender diisi class model yang relevan — di sini Order, bukan string. Receiver bisa menyaring sender dengan @receiver(pesanan_dibayar, sender=Order) kalau kamu ingin beberapa receiver menangani sender yang berbeda-beda.

Offload Pekerjaan Berat ke Celery

Receiver berjalan sinkron, di thread yang sama dengan pemanggilnya. Kirim email langsung di dalam receiver dan halaman jadi ikut lambat kalau SMTP lelet. Simpan logika berat ke task queue, lalu tinggal panggil .delay() dari receiver. Pola ini cocok banget dengan Celery — cara setup Celery bisa kamu baca di sini.

Menguji Signal

Signal ikut terpanggil di test karena ready() juga jalan saat test runner memuat app. Tesnya sederhana: buat user, cek profil muncul. Kalau belum terbiasa menulis test Django, mulai dari panduan pytest ini.

python
# accounts/tests.py
from django.contrib.auth import get_user_model
from django.test import TestCase

from .models import Profile

User = get_user_model()


class ProfileSignalTest(TestCase):
    def test_profil_dibuat_otomatis(self):
        user = User.objects.create_user(username="budi", password="rahasia123")
        self.assertTrue(Profile.objects.filter(user=user).exists())

Kapan Jangan Pakai Signal

Signal nyaman tapi gampang disalahgunakan. Kalau suatu logika hanya dipanggil dari satu tempat — satu view, satu form — taruh saja di sana secara eksplisit. Signal menyembunyikan alur kontrol; orang yang baru baca kode akan bingung kenapa tiba-tiba ada profil baru padahal view-nya cuma memanggil create_user(). Pakai signal untuk efek lintas-aplikasi yang harus selalu terjadi (audit log, cache invalidation, profil default), bukan sebagai pengganti pemanggilan fungsi biasa.

Mulai dari satu kasus nyata di proyekmu: temukan satu hal yang sekarang kamu duplikasi di beberapa tempat, pindahkan ke receiver post_save atau custom signal, dan daftarkan lewat ready(). Setelah itu buka shell dan jalankan User.objects.create_user(...) — profilnya harus muncul sendiri tanpa kamu sentuh view sama sekali.