Most applications with soft deletes have a deleted_at column.
It answers one question: "should this row be invisible to normal queries?"
The moment you start reading it back, branching on it, or reversing it,
you've turned an infrastructure concern into a domain concept without giving it a proper name.
Consider a support ticket that goes through:
opened → processing → resolved → closed
Each transition has meaning: who changed it, when, and why.
Now imagine you drop closed from the enum and use deleted_at instead.
You've introduced a fundamental asymmetry:
opened, processing, resolved live in a status column with full historyclosed lives in a timestamp, with no actor, no reason, no audit trail
Worse, deleted_at IS NOT NULL doesn't tell you why. Was the ticket
closed by a user? Auto-closed after 30 days? Closed because the customer
left? Cancelled by an admin? A single nullable timestamp absorbs all those
meanings and erases the distinctions.
WHERE status = 'closed' is self-documenting.
WHERE deleted_at IS NOT NULL requires you to know that deletion
means closure in this specific table.
deleted_at = NULL,
"never closed" and "closed then reopened" become indistinguishable.
The history is gone.
You track ticket history as JSONB, appending an entry on every
transition. But closed isn't in the enum — it's deleted_at.
So the history gets short-circuited:
func GetTicketSummary(ticket Ticket) Summary {
// "deleted" means "closed" here. probably.
if ticket.DeletedAt.Valid {
return Summary{Status: "closed"}
}
// parse the actual history to figure out real state
var history []HistoryEntry
json.Unmarshal(ticket.History, &history)
last := history[len(history)-1]
return Summary{
Status: last.Action,
ChangedBy: last.By,
ChangedAt: last.At,
}
}
Every status except closed has an actor, a precise timestamp, and a reason
in the history. closed gets none of that — just a nullable column that
was meant for soft deletes. And if someone reopens the ticket by setting
deleted_at = NULL, the fact that it was ever closed disappears entirely.
Same ticket, but closed is a proper status and every transition
appends to the history:
type Ticket struct {
Status string // "opened", "in_progress", "resolved", "closed"
History []byte // [{action, by, at, reason}, ...]
}
func UpdateTicketStatus(ctx context.Context, ticketID uuid.UUID, status string, entry HistoryEntry) {
entryJSON, _ := json.Marshal(entry)
query := `UPDATE tickets
SET status = $2,
history = history || $3::jsonb
WHERE uuid = $1`
db.Exec(ctx, query, ticketID, status, entryJSON)
}
Closing a ticket is just another call to UpdateTicketStatus.
Who closed it, when, and why — all recorded. Reopening appends a new
entry; the closure is still in the history. Every state gets the same
treatment.
When deleted_at lives alongside a status enum,
branching logic starts checking it as a precondition and
the status enum silently stops being the source of truth:
func HandleTicket(ticket Ticket) error {
// quick exit: "deleted" means "closed"
if ticket.DeletedAt.Valid {
return ErrTicketClosed
}
switch ticket.Status {
case "opened":
return assignToAgent(ticket)
case "in_progress":
return checkProgress(ticket)
case "resolved":
return notifyCustomer(ticket)
}
return nil
}
closed isn't in the switch. It's handled before
the switch even runs. A new developer reading the status enum will
never see it. A query filtering by status will miss it.
The state machine has a phantom state that only exists as a timestamp on a different column.
func HandleTicket(ticket Ticket) error {
switch ticket.Status {
case "opened":
return assignToAgent(ticket)
case "in_progress":
return checkProgress(ticket)
case "resolved":
return notifyCustomer(ticket)
case "closed":
return ErrTicketClosed
}
return nil
}
All states visible. All states queryable. deleted_at goes back to its
one job: making the row invisible.
deleted_at should answer exactly one question: "should this row
appear in normal queries?" If you find yourself checking
deleted_at IS NOT NULL as a business condition, you've
outgrown a boolean and need a proper status.