The order that vanished
An e-commerce app processes order #8842. The customer cancels it an hour later. You run DELETE FROM orders WHERE id = 8842. The row disappears. Three days later, the accounting script crashes because it references order #8842 in a reconciliation report. A support agent opens a ticket: "Customer says they paid, but I can't find the order." The data is gone. You have no audit trail. You have no way to restore the order if it was a mistake.
Hard deletes destroy history. Soft deletes preserve it. You add a deleted_at column to the table. When a record is "deleted," you update that column with the current timestamp. Queries filter out rows where deleted_at is set. The row stays in the database, marked as inactive. The order still exists. Accounting can query it. Support can see it. The application just treats it as invisible.
Soft delete is a filter, not a feature
Soft delete is a pattern, not a database superpower. The database doesn't know about soft deletes. You are implementing a convention: every query that reads data must exclude rows marked as deleted. If you forget the filter, the deleted data leaks back into the UI.
Think of it like a library. Hard delete is shredding the book. Soft delete is stamping "WITHDRAWN" on the cover and moving it to a back room. The book still exists. Staff can find it. Patrons just don't see it on the shelf.
The implementation boils down to two rules:
- Writes that delete a record update a timestamp column instead of removing the row.
- Reads add a condition to ignore rows with a non-null timestamp.
Go code enforces this pattern through struct definitions and query builders. The type system helps you remember the field. The query builder helps you remember the filter.
The GORM way
GORM is the most common ORM in the Go ecosystem. It has built-in support for soft deletes. You add a field of type gorm.DeletedAt to your struct. GORM handles the rest.
Here's the struct that enables soft deletes with zero extra query logic.
package main
import (
"gorm.io/gorm"
)
// User represents a user in the system.
// The DeletedAt field enables GORM's soft delete behavior.
type User struct {
ID uint
Name string
Email string
DeletedAt gorm.DeletedAt // GORM treats this as the soft delete marker
}
// SoftDelete marks the user as deleted.
// GORM generates an UPDATE query instead of DELETE.
func (u *User) SoftDelete(db *gorm.DB) error {
return db.Delete(u).Error // Updates deleted_at to current time
}
gorm.DeletedAt is a type alias for *time.Time. The pointer matters. A time.Time value has a zero value of 0001-01-01, which would look like a deletion timestamp. A pointer can be nil, which maps to NULL in the database. NULL means the record is active. Any other value means it's deleted.
GORM automatically adds WHERE deleted_at IS NULL to every query. You don't write the filter. The ORM injects it.
What happens under the hood
When you call db.Delete(&user), GORM doesn't send a DELETE statement. It sends an UPDATE.
The generated SQL looks like UPDATE users SET deleted_at = NOW() WHERE id = ?. The row stays in the table. The timestamp gets set.
When you call db.First(&user, id), GORM generates SELECT * FROM users WHERE id = ? AND deleted_at IS NULL. If the row has a timestamp, the query returns no results. The application sees the user as gone.
This behavior is consistent across all GORM methods. Find, Where, Joins all respect the soft delete filter. The filter is part of the query scope.
Convention aside: receiver names in Go are usually one or two letters matching the type. (u *User) SoftDelete follows the convention. (this *User) or (self *User) breaks the style guide. Keep receivers short.
Error handling follows the standard pattern. if err != nil { return err }. The boilerplate is verbose by design. It makes the failure path visible. Don't hide errors. Return them immediately.
Realistic handler
In a real application, soft deletes happen inside HTTP handlers. You extract the ID, call the delete logic, and return a status code. Context flows through the call chain.
Here's a handler that soft deletes a user and returns the right status code.
package main
import (
"net/http"
"strconv"
"gorm.io/gorm"
)
// DeleteUser handles soft deletion of a user.
// It returns 204 on success or 404 if the user is not found.
func DeleteUser(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Extract ID from the URL path
idStr := r.PathValue("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
// Attempt to delete the user
// GORM returns ErrRecordNotFound if the row is already soft deleted
result := db.Delete(&User{}, id)
if result.Error != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
// Check if any row was affected
if result.RowsAffected == 0 {
http.Error(w, "user not found", http.StatusNotFound)
return
}
// Return 204 No Content on success
w.WriteHeader(http.StatusNoContent)
}
}
The handler checks RowsAffected. If the user was already soft deleted, db.Delete returns zero rows affected because the WHERE deleted_at IS NULL clause filters out the row. The delete becomes a no-op. This is safe. Calling delete twice doesn't break anything.
Convention aside: context.Context always goes as the first parameter in functions that perform I/O. Handlers receive the context from the request via r.Context(). Pass it to database calls if your library supports it. GORM doesn't require context in the API, but the underlying driver respects request cancellation.
The unique constraint trap
Soft deletes introduce a subtle problem with unique constraints. Suppose the users table has a unique index on email. You soft delete a user with alice@example.com. The row stays in the table with deleted_at set. You try to create a new user with alice@example.com. The insert fails.
The database sees a duplicate key. The unique constraint doesn't know about soft deletes. It sees two rows with the same email. One is active, one is deleted. The constraint fires anyway.
The compiler or driver rejects the insert with an error like pq: duplicate key value violates unique constraint "users_email_key". The application crashes or returns a 500 error. The user can't re-register.
The fix is a composite unique index. You index (email, deleted_at). The constraint allows duplicate emails as long as the deleted_at values differ. One row has NULL, the other has a timestamp. The combination is unique.
In GORM, you define the index in the struct tag or migration.
type User struct {
ID uint
Email string `gorm:"uniqueIndex:idx_users_email_deleted_at,composite"`
DeletedAt gorm.DeletedAt `gorm:"uniqueIndex:idx_users_email_deleted_at,composite"`
}
The composite index solves the problem. New users can reuse emails from deleted accounts. The database enforces uniqueness correctly.
Soft deletes are just updates. Treat them like any other mutation. Index the columns you query. Composite indexes are your friend when business logic conflicts with schema constraints.
Recovering data
Sometimes a user deletes their account by mistake. Or an admin accidentally wipes a record. Soft deletes make recovery possible. You need to bypass the filter and restore the row.
GORM provides Unscoped to ignore the soft delete filter. You can query deleted records and update the timestamp back to NULL.
Here's how to recover a soft-deleted record when a user changes their mind.
package main
import (
"gorm.io/gorm"
)
// RecoverUser restores a soft-deleted user.
// It returns an error if the user doesn't exist or is already active.
func RecoverUser(db *gorm.DB, id uint) error {
// Unscoped bypasses the automatic soft delete filter
// This allows querying rows where deleted_at is not NULL
result := db.Unscoped().Where("id = ? AND deleted_at IS NOT NULL", id).
Model(&User{}).
Update("deleted_at", nil)
// Check for database errors
if result.Error != nil {
return result.Error
}
// Verify a row was actually updated
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
Unscoped removes the WHERE deleted_at IS NULL clause. The query finds the deleted row. The Update sets deleted_at to nil, which maps to NULL. The row becomes active again.
Use Unscoped carefully. It exposes deleted data. Only use it in admin endpoints or recovery flows. Never use it in public queries. Deleted data might contain sensitive information.
Recovery requires bypassing the filter. Use Unscoped with caution. Audit who calls it.
Pitfalls and edge cases
Soft deletes work well until you hit edge cases. Here are the common traps.
Custom raw SQL queries don't respect GORM's soft delete filter. If you write db.Raw("SELECT * FROM users WHERE name = ?", name), GORM doesn't inject the deleted_at clause. Deleted rows leak into the result. Always add the filter manually in raw queries, or use GORM's typed methods.
Large tables with many soft-deleted rows slow down queries. The database scans all rows, including deleted ones, unless you index deleted_at. Add an index on deleted_at to speed up filters. If the table grows huge, consider archiving old deleted rows to a separate table.
Foreign keys can break. If another table references the deleted row with a foreign key, the reference stays valid. The parent row exists. If you hard delete the parent, the foreign key constraint might prevent deletion or cascade the delete. Soft deletes avoid cascade issues because the row never disappears. References remain valid.
Boolean flags vs timestamps. Some implementations use a boolean is_deleted column instead of a timestamp. A boolean is simpler. A timestamp gives you audit data. You can see when the deletion happened. Timestamps are better for compliance and debugging. Use deleted_at, not is_deleted.
Soft deletes hide data. They don't erase it. Audit logs still see everything. Ensure your backup strategy accounts for soft-deleted rows.
When to use what
Pick the right deletion strategy based on your requirements. Not every table needs soft deletes.
Use a soft delete with a timestamp when you need to recover data or audit when records were removed. Use a hard delete when data contains PII that must be destroyed for GDPR compliance or when storage costs outweigh recovery value. Use a boolean flag when you only care about active vs inactive state and don't need the deletion time. Use a separate archive table when deleted records grow too large and slow down the main table. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.
Unique constraints don't care about your business logic. Add the composite index or the insert fails. Context is plumbing. Run it through every long-lived call site.