Soft Deletes Done Right — Three Strategies for Go

Soft deletes seem simple until you hit NOT NULL constraints or need Laravel compatibility. Neat ORM supports three strategies: nullable column, Laravel-compatible deleted_at, and max-date sentinel. Here's when to use each.

Soft Deletes Done Right — Three Strategies for Go

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:

  1. Standard — the default, for new projects
  2. Laravel-compatible — for migration, because renaming columns in production is painful
  3. 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.

Previous
Building 7 Database Drivers — The Compatibility Illusion and Its Discontents
Next
Associations and Eager Loading in Go — Without GORM

Keep Reading

Explore more articles and insights

Ork: SSH Server Automation in Go

Ork: SSH Server Automation in Go

Read Article
Deno Deploy Killed My Start Page

Deno Deploy Killed My Start Page

Read Article
Back to Golang: Why I Switched From Static to Dynamic Again

Back to Golang: Why I Switched From Static to Dynamic Again

Read Article
View All Blog Posts