Ent Code Generation Workflow That Does Not Annoy the Team
An Ent code generation workflow: go:generate, committed output, a CI drift check and feature flags, and what one new field changed across 7 files

Code generation is the part of Ent that people either accept in a day or resent for a year. The difference is not the tool, it is the workflow around it: whether generation is one obvious command, whether the output is committed, whether anything checks that it is current, and whether a schema change produces a diff a reviewer can understand. This article sets up a workflow that stays out of the way, and measures the generator on PostgreSQL 18.6's schema from the Ent tutorial: how many files and lines it writes, whether it is deterministic, and what one new field changes.
Tested with Go 1.25.6, Ent v0.14.5, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
The same discipline applies to other generators in this cluster: sqlc vet and diff and GORM Gen. For the Ent side of the schema, see schema.
One command
Put the generator behind go generate, in a file inside the ent package, so go generate ./... does the right thing from the repository root:
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schemago run -mod=mod entgo.io/ent/cmd/ent generate ./schema fetches the generator at the version in go.mod. There is no global install to drift between laptops, and CI uses the same version. Pin the Ent module deliberately: a new release can change the generated output, and you want that to appear as a reviewed diff, not as noise in someone's unrelated branch.
A Makefile target (make generate) wrapping go generate ./... gives people a name to remember, which is all the abstraction it needs.
The schema and a small program
The lab uses the author and book schemas from the tutorial and a main.go that proves the generated client works:
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)} }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()} }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 main
import (
"context"
"fmt"
)
func main() {
ctx := context.Background()
client := open("l0738")
defer client.Close()
must(client.Schema.Create(ctx))
fmt.Println("generated client works, books:", client.Book.Query().CountX(ctx))
}What the script measures
The script generates, counts, hashes, regenerates, changes the schema, regenerates again and compares:
set -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
hashes() { find ent -name '*.go' -not -path 'ent/schema/*' | sort | xargs shasum | cut -c1-12,41-; }
count() { find ent -name '*.go' -not -path 'ent/schema/*' | wc -l | tr -d ' '; }
lines() { find ent -name '*.go' -not -path 'ent/schema/*' | xargs cat | wc -l | tr -d ' '; }
echo "schema files: $(ls ent/schema | wc -l | tr -d ' '), generated files: $(count), generated lines: $(lines)"
hashes > before.txt
GOFLAGS=-mod=mod go generate ./ent
hashes > after.txt
if diff -q before.txt after.txt >/dev/null; then echo "regenerate with no schema change: identical"; else echo "regenerate with no schema change: DIFFERENT"; fi
perl -0pi -e 's/(field\.Bool\("published"\)\.Default\(false\),)/$1\n\t\tfield.String("isbn").Optional(),/' ent/schema/book.go
GOFLAGS=-mod=mod go generate ./ent
hashes > after.txt
echo "files that changed after adding one field to Book:"
diff before.txt after.txt | grep '^>' | sed 's/^> [0-9a-f]* */ /' | sort
perl -0pi -e 's/\n\t\tfield\.String\("isbn"\)\.Optional\(\),//' ent/schema/book.go
printf 'package ent\n\n//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate --feature sql/upsert,sql/execquery ./schema\n' > ent/generate.go
GOFLAGS=-mod=mod go generate ./ent
echo "with --feature sql/upsert,sql/execquery: generated lines $(lines)"
go run .
rm -f before.txt after.txtschema files: 2, generated files: 26, generated lines: 6747
regenerate with no schema change: identical
files that changed after adding one field to Book:
ent/book.go
ent/book/book.go
ent/book/where.go
ent/book_create.go
ent/book_update.go
ent/migrate/schema.go
ent/mutation.go
with --feature sql/upsert,sql/execquery: generated lines 7493
generated client works, books: 0Size. schema files: 2, generated files: 26, generated lines: 6747. Two small entities produced 26 files and 6747 lines. Each entity contributes about a dozen files (the struct, five builders, a sub package with names and predicates) and the shared plumbing (client, transaction, mutation, runtime, migration) is the rest. The count grows with entities and with edges. Plan for it: the code is big, it is in version control, and tools that scan the repository will see it.
Determinism. regenerate with no schema change: identical. The hashes of every generated file were the same on a second run. This is the property that makes the next step possible. A generator whose output varies from run to run (timestamps, map ordering) makes a CI check impossible. Ent's does not vary, so the check is reliable.
Locality. After adding field.String("isbn").Optional() to Book, seven files changed:
ent/book.go,ent/book/book.go,ent/book/where.go: the struct, the field constant and the predicates.ent/book_create.go,ent/book_update.go: the setters.ent/migrate/schema.go: the table definition.ent/mutation.go: the mutation type that hooks receive.
Nothing about Author changed. A schema change shows up as a diff in files that plainly relate to it, which makes the review tractable even though the files are large. If a one field change touches dozens of files, something other than the schema changed: a version bump or a feature flag.
Features. Ent's optional features add code. The lab regenerated with --feature sql/upsert,sql/execquery and the output went from 6747 to 7493 lines. sql/upsert adds the OnConflict builders (used in the benchmark), sql/execquery adds raw ExecContext and QueryContext on the client, and others add privacy, versioned migrations, interceptors, and more. Turn on the ones you use, and keep the list in the go:generate line so it is visible. Changing it changes every build, so decide early.
Commit the output, and check it
Two policies work; pick one and write it down.
- Commit the generated code, and have CI fail on drift. This is the one to prefer. A fresh clone builds with no generation step, reviewers see the effect of a schema change, and tools like gopls work immediately. The CI job is:
go generate ./...
git diff --exit-codeIf the schema changed and someone forgot to regenerate, the job fails and prints the diff. This works because generation is deterministic, as shown above. It requires that every generated file is tracked, including new ones, so use git status --porcelain instead of git diff if you want untracked files to fail the check too.
- Do not commit it; generate in CI and locally. This keeps the repository small and makes every build depend on running the generator. New contributors cannot compile until they run it, tools that read the repository cold see nothing, and a generator failure blocks everything. Use this only with a build system that makes generation automatic.
Mark generated files in .gitattributes (ent/** linguist-generated=true) so code review tools collapse them by default. The diff is still there when you need it, and the noise is not.
Keep generated code out of your edits
Never edit a generated file. The next run overwrites it, and nobody remembers why a build changed. Put extension code in your own files in other packages, use hooks and mixins for behaviour, and use the ent/template mechanism if you need to customise the generated output (a documented feature for the rare case). A comment // Code generated by ent, DO NOT EDIT. heads every file, which also tells linters to skip them: configure golangci-lint to exclude generated files so they do not drown real findings.
Speed
Generation took a few seconds in the lab, mostly compiling the generator the first time. Subsequent runs reuse the build cache. In CI, cache GOCACHE and the module cache so the generator is not rebuilt on every run. If generation becomes slow, the schema is large: split entities into files, and make sure nothing in the schema package imports heavy dependencies, since the schema package is compiled and executed by the generator.
Two-step generation
Some features make the schema import generated code. The privacy layer is the usual one: a policy rule imports ent/privacy, which does not exist until the first generation. Ent's loader then fails, because it cannot compile the schema. The workaround is to generate once without the policy, add the policy, and generate again. Document this in the repository README, or keep the dependent code in a separate file that is added after bootstrap (Ent privacy shows the sequence, and it is run there).
A workflow in five lines
- Edit
ent/schema/*.go. make generate.- Commit schema and generated code together.
- CI regenerates and runs
git diff --exit-code. - Pin the Ent version, and update it in its own pull request.
Checklist
- Use one
go:generateline and amake generatetarget. - Commit generated code, and fail CI on a diff.
- Mark generated files as generated for review tools and linters.
- List the feature flags in the directive and enable only what you use.
- Upgrade Ent in a dedicated pull request and read the generated diff.
The generator is documented in the Ent code generation guide, and the feature flags in its feature flags section.


