Langsung ke konten
Soft Delete vs Hard Delete: Saya Kena Masalah di Kedua-Duanya, Ini Cara Saya Memilih Sekarang
Kembali ke Blog
Database·6 menit baca·

Soft Delete vs Hard Delete: Saya Kena Masalah di Kedua-Duanya, Ini Cara Saya Memilih Sekarang

Soft delete terdengar aman tapi bisa meracuni query dan constraint integrity data. Saya bagikan pola implementasi soft delete di PostgreSQL yang terbukti aman di production, plus kapan hard delete tetap pilihan tepat.

TH
Tomi Hartanto·Senior Software Engineer

Minggu lali, tim saya menghapus data customer yang salah — salah satu tim marketing menghapus 2.300+ record kontak lewat bulk action di admin panel. Untungnya, tabel itu pakai soft delete, jadi restore-nya cuma urusan satu UPDATE. Tapi cerita tidak selesai di situ: dua jam kemudian, tim support lapor ada customer "hilang" dari hasil pencarian internal. Ternyata query mereka tidak memfilter deleted_at IS NULL di satu JOIN. Inilah sumber chaos soft delete: solusi masalahnya sendiri jadi sumber bug kalau tidak ditangani dengan disiplin.

Saya sudah kena masalah di kedua kubu. Hard delete pernah bikin saya harus restore dari backup semalaman (dan kehilangan data transaksi 6 jam terakhir). Soft delete pernah bikin unique constraint tidak jalan dan tabel bengkak 40 GB penuh baris sampah. Jadi artikel ini bukan advokasi satu sisi — ini catatan bagaimana saya sekarang memutuskan, dan pola implementasi konkret yang saya pakai.

Masalah Inti dari Soft Delete

Soft delete artinya kamu tidak benar-benar menghapus baris, cuma menandainya — biasanya kolom deleted_at TIMESTAMPTZ. Query aplikasi lalu harus memfilter baris yang bertanda itu. Kedengarannya sederhana, tapi ada tiga jebakan yang hampir semua orang (termasuk saya) tabrak:

1. Filter deleted_at IS NULL yang selalu bocor

Ini masalah paling sering saya lihat di code review. ORM seperti Prisma atau Eloquent bisa membantu dengan global scope atau middleware, tapi begitu ada query raw SQL, laporan BI, atau JOIN yang ditulis manual, filter itu hilang satu-satu. Bug-nya senyap: data "muncul lagi" di laporan, atau lebih buruk, data terhapus masih bisa diedit lewat endpoint yang kelupaan filter.

Pola yang sekarang saya pakai: semua akses data lewat satu layer repository/query builder yang memfilter deleted_at secara default. Query yang benar-benar butuh data terhapus harus eksplisit withDeleted() — bukan sebaliknya.

2. Unique constraint yang rusak

Ini jebakan paling menyakitkan. Kalau tabel users punya UNIQUE(email) dan user di-soft delete, user baru dengan email sama tidak bisa didaftarkan — constraint tetap menganggap email itu sudah dipakai baris mati. Diskusi di atlas9.dev tentang tantangan soft delete membahas ini panjang, dan saya setuju sama sekali: ini adalah bug desain yang paling sering diabaikan saat orang asal menambah kolom deleted_at.

Solusinya di PostgreSQL: partial unique index.

CREATE UNIQUE INDEX users_email_active_uidx
ON users (email)
WHERE deleted_at IS NULL;

Dengan ini, email boleh dipakai ulang selama baris lamanya sudah ter-soft delete. Konsekuensinya: kalau user lama di-restore sementara email sudah dipakai orang baru, restore harus menangani konflik — jadi restore bukan sekadar UPDATE deleted_at = NULL.

3. Tabel yang membengkak

Baris ter-soft delete tetap menempati storage, ikut discan (kecuali dikecualikan di index, lagi-lagi partial index), dan menumpuk selama bertahun-tahun. Di satu proyek SaaS multi-tenant yang saya kerjakan, tabel events tumbuh sampai sebagian besar isinya baris terhapus — query analytics makin lambat padahal data "aktif"-nya cuma sedikit.

Untuk tabel besar, saya selalu gabungkan soft delete dengan purge job terjadwal: baris yang ter-soft delete lebih dari 90 hari (atau sesuai retensi kontraktual tenant) di-hard delete oleh cron. Kamu butuh index di deleted_at supaya job ini tidak jadi full table scan — dan kalau mau tahu kapan index benar-benar membantu versus cuma membebani, saya pernah bahas di artikel indexing PostgreSQL saya.

Diagram tabel database dengan baris aktif dan baris bertanda deleted_at, sebagian baris terhapus diangkut oleh purge job ke storage terpisah

Alternatif yang Saya Pakai untuk Tabel Besar: Tabel Sampah Terpisah

Untuk tabel yang benar-benar besar dan relasi-nya kompleks, saya tidak lagi menaruh deleted_at di tabel utama. Pola yang saya adopsi terinspirasi dari pola deleted_record_insert yang ditulis brandur: sebelum hard delete, salin barisnya ke tabel deleted_<nama_tabel> (atau tablespace/S3 kalau volumenya besar), baru hapus dari tabel utama.

Keuntungannya nyata:

  • Tabel utama tetap kecil dan bersih — tidak ada partial index, tidak ada filter deleted_at di semua query.
  • Restore = INSERT ... SELECT dari tabel sampah.
  • Retensi jadi eksplisit: purge tabel sampah itu murah dan tidak menyentuh tabel produksi.

Kerugiannya juga jujur saya akui: restore lebih lambat, dan kamu harus menyimpan data relasi terkait (misalnya snapshot child rows) kalau ingin restore yang konsisten. Untuk tabel kecil dengan relasi sederhana, pola ini overkill — deleted_at biasa cukup.

Ilustrasi proses delete dua langkah: baris disalin ke tabel deleted_records terlebih dahulu lalu dihapus dari tabel utama

Kapan Saya Tetap Pilih Hard Delete

Ada kelas data yang menurut saya justru berbahaya kalau di-soft delete:

  • Data transient: session log, cache, notifikasi yang sudah terkirim. Menyimpannya dengan flag terhapus cuma menambah noise.
  • Data yang secara hukum harus dihapus: permintaan hapus akun (right to erasure) menuntut penghapusan nyata, bukan sekadar flag. Kalau kamu cuma menandainya, secara teknis data itu masih ada — dan itu bisa jadi masalah compliance.
  • Data dengan siklus hidup pendek yang restore-nya tidak masuk akal setelah beberapa hari.

Diskusi klasik di Stack Overflow menyoroti poin yang sama: jawabannya selalu "tergantung kebutuhan bisnis akan audit dan recovery", bukan "selalu soft delete".

Saya juga belajar dari implementasi soft delete di Ecto yang dibagikan Dashbit: bahkan tim yang sangat disiplin pun menganggap soft delete sebagai keputusan desain yang menular ke seluruh aplikasi — bukan fitur yang bisa ditambahkan asal tempel.

Checklist yang Saya Pakai Sebelum Menambah deleted_at

Setelah dua kali kena masalah ini, sekarang setiap kali ada usulan soft delete di tim, saya lempar checklist berikut:

  1. Siapa yang bisa restore, dan lewat jalur apa? Kalau tidak ada UI/proses restore, soft delete cuma jadi tempat sampah yang tidak pernah dikosongkan.
  2. Apakah semua unique constraint sudah jadi partial index dengan WHERE deleted_at IS NULL?
  3. Berapa lama retensinya, dan siapa yang purge? Tanpa jawaban ini, tabel akan bengkak diam-diam.
  4. Apakah query laporan/BI tahu soal flag ini? Kalau tim data pakai query raw, siapkan view yang sudah terfilter.
  5. Apakah FK ke tabel ter-soft delete masih valid secara semantik? Baris "hidup" yang mereferensi baris "mati" adalah resep bug yang sulit ditelusuri.

Yang terakhir ini sering nyambung dengan keputusan desain tabel lain — misalnya saat menentukan apakah relasi itu layak jadi tabel terpisah atau cukup denormalisasi, saya biasanya rujuk kembali pelajaran dari normalisasi vs denormalisasi dan memastikan migrasi kolomnya aman lewat pola expand and contract karena menambah deleted_at ke tabel besar tanpa downtime butuh pendekatan yang sama.

Takeaway

Soft delete bukan fitur gratis; itu komitmen arsitektural yang menular ke query, index, constraint, dan proses restore. Aturan praktis saya sekarang: soft delete hanya untuk data yang (a) punya nilai restore nyata bagi bisnis, (b) volumenya terkendali atau ada purge job, dan (c) tim sanggup menjaga disiplin filter di semua jalur akses data. Sisanya — hard delete, kalau perlu dengan tabel sampah terpisah untuk jendela restore terbatas. Dan kalau kamu sudah punya tabel besar dengan jutaan baris ter-soft delete yang tidak pernah dibersihkan: mulai purge sekarang, jangan tunggu sampai query analytics kamu yang memaksa.

Sumber

TH

Tomi Hartanto

Senior Software Engineer

Backend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.