
Soft Delete vs Hard Delete: Kapan Data Boleh Benar-benar Hilang dari Database
Soft delete vs hard delete: pengalaman saya mendesain data yang bisa di-restore di SaaS multi-tenant, lengkap dengan skema tabel, partial index, dan jebakannya.
Minggu lalu seorang customer kami menghapus salah satu workspace di produksi — salah klik, katanya. Tim support datang ke saya dengan pertanyaan sederhana: "bisa di-restore?" Jawabannya tergantung tabel mana yang dia hapus, dan itu persis masalahnya. Sebagian tabel kami pakai soft delete, sebagian lagi benar-benar DELETE. Keputusan yang saya ambil tiga tahun lalu secara ad-hoc, tanpa kebijakan yang jelas, akhirnya menagih janji.
Artikel ini adalah rangkuman bagaimana saya sekarang memutuskan kapan data boleh di-hard delete dan kapan harus soft delete — bukan teori, tapi aturan main yang saya tulis setelah kena dua insiden.
Kenapa Soft Delete Itu Tidak Sesederhana Kelihatannya
Ide dasarnya memang sederhana: tambah kolom deleted_at, dan semua query menyaring WHERE deleted_at IS NULL. Tapi begitu masuk ke praktik, masalahnya bertumpuk:
- Setiap query di aplikasi harus ingat menyaring
deleted_at. Lupa satu saja, data "terhapus" muncul lagi di laporan. - Unique constraint jadi rumit. Email
user@example.comyang sudah di-soft delete akan blokir pendaftaran ulang kalau constraint biasa. - Index membengkak karena baris "mati" tetap menempel di tabel.
- Tabel besar dengan jutaan baris ter-soft delete memperlambat query — dan di situlah kamu perlu membaca EXPLAIN ANALYZE untuk tahu index scan-mu masih sehat atau tidak.

Diskusi di komunitas backend soal ini cukup panas — tulisan The challenges of soft delete yang mengangkat masalah-masalah ini mendapat 267 poin di Hacker News dengan 151 komentar, mayoritas berbagi pengalaman kena masalah yang sama. Jadi kalau kamu merasa soft delete "menyusahkan", kamu tidak sendirian.
Pola yang Saya Pakai Sekarang di PostgreSQL
Setelah dua insiden (restore data via backup semalaman, dan bug laporan yang menampilkan data terhapus selama dua minggu), saya berhenti mencampur aduk. Ini kebijakannya:
1. Soft delete hanya untuk data yang dimiliki tenant
workspaces, documents, api_keys — semua yang muncul di UI dan bisa dihapus user. Ini skema standar saya:
ALTER TABLE documents ADD COLUMN deleted_at timestamptz;
CREATE INDEX idx_documents_active
ON documents (workspace_id, updated_at)
WHERE deleted_at IS NULL;
Dua hal penting di sini. Pertama, partial index — index hanya menjangkau baris aktif, jadi walau ada 2 juta dokumen ter-soft delete, query listing tenant tetap cepat. Kedua, nama indexnya eksplisit (_active), supaya reviewer langsung tahu query mana yang seharusnya memakainya.
2. Unique constraint dengan pengecualian
Masalah klasik: user menghapus akun lalu mau daftar ulang dengan email sama. Solusi di PostgreSQL:
CREATE UNIQUE INDEX idx_users_email_active
ON users (email)
WHERE deleted_at IS NULL;
Email unik hanya di antara user aktif. Baris ter-soft delete tidak menghalangi.
3. Hard delete untuk data turunan yang tidak dimiliki langsung
Log verifikasi, session token kedaluwarsa, job queue yang selesai — ini saya hard delete dengan batch terjadwal. Tidak ada alasan bisnis untuk me-restore session token 6 bulan lalu, dan menahannya di tabel hanya buang-buang storage.
Alternatif yang Saya Pertimbangkan Serius: Tabel Sampah Terpisah
Pendekatan lain yang dibahas brandur.org di fragmen tentang deleted_record_insert — yang juga ramai dibahas di Hacker News (275 poin) — adalah memindahkan baris ke tabel sampah terpisah, bukan menandainya di tempat. Saya sudah mencoba pola ini di satu service dan hasilnya campuran:
Kelebihannya: tabel utama tetap bersih, tidak ada WHERE deleted_at IS NULL di mana-mana, unique constraint tidak perlu trik partial index. Query yang lupa menyaring? Tidak mungkin, karena datanya memang tidak ada di tabel utama.
Kekurangannya: operasi delete jadi dua langkah (INSERT ke tabel sampah + DELETE dari tabel utama) yang harus atomik dalam satu transaksi, dan restore butuh mapping kolom yang tetap sinkron ketika skema tabel utama berubah. Migration yang menambah kolom di tabel utama harus ikut mengubah tabel sampah — ini yang paling sering kelewat di code review.
Di project kecil saya tetap pilih deleted_at karena lebih murah di-maintain. Untuk tabel ratusan juta baris, tabel sampah terpisah mulai masuk akal.
Kalau Sudah Terlanjur: Restore dari Backup
Bagian yang tidak suka dibicarakan orang: soft delete bukan pengganti backup. Kasus terburuk yang saya alami adalah bug yang justru ikut menandai deleted_at pada baris yang tidak seharusnya — soft delete-nya "berfungsi", datanya "ada", tapi secara logis sudah rusak. Situasi seperti ini, atau kehilangan data fisik, hanya bisa ditolong oleh backup dan point-in-time recovery. Saya pribadi menganggap kombinasi keduanya wajib, bukan pilihan salah satu.
Untuk kepatuhan dan forensik, soft delete saja juga tidak cukup. Saya tetap butuh audit log yang mencatat siapa menghapus apa dan kapan — kolom deleted_at tidak menyimpan siapa pelakunya, dan itu informasi pertama yang ditanya customer saat komplain.

Pertanyaan yang Saya Ajukan Sebelum Menambah deleted_at
Ini checklist yang sekarang saya pakai di code review, diadaptasi dari diskusi klasik di Stack Overflow tentang physical vs logical delete ditambah pengalaman sendiri:
- Apakah user tahu data ini ada dan secara eksplisit menghapusnya? Ya → soft delete (atau trash bin dengan auto-purge 30 hari).
- Apakah ada kebutuhan compliance untuk retensi data? Kalau ada (misal invoice untuk pajak), data tidak boleh benar-benar hilang — soft delete atau arsip.
- Apakah tabel ini akan besar? Kalau ya, hitung dulu dampak partial index dan rencanakan job purge untuk baris ter-soft delete yang sudah lewat masa retensinya. Soft delete tanpa purge adalah utang teknis yang berbunga.
- Apakah data ini turunan/derived? Ya → hard delete. Bisa selalu dibuat ulang.
Poin 3 yang paling sering diabaikan. Saya pernah menemukan tabel dengan 40% baris berstatus ter-soft delete lebih dari dua tahun, tidak pernah di-purge, karena tidak ada yang kepikiran dari awal. Diskusi di Dashbit tentang soft delete dengan Ecto juga menyoroti hal serupa: abstraksi ORM bisa menyembunyikan query WHERE deleted_at IS NULL dari developer, sampai performa turun dan baru disadari.
Takeaway Praktis
Aturan final yang saya pin di repo tim:
- Data yang dihapus user → soft delete + partial index + auto-purge sesuai retensi (default 90 hari).
- Data turunan/derived → hard delete via cron batch.
- Soft delete + audit log + backup adalah tiga lapis pertahanan yang berbeda — jangan dianggap saling menggantikan.
- Setiap tabel baru wajib menjawab checklist 4 pertanyaan di atas sebelum schema review disetujui.
Kalau kamu sedang mendesain ulang kebijakan delete di sistem yang sudah jalan, mulai dari tabel yang paling sering dihapus usernya, bukan dari tabel terbesar. Dampaknya ke user paling terasa, risiko migration paling kecil, dan kamu dapat ujian nyata untuk kebijakannya sebelum diterapkan ke data yang lebih sensitif.
Tomi Hartanto
Senior Software Engineer
Backend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.
Tomi Hartanto
Senior Software EngineerBackend developer dengan 8+ tahun pengalaman membangun sistem scalable. Menulis tentang arsitektur backend, database, dan DevOps.


