deleted_at is not a status

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.

the asymmetry

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:

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.

what you lose

code: history with a deleted_at leak

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.

code: the clean version

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.

code: the switch leak

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.

the rule

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.

← back