Soft Deletes Done Right — Three Strategies for Go
Soft deletes are simple in concept and complex in implementation. The concept: mark records as deleted instead of removing them. The complexity: how you mark them determines your schema design, your query patterns, and your migration path.
Neat ORM provides three strategies. Each solves a specific problem.
The Core Idea
A soft delete preserves the record while hiding it from normal queries. This is useful for:
- Recovery: Undo accidental deletions
- Audit trails: Keep records for compliance
- Referential integrity: Don't break foreign keys
- Analytics: Track what was deleted and when
The question is: how do you mark a record as deleted?
Strategy 1: Standard (Nullable Column)
Add a nullable timestamp. Set it when deleted. Filter it in queries.
type User struct {
ID int64
Name string
SoftDeletedAt *time.Time
}
| Property | Value |
|---|---|
| Column | soft_deleted_at (nullable) |
| Active | IS NULL |
| Deleted | IS NOT NULL |
| Best for | New projects |
Simple. Works everywhere. No special database features needed.
The one downside: nullable columns. Some teams avoid them. If that's you, see Strategy 3.
Strategy 2: Laravel-Compatible (deleted_at)
Identical to Strategy 1, but uses deleted_at as the column name — matching Laravel's Eloquent convention.
type User struct {
ID int64
Name string
DeletedAt *time.Time
}
| Property | Value |
|---|---|
| Column | deleted_at (nullable) |
| Active | IS NULL |
| Deleted | IS NOT NULL |
| Best for | Migrating from Laravel |
If your database already has deleted_at columns from a Laravel project, this strategy lets you switch to Go without changing your schema.
Strategy 3: Max-Date Sentinel (NOT NULL)
For teams with a strict NOT NULL policy on every column. Uses a sentinel value instead of null.
type User struct {
ID int64
Name string
SoftDeletedAt time.Time // NOT NULL, default: 9999-12-31 23:59:59
}
| Property | Value |
|---|---|
| Column | soft_deleted_at (NOT NULL) |
| Active | > NOW() (sentinel is far future) |
| Deleted | <= NOW() |
| Best for | NOT NULL constraint policies |
When deleted, set the timestamp to now. When active, the sentinel (9999-12-31 23:59:59) is always in the future.
Bonus: new records automatically get the default value, so INSERTs don't need to specify the column.
One API, Three Strategies
Regardless of which strategy you choose, the API is identical:
// Soft delete
db.Query().Where("id", 1).SoftDelete(&user)
// Hard delete (permanent removal)
db.Query().Where("id", 1).HardDelete(&user)
// Include soft-deleted records
db.Query().WithSoftDeleted().Get(&allUsers)
// Query only soft-deleted records
db.Query().OnlySoftDeleted().Get(&deletedUsers)
// Restore
db.Query().Where("id", 1).Restore(&user)
You choose the strategy when defining your model. The query builder handles the rest — filtering, restoring, querying — automatically.
Naming Evolution
The method names evolved over development:
| Version | Soft Delete | Hard Delete |
|---|---|---|
| Initial | Delete() |
ForceDelete() |
| v0.33 (Jul 2026) | SoftDelete() |
HardDelete() |
The old names still work (with deprecation warnings). The new names are explicit — you know exactly what each method does without checking the docs.
Decision Matrix
| Your Situation | Strategy |
|---|---|
| New project, no constraints | Standard |
| Migrating from Laravel | Laravel-compatible |
| NOT NULL policy on all columns | Max-date sentinel |
| Unsure | Standard (simplest) |
Why Three Strategies Matter
A single-strategy ORM works until you hit a project where that strategy doesn't fit. Then you're fighting the ORM instead of using it.
Neat ORM supports three strategies because three real-world scenarios demanded them:
- Standard — the default, for new projects
- Laravel-compatible — for migration, because renaming columns in production is painful
- Max-date sentinel — for NOT NULL policies, because some teams don't allow nulls
One API. Three strategies. Pick the one that fits your project. The ORM handles the rest.





