Ent Mixins for Timestamps Soft Deletes and Shared Fields
Build Ent mixins for created_at and updated_at and a soft delete that uses hooks and interceptors, then run a delete, a query and a real purge on PostgreSQL

Most tables share columns. Every entity wants created_at and updated_at, many want a soft delete marker, and multi tenant systems want a tenant_id on everything. Copying those fields into every schema file is how they drift. Ent's answer is the mixin: a type that contributes fields, edges, indexes, hooks and interceptors to any entity that lists it. This article builds two, a time mixin and a soft delete mixin, and runs them on PostgreSQL 18.6. The second shows the full power of the idea, because it changes what delete and query mean for every entity that uses it.
Tested with Go 1.25.6, Ent v0.14.5, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It follows the schema article and uses hooks as introduced in transactions and hooks. The GORM version of soft delete, with its traps, is in GORM soft deletes. For tenant isolation built from the same parts, see Ent multi tenancy.
A mixin is a schema fragment
A mixin embeds mixin.Schema and overrides what it contributes: Fields(), Edges(), Indexes(), Hooks(), Interceptors(), Policy() and Annotations(). An entity opts in through its Mixin() method:
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
"example.test/lab/ent/schema/mixin"
)
type Note struct{ ent.Schema }
func (Note) Mixin() []ent.Mixin {
return []ent.Mixin{mixin.TimeMixin{}, mixin.SoftDeleteMixin{}}
}
func (Note) Fields() []ent.Field {
return []ent.Field{field.String("body").NotEmpty()}
}The order of mixins is the order of their fields in the table, and they come before the entity's own fields. The generated Note has created_at, updated_at, delete_time and body.
The time mixin
package mixin
import (
"time"
"entgo.io/ent"
"entgo.io/ent/schema/field"
entmixin "entgo.io/ent/schema/mixin"
)
// TimeMixin adds created_at and updated_at to any entity that lists it.
type TimeMixin struct{ entmixin.Schema }
func (TimeMixin) Fields() []ent.Field {
return []ent.Field{
field.Time("created_at").Default(time.Now).Immutable(),
field.Time("updated_at").Default(time.Now).UpdateDefault(time.Now),
}
}created_at defaults to time.Now and is Immutable(), so the update builder has no setter for it. updated_at also defaults to time.Now and adds UpdateDefault(time.Now), which makes Ent set it on every update without the caller doing anything. The lab shows both behaving: created_at set: true | updated_at set: true, and after an update of the body updated_at moved: true | created_at unchanged: true.
Because the default is applied in Go, a row updated through raw SQL does not move updated_at. If the timestamp has to be right for every writer, add a database trigger as well.
The soft delete mixin
The fields are one line (delete_time, optional, so NULL means alive):
package mixin
import (
"entgo.io/ent"
"entgo.io/ent/schema/field"
entmixin "entgo.io/ent/schema/mixin"
)
// SoftDeleteMixin adds delete_time. Its hooks and interceptors are in softdelete_rules.go.
type SoftDeleteMixin struct{ entmixin.Schema }
func (SoftDeleteMixin) Fields() []ent.Field {
return []ent.Field{field.Time("delete_time").Optional()}
}The behaviour lives in two methods that need the generated hook and intercept packages. They are in a second file, which the script adds after the first generation:
package mixin
import (
"context"
"fmt"
"time"
"entgo.io/ent"
"entgo.io/ent/dialect/sql"
gen "example.test/lab/ent"
"example.test/lab/ent/hook"
"example.test/lab/ent/intercept"
)
type skipKey struct{}
// WithDeleted lets one call see and really delete soft deleted rows.
func WithDeleted(ctx context.Context) context.Context { return context.WithValue(ctx, skipKey{}, true) }
func (d SoftDeleteMixin) P(w interface{ WhereP(...func(*sql.Selector)) }) {
w.WhereP(sql.FieldIsNull("delete_time"))
}
// Interceptors hide deleted rows from every query.
func (d SoftDeleteMixin) Interceptors() []ent.Interceptor {
return []ent.Interceptor{
intercept.TraverseFunc(func(ctx context.Context, q intercept.Query) error {
if skip, _ := ctx.Value(skipKey{}).(bool); skip {
return nil
}
d.P(q)
return nil
}),
}
}
// Hooks turn a delete into an update of delete_time.
func (d SoftDeleteMixin) Hooks() []ent.Hook {
return []ent.Hook{
hook.On(func(next ent.Mutator) ent.Mutator {
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
if skip, _ := ctx.Value(skipKey{}).(bool); skip {
return next.Mutate(ctx, m)
}
mx, ok := m.(interface {
SetOp(ent.Op)
Client() *gen.Client
SetDeleteTime(time.Time)
WhereP(...func(*sql.Selector))
})
if !ok {
return nil, fmt.Errorf("unexpected mutation type %T", m)
}
d.P(mx)
mx.SetOp(ent.OpUpdate)
mx.SetDeleteTime(time.Now())
return mx.Client().Mutate(ctx, m)
})
}, ent.OpDeleteOne|ent.OpDelete),
}
}Reading it:
Interceptors()returns a traversal interceptor, which runs for every query on every entity that uses the mixin. It addsWHERE delete_time IS NULLthroughd.P(q)unless the context says to skip.Hooks()returns a hook that runs forOpDeleteandOpDeleteOne. It checks the context, casts the mutation to an interface that every generated mutation with the field satisfies, adds the same predicate, changes the operation to an update withSetOp(ent.OpUpdate), setsdelete_timeto now and hands the mutation back to the client. The delete becomes an update.WithDeleted(ctx)marks a context to bypass both.
The program
package main
import (
"context"
"fmt"
"time"
"example.test/lab/ent/note"
_ "example.test/lab/ent/runtime"
"example.test/lab/ent/schema/mixin"
)
func main() {
ctx := context.Background()
client := open("l0747")
defer client.Close()
must(client.Schema.Create(ctx))
// 1. TimeMixin: both timestamps are filled in.
n := client.Note.Create().SetBody("first").SaveX(ctx)
fmt.Println("created_at set:", !n.CreatedAt.IsZero(), "| updated_at set:", !n.UpdatedAt.IsZero())
// 2. updated_at moves on update, created_at does not (it is immutable).
time.Sleep(20 * time.Millisecond)
n2 := n.Update().SetBody("edited").SaveX(ctx)
fmt.Println("updated_at moved:", n2.UpdatedAt.After(n.UpdatedAt), "| created_at unchanged:", n2.CreatedAt.Equal(n.CreatedAt))
// 3. SoftDeleteMixin: Delete becomes an update, and queries hide the row.
other := client.Note.Create().SetBody("second").SaveX(ctx)
client.Note.DeleteOne(n2).ExecX(ctx)
fmt.Println("visible after delete:", client.Note.Query().CountX(ctx))
_, err := client.Note.Get(ctx, n2.ID)
fmt.Println("Get on a deleted note is NotFound:", err != nil)
// 4. The row is still there, and an explicit context sees it.
all := client.Note.Query().CountX(mixin.WithDeleted(ctx))
fmt.Println("rows including deleted:", all)
gone := client.Note.Query().Where(note.DeleteTimeNotNil()).CountX(mixin.WithDeleted(ctx))
fmt.Println("rows with delete_time set:", gone)
// 5. Bulk deletes are soft too.
client.Note.Delete().Where(note.BodyContains("second")).ExecX(ctx)
fmt.Println("visible after bulk delete:", client.Note.Query().CountX(ctx), "| still stored:", client.Note.Query().CountX(mixin.WithDeleted(ctx)))
_ = other
// 6. A real delete needs the explicit context.
client.Note.Delete().ExecX(mixin.WithDeleted(ctx))
fmt.Println("after a real delete:", client.Note.Query().CountX(mixin.WithDeleted(ctx)))
}package main
import (
"database/sql"
"log"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"example.test/lab/ent"
_ "github.com/jackc/pgx/v5/stdlib"
)
const base = "postgres://postgres:[email protected]:55432/lab?sslmode=disable"
// open recreates a schema and returns a client whose connections use it.
func open(schema string, opts ...ent.Option) *ent.Client {
admin, err := sql.Open("pgx", base)
if err != nil {
log.Fatal(err)
}
admin.Exec("drop schema if exists " + schema + " cascade")
admin.Exec("create schema " + schema)
admin.Close()
db, err := sql.Open("pgx", base+"&search_path="+schema)
if err != nil {
log.Fatal(err)
}
drv := entsql.OpenDB(dialect.Postgres, db)
return ent.NewClient(append([]ent.Option{ent.Driver(drv)}, opts...)...)
}
func must(err error) {
if err != nil {
log.SetFlags(0)
log.Fatal(err)
}
}package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature intercept,schema/snapshot ./schemaset -e
GOFLAGS=-mod=mod go generate ./ent
# The mixin rules import the generated hook and intercept packages, so they are added after the first generation.
mv ent/schema/mixin/softdelete_rules.txt ent/schema/mixin/softdelete_rules.go
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go run .created_at set: true | updated_at set: true
updated_at moved: true | created_at unchanged: true
visible after delete: 1
Get on a deleted note is NotFound: true
rows including deleted: 2
rows with delete_time set: 1
visible after bulk delete: 0 | still stored: 2
after a real delete: 0Two passes of generation
The soft delete rules import example.test/lab/ent/hook and ent/intercept. Those packages exist only after Ent has generated, and the schema package (which imports the mixin) must compile for Ent to generate. Hence two passes. The first uses only the fields file; go generate then produces hook and intercept (the intercept package comes from the intercept feature in the directive). The script renames the rules file into place and generates again, and from then on the project builds with a single go generate. Document the bootstrap in the repository, or a new contributor will meet a confusing load error on a fresh clone with the second file already present and the generated code absent. The usual approach is to commit the generated code, so a clone never needs the first pass (the codegen workflow).
The program also imports ent/runtime for its side effect. That package registers hooks, interceptors and defaults declared in schemas, and without it the mixin's behaviour is silently absent. Forgetting that import is the most common reason a mixin "does nothing".
What the soft delete did
The lab creates a note, edits it, creates a second one and deletes the first:
visible after delete: 1: one note is left from the application's point of view.Get on a deleted note is NotFound: true: the interceptor filtered the lookup by id too. No call site was changed.rows including deleted: 2: withWithDeleted(ctx)the table still has both rows.rows with delete_time set: 1: the deleted one carries its timestamp.
A bulk Delete().Where(note.BodyContains("second")) was also soft. The count after it was visible after bulk delete: 0 | still stored: 2: the bulk delete was rewritten to a bulk update, with the same predicate.
To remove rows for real (a retention job, a GDPR erasure), run the delete with the bypass context: client.Note.Delete().ExecX(mixin.WithDeleted(ctx)) left 0 rows. Keep that call in one place, run it from a job and log it.
Pitfalls of this pattern
Everything from the GORM soft delete article applies, because the database does not know about the marker:
- Unique constraints still see deleted rows. A unique email on a soft deleted entity blocks the address from being reused. Use a partial unique index (
WHERE delete_time IS NULL), which Ent supports through an index annotation with a predicate, or move deleted rows to an archive table. - Raw SQL and other programs ignore the interceptor. They need
delete_time IS NULLin their own queries, or a view. - Foreign keys. A soft deleted parent leaves children pointing at a row nobody can load. Decide whether deleting the parent should soft delete the children (a hook), and test the case.
- Edge loading. The interceptor applies to eager loads of the entity from another query, which is what you want, so a deleted note disappears from
author.WithNotes()too. - Size. Deleted rows stay in the table and its indexes until purged. Run the purge on a schedule.
When mixins are worth it
Use one when two or more entities share a field set or behaviour, and when forgetting it on the third would be a bug: timestamps, tenant ids, soft delete, audit columns (created_by). Do not build a hierarchy of mixins that only some entities partly use; a mixin should mean one thing. Keep mixins in their own package and test them with a throwaway entity.
Checklist
- Put shared fields in a mixin and list it in each entity's
Mixin(). - Use
UpdateDefaultforupdated_atandImmutableforcreated_at. - Implement soft delete as a hook plus an interceptor, with an explicit bypass context.
- Import
ent/runtimein the program or the hooks do not run. - Plan the two generation passes, or commit the generated code.
- Add partial unique indexes and a purge job.
The feature is documented in the Ent mixin guide and the soft delete recipe in the Ent soft delete example.


