Skip to content
gohacks.dev
Databases

Ent Transactions and Hooks for Consistent Writes in Go

Ent transactions and mutation hooks on PostgreSQL: a WithTx helper, hook order, rejecting writes, bulk operations and a side effect that survived a rollback

10 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

Two features decide whether an Ent service writes consistent data: transactions, which make several statements succeed or fail together, and hooks, which run your code around every mutation. Both are straightforward to use and have an edge each that deserves an experiment. This article runs a transaction helper and a set of hooks against PostgreSQL 18.6, prints the order in which things happen, and shows two behaviours that surprise: the poisoned transaction after a handled error, and the side effect that survives a rollback.

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 extends the Ent tutorial. The same lessons for GORM are in transactions and hooks, and the outbox pattern that fixes the side effect problem is in the outbox article.

Transactions

client.Tx(ctx) returns an *ent.Tx. It has a client for every entity (tx.Author, tx.Book) whose operations run on one database transaction, and Commit and Rollback methods. The transaction does nothing until you use it, and holds a pooled connection until it ends, so every code path must end it.

The standard helper wraps that in a function, so callers pass a closure and cannot forget to roll back:

main.goGo
package main

import (
	"context"
	"errors"
	"fmt"

	"example.test/lab/ent"
	"example.test/lab/ent/author"
	"example.test/lab/ent/hook"
)

// WithTx runs fn in a transaction and rolls back on an error or a panic.
func WithTx(ctx context.Context, client *ent.Client, fn func(tx *ent.Tx) error) error {
	tx, err := client.Tx(ctx)
	if err != nil {
		return err
	}
	defer func() {
		if v := recover(); v != nil {
			tx.Rollback()
			panic(v)
		}
	}()
	if err := fn(tx); err != nil {
		if rerr := tx.Rollback(); rerr != nil {
			err = fmt.Errorf("%w: rolling back: %v", err, rerr)
		}
		return err
	}
	return tx.Commit()
}

func main() {
	ctx := context.Background()
	client := open("l0740")
	defer client.Close()
	must(client.Schema.Create(ctx))

	count := func() int { return client.Author.Query().CountX(ctx) }

	fmt.Println("== transactions")
	err := WithTx(ctx, client, func(tx *ent.Tx) error {
		tx.Author.Create().SetName("Ada").SetEmail("[email protected]").SaveX(ctx)
		return errors.New("later step failed")
	})
	fmt.Println("rolled back:", err, "| authors:", count())
	err = WithTx(ctx, client, func(tx *ent.Tx) error {
		_, err := tx.Author.Create().SetName("Ada").SetEmail("[email protected]").Save(ctx)
		return err
	})
	fmt.Println("committed:", err, "| authors:", count())

	// A failed statement aborts a PostgreSQL transaction, so the next statement fails too.
	err = WithTx(ctx, client, func(tx *ent.Tx) error {
		_, dup := tx.Author.Create().SetName("Dup").SetEmail("[email protected]").Save(ctx)
		fmt.Println("duplicate inside tx:", ent.IsConstraintError(dup))
		_, err := tx.Author.Create().SetName("Next").SetEmail("[email protected]").Save(ctx)
		return err
	})
	fmt.Println("statement after the failure:", err != nil)

	fmt.Println("== hooks")
	order := []string{}
	client.Author.Use(func(next ent.Mutator) ent.Mutator {
		return hook.AuthorFunc(func(ctx context.Context, m *ent.AuthorMutation) (ent.Value, error) {
			if name, ok := m.Name(); ok && name == "banned" {
				return nil, errors.New("hook: that name is not allowed")
			}
			order = append(order, "outer in "+m.Op().String())
			v, err := next.Mutate(ctx, m)
			order = append(order, "outer out")
			return v, err
		})
	})
	client.Author.Use(func(next ent.Mutator) ent.Mutator {
		return hook.AuthorFunc(func(ctx context.Context, m *ent.AuthorMutation) (ent.Value, error) {
			order = append(order, "inner in")
			v, err := next.Mutate(ctx, m)
			order = append(order, "inner out")
			return v, err
		})
	})
	client.Author.Create().SetName("Bob").SetEmail("[email protected]").SaveX(ctx)
	fmt.Println("order of two hooks:", order)

	_, err = client.Author.Create().SetName("banned").SetEmail("[email protected]").Save(ctx)
	fmt.Println("rejected by a hook:", err)

	order = nil
	client.Author.Update().Where(author.NameEQ("Bob")).SetName("Robert").SaveX(ctx)
	fmt.Println("bulk update runs the hook once:", order)

	order = nil
	client.Author.CreateBulk(
		client.Author.Create().SetName("C1").SetEmail("[email protected]"),
		client.Author.Create().SetName("C2").SetEmail("[email protected]"),
	).SaveX(ctx)
	fmt.Println("bulk create of 2 ran", len(order)/4, "hook pairs")

	// Hooks registered on the client are inherited by transactions, and their side effects are not rolled back.
	sideEffects := 0
	client.Author.Use(hook.On(func(next ent.Mutator) ent.Mutator {
		return hook.AuthorFunc(func(ctx context.Context, m *ent.AuthorMutation) (ent.Value, error) {
			v, err := next.Mutate(ctx, m)
			if err == nil {
				sideEffects++ // stands in for an email or a queue message
			}
			return v, err
		})
	}, ent.OpCreate))
	before := count()
	WithTx(ctx, client, func(tx *ent.Tx) error {
		tx.Author.Create().SetName("Temp").SetEmail("[email protected]").SaveX(ctx)
		return errors.New("roll back")
	})
	fmt.Println("side effects fired:", sideEffects, "| authors added:", count()-before)
}
db.goGo
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)
	}
}
ent/generate.goGo
package ent

//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate  ./schema
ent/schema/author.goGo
package schema

import (
	"time"

	"entgo.io/ent"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
)

type Author struct{ ent.Schema }

func (Author) Fields() []ent.Field {
	return []ent.Field{
		field.String("name").NotEmpty(),
		field.String("email").Unique(),
		field.Time("created_at").Default(time.Now).Immutable(),
	}
}

func (Author) Edges() []ent.Edge { return []ent.Edge{edge.To("books", Book.Type)} }
ent/schema/book.goGo
package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/schema/edge"
	"entgo.io/ent/schema/field"
	"entgo.io/ent/schema/index"
)

type Book struct{ ent.Schema }

func (Book) Fields() []ent.Field {
	return []ent.Field{
		field.String("title").NotEmpty(),
		field.Int("pages").Positive(),
		field.Bool("published").Default(false),
	}
}

func (Book) Edges() []ent.Edge {
	return []ent.Edge{edge.From("author", Author.Type).Ref("books").Unique().Required()}
}

func (Book) Indexes() []ent.Index { return []ent.Index{index.Fields("title").Edges("author").Unique()} }
Shell
set -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go run .
Text
== transactions
rolled back: later step failed | authors: 0
committed: <nil> | authors: 1
duplicate inside tx: true
statement after the failure: true
== hooks
order of two hooks: [outer in OpCreate inner in inner out outer out]
rejected by a hook: hook: that name is not allowed
bulk update runs the hook once: [outer in OpUpdate inner in inner out outer out]
bulk create of 2 ran 2 hook pairs
side effects fired: 1 | authors added: 0

WithTx does four things: begin; recover from a panic and roll back before re-panicking; roll back and wrap the error if the function fails; commit if it succeeds. This is the form Ent's own documentation recommends. If you use a different pattern, write the same four steps.

The results

Rollback. The first closure created an author and then returned an error. The output: rolled back: later step failed | authors: 0. The write was undone. Commit. The second closure created the same author and returned nil: committed: <nil> | authors: 1.

Inside the closure, use tx. The closure receives the transaction, and every operation in it must go through tx.Author, not the outer client.Author. A call on the outer client runs on a different connection, outside the transaction, so it is neither rolled back nor able to see the transaction's uncommitted rows. The compiler cannot catch this, and the habit that avoids it is to name the closure parameter tx and never reference client inside.

A handled error still poisons PostgreSQL's transaction. The third closure inserted an author with a duplicate email (duplicate inside tx: true, as IsConstraintError), ignored that error and tried another insert with a different email. That second statement failed too (statement after the failure: true). PostgreSQL marks a transaction aborted after any error, and every later statement is rejected until rollback. This is the same behaviour as in GORM, and the fixes are the same: avoid the error with OnConflict, check first, or isolate the risky statement. Code that handles a constraint error and carries on works on MySQL and SQLite and fails on PostgreSQL (the differences article).

Hooks

A mutation hook is a function that wraps the code that performs a create, update or delete. It receives the next mutator in the chain and returns a mutator, so it can run code before, after or instead of the operation:

Go
func(next ent.Mutator) ent.Mutator {
	return hook.AuthorFunc(func(ctx context.Context, m *ent.AuthorMutation) (ent.Value, error) {
		// before
		v, err := next.Mutate(ctx, m)
		// after
		return v, err
	})
}

The mutation m is typed: m.Name() returns the value being set and whether it is set, m.Op() says which operation, and m.SetName(...) changes the value before it is stored. The helper package ent/hook provides AuthorFunc and combinators like hook.On(fn, ent.OpCreate) (run only for creates), hook.Unless and hook.If.

You register a hook on one entity with client.Author.Use(hook) or on all of them with client.Use(hook). Hooks can also be declared in the schema, under Hooks(), which attaches them to every client that imports the generated runtime package.

Order

The lab added two hooks and created an author. The log reads outer in OpCreate, inner in, inner out, outer out. The first hook registered is the outermost: it sees the mutation first and the result last. Treat the order as significant if hooks depend on each other, and as a reason to keep hooks independent.

Rejecting a write

The outer hook checks the name and returns an error for banned: rejected by a hook: hook: that name is not allowed. The error comes out of Save and no SQL ran. This is the right place for rules that are about the data and not the request (normalise an email, reject a reserved name). For rules the database can enforce, a constraint is the stronger home, since a hook is bypassed by anything that does not use this client.

Bulk operations

Hooks run once per mutation, and a bulk operation is one mutation or several:

  • client.Author.Update().Where(...).SetName("Robert").Save(ctx) is one mutation. The hook ran once, with OpUpdate: [outer in OpUpdate inner in inner out outer out]. The hook sees the set values and the predicates but not the list of rows affected; for per row logic, load the rows first or use a trigger.
  • CreateBulk(b1, b2) ran the hooks twice, once per builder, which the lab shows as 2 hook pairs. Ent runs the hook for each mutation and then one multi row insert.

So the cost of a hook scales with the number of mutations, not the number of rows touched by a bulk update. A hook that queries the database makes a bulk create an N plus one.

The side effect problem

The last experiment registers a hook that increments a counter after a successful create, standing in for sending an email or publishing a message. It then creates an author inside a transaction and rolls back. The result: side effects fired: 1 | authors added: 0.

The hook ran after the create statement succeeded inside the transaction, and the counter went up. The transaction was then rolled back, so the author does not exist. The "email" went out for a user who was never saved. A hook registered on the client is inherited by the transactional client, which is why it fired at all, and a hook cannot know whether the surrounding transaction will commit.

The rule is the same as for GORM: do not perform external side effects in a hook. Options, in order of preference:

  1. Record the intent in the same transaction. Write an outbox row (event type, payload) with the author, and have a separate worker publish after the commit. If the transaction rolls back the row disappears with it (outbox).
  2. Do it after Commit. Return what needs doing from the transactional function and perform it once WithTx has returned nil.
  3. Use Tx.OnCommit. Ent's transaction type supports commit hooks (tx.OnCommit(func(next ent.Committer) ent.Committer {...})), which run when the commit happens. I did not run this in the lab. It is the closest thing to "after the data is safe" inside Ent, and its documentation describes it.

Hooks and the unit of work

Putting this together, a write path in a service looks like:

  1. WithTx opens the transaction.
  2. Builders write the rows. Hooks validate and normalise.
  3. An outbox row is written in the same transaction.
  4. Commit. A worker reads the outbox and sends.

Every step that can fail is before the commit, and everything outside the database happens after it.

Checklist

  • Use a WithTx helper with recover, rollback and commit.
  • Use only tx inside the closure.
  • Remember that PostgreSQL aborts a transaction after any error.
  • Keep hooks free of I/O. They run per mutation, before the commit.
  • Put external effects in an outbox or after the commit.
  • Back hook rules with database constraints for anything that must always hold.

The mechanisms are documented in the Ent transactions guide and the hooks guide.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.