Ent in Go for Schema as Code and Type Safe Queries
An Ent tutorial for Go and PostgreSQL: describe entities and edges as code, generate the client, then create, query, update and delete with typed errors

Ent is an entity framework for Go that works the opposite way round from most ORMs. You do not annotate structs and wait for reflection to figure it out at run time. You write a schema as Go code, run a generator, and the generator writes a complete, typed client: one package with a builder for every create and update, a predicate for every field, and a method for every relation. The compiler then does the checking that an ORM leaves to your tests. This article builds a small library of authors and books with Ent on PostgreSQL 18.6, runs it, and reads the results, including the errors, which are the part most tutorials skip.
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 is the entry point to the Ent articles in the database cornerstone. The detail pages are schema, queries and transactions and hooks. For how it compares with the alternatives, see Ent vs GORM, Ent vs sqlc and the first GORM article, the GORM tutorial.
What Ent is
Ent has three parts:
- The schema, ordinary Go types in
ent/schemathat implementent.Interfacethrough embeddingent.Schema, with methodsFields(),Edges()andIndexes(). The schema is the single description of your data model. - The generator,
entgo.io/ent/cmd/ent generate ./schema, usually triggered by a//go:generateline. It writes the client, one sub package per entity (predicates, field names, where clauses), builders, mutations, migration code and a runtime hook registry. - The runtime, the small
entgo.io/entmodule that the generated code imports. It supplies the SQL builder, the graph traversal and the dialect drivers.
What you get is a model closer to a graph than to tables. An Author has Books; a Book has an Author. You traverse the edges with methods rather than writing joins, and Ent turns the traversal into SQL.
Install and generate
go get entgo.io/[email protected]The generated package lives in ent/, next to ent/schema/. A generate.go file holds the directive:
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schemago run -mod=mod entgo.io/ent/cmd/ent runs the generator from the module cache, so there is no separate install, and -mod=mod lets Go add the dependencies the generator needs to go.sum. Commit the generated code. A reviewer can see how a schema change affects the API, and the build does not depend on a generation step.
The schema
An author has a name, a unique email and a creation time. The time is immutable (set once) and defaults to now:
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)} }A book has a title, a page count and a published flag, an edge back to its author, and a unique index over the title and the author:
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()} }Reading the two files together:
field.String("name").NotEmpty()adds a validator that rejects the empty string.field.Int("pages").Positive()rejects zero and negatives.edge.To("books", Book.Type)on the author andedge.From("author", Author.Type).Ref("books")on the book describe one relation from both sides..Unique().Required()on the book's side says each book has exactly one author, so the foreign key column isNOT NULL.index.Fields("title").Edges("author").Unique()makes a title unique per author, not globally. Edges can appear in indexes because the foreign key is a column.
The details of fields, edges and indexes are in the schema article.
Connecting
The lab opens a PostgreSQL connection through pgx's database/sql adapter and wraps it for Ent. It first creates a fresh schema so the run can repeat:
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)
}
}entsql.OpenDB(dialect.Postgres, db) takes an existing *sql.DB, which means pool settings, instrumentation and anything else you do to database/sql still apply (pool settings). Ent also has ent.Open("postgres", dsn) as a shortcut for simple cases.
The program
package main
import (
"context"
"fmt"
"example.test/lab/ent"
"example.test/lab/ent/author"
"example.test/lab/ent/book"
)
func main() {
ctx := context.Background()
client := open("l0736")
defer client.Close()
must(client.Schema.Create(ctx))
// Create: builders with typed setters. Save returns the new value with its id.
ada, err := client.Author.Create().SetName("Ada").SetEmail("[email protected]").Save(ctx)
must(err)
fmt.Println("created author:", ada.ID, ada.Name)
// Edges are set with the related value, and bulk creates take a slice of builders.
_, err = client.Book.CreateBulk(
client.Book.Create().SetTitle("Go in Practice").SetPages(300).SetAuthor(ada).SetPublished(true),
client.Book.Create().SetTitle("Concurrency").SetPages(240).SetAuthor(ada),
client.Book.Create().SetTitle("Testing").SetPages(180).SetAuthor(ada).SetPublished(true),
).Save(ctx)
must(err)
// Query with predicates, order and limit.
long, err := client.Book.Query().
Where(book.PagesGT(200), book.Published(true)).
Order(ent.Desc(book.FieldPages)).
All(ctx)
must(err)
for _, b := range long {
fmt.Println("long and published:", b.Title, b.Pages)
}
// Edge traversal, and eager loading in one extra query.
titles, err := ada.QueryBooks().Order(ent.Asc(book.FieldTitle)).Select(book.FieldTitle).Strings(ctx)
must(err)
fmt.Println("books of Ada:", titles)
withBooks, err := client.Author.Query().Where(author.Email("[email protected]")).WithBooks().Only(ctx)
must(err)
fmt.Println("eager loaded books:", len(withBooks.Edges.Books))
// Update returns the new value; UpdateOneID with a missing id is an error.
b, _ := client.Book.Query().Where(book.Title("Concurrency")).Only(ctx)
b, err = b.Update().SetPublished(true).AddPages(10).Save(ctx)
must(err)
fmt.Println("updated:", b.Title, b.Pages, b.Published)
n, err := client.Book.Update().Where(book.PagesLT(200)).SetPublished(false).Save(ctx)
fmt.Println("bulk update rows:", n, err)
// Errors are typed.
_, err = client.Book.Get(ctx, 999)
fmt.Println("Get missing:", ent.IsNotFound(err))
_, err = client.Author.Create().SetName("Ada 2").SetEmail("[email protected]").Save(ctx)
fmt.Println("duplicate email is a constraint error:", ent.IsConstraintError(err))
_, err = client.Book.Create().SetTitle("").SetPages(1).SetAuthor(ada).Save(ctx)
fmt.Println("empty title:", ent.IsValidationError(err), "|", err)
_, err = client.Book.Create().SetTitle("No author").SetPages(1).Save(ctx)
fmt.Println("missing required edge:", err)
_, err = client.Book.Create().SetTitle("Testing").SetPages(5).SetAuthor(ada).Save(ctx)
fmt.Println("unique index on (title, author):", ent.IsConstraintError(err))
// Delete.
deleted, err := client.Book.Delete().Where(book.Published(false)).Exec(ctx)
must(err)
left := client.Book.Query().CountX(ctx)
fmt.Println("deleted:", deleted, "left:", left)
}set -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go run .created author: 1 Ada
long and published: Go in Practice 300
books of Ada: [Concurrency Go in Practice Testing]
eager loaded books: 3
updated: Concurrency 250 true
bulk update rows: 1 <nil>
Get missing: true
duplicate email is a constraint error: true
empty title: true | ent: validator failed for field "Book.title": value is less than the required length
missing required edge: ent: missing required edge "Book.author"
unique index on (title, author): true
deleted: 1 left: 2Create
client.Author.Create().SetName("Ada").SetEmail("[email protected]").Save(ctx) is a builder. Every field has a typed setter, so a wrong type fails to compile, and required fields are checked when you call Save. It returns the new entity with its id: created author: 1 Ada.
Edges are set with the related entity: SetAuthor(ada). CreateBulk takes a slice of builders and inserts them in one statement, which is what the lab uses for the three books. Defaults (published false, created_at now) are applied by Ent in Go before the insert.
Query
client.Book.Query().Where(book.PagesGT(200), book.Published(true)).Order(ent.Desc(book.FieldPages)).All(ctx) returned Go in Practice 300: the only published book over 200 pages. The book package is generated, with a function per field and operator: PagesGT, PagesLT, TitleContains, Published. Multiple predicates in Where are combined with AND, and book.Or(...) and book.Not(...) compose them.
The finishers say what you expect back: All returns a slice, First the first or NotFound, Only exactly one or an error, Count, Exist, IDs, and Strings/Ints for a single selected column.
Edges
There are two ways to read a relation, and the difference matters:
- Traversal:
ada.QueryBooks()returns a new query for the books of this author. The lab chainedOrder(...)andSelect(book.FieldTitle).Strings(ctx)and got[Concurrency Go in Practice Testing]. Each traversal is its own query. - Eager loading:
client.Author.Query().WithBooks().Only(ctx)loads the authors and their books together. The books arrive inEdges.Booksand the lab printedeager loaded books: 3. Ent runs one query for the authors and one for the books, withIN (...)over the author ids, which avoids the N plus one problem the wayPreloaddoes in GORM (Preload and Joins).
Use eager loading when you are about to read the relation for many parents, and traversal when you have one.
Update and delete
b.Update().SetPublished(true).AddPages(10).Save(ctx) set a flag and added 10 to the page count in SQL. The Add methods on numeric fields generate pages = pages + 10, which is atomic. The result was updated: Concurrency 250 true, and the returned entity has the new values.
Bulk updates and deletes take a Where: client.Book.Update().Where(book.PagesLT(200)).SetPublished(false).Save(ctx) returned the number of rows changed, 1. client.Book.Delete().Where(book.Published(false)).Exec(ctx) removed 1, leaving 2 books. Both return counts, so 0 means nothing matched, which is how you detect "already gone".
Errors are typed
This is the best part of the API. Ent gives predicates for the errors you handle:
ent.IsNotFound(err):Geton id 999 gaveGet missing: true.OnlyandFirstreturn it too.ent.IsConstraintError(err): a second author with the same email, and a second book titledTestingfor the same author, both gavetrue. This covers unique, foreign key and similar violations.ent.IsValidationError(err): the empty title failed before any SQL ran, withvalidator failed for field "Book.title": value is less than the required length. The validators you declared in the schema run in Go.ent.IsNotSingular(err):Onlyfound more than one row.
A missing required edge is also caught in Go: creating a book with no author returned missing required edge "Book.author". Nothing was sent to the database.
Use these in a store layer to map to domain errors, as in GORM error handling. They are plain functions that look through wrapped errors, so they work after fmt.Errorf("%w", ...).
What the generator wrote
The ent directory after generation is worth a look once, so it stops being a mystery. At the top are client.go (the Client and one typed sub client per entity), ent.go (shared helpers and the error predicates), mutation.go (a mutation type per entity, which hooks receive), tx.go (the transactional client) and runtime.go. For each entity there is a set of files: author.go (the struct), author_create.go, author_update.go, author_delete.go and author_query.go (the builders), and a sub package author/ with the field name constants and the where.go predicates. migrate/schema.go holds the table definitions the migration engine uses.
Two details to know. The entity struct (ent.Book) has the columns as fields plus an Edges struct that eager loading fills, so a loaded edge is a plain slice and an unloaded one is nil, and NotLoadedError tells you when you read an edge you did not ask for. And the struct is a read model: you do not mutate it and save it back; you call Update() on it, which returns a builder. That shape removes the Save-the-whole-struct mistake that GORM allows.
How edges map to tables
Ent describes three relation shapes, and each has a standard SQL form:
| Edge | Declared as | Stored as |
|---|---|---|
| One to many | edge.To("books") and edge.From("author").Unique() | A foreign key column on the many side |
| Many to many | edge.To("tags") and edge.From("posts") on both sides | A join table with two foreign keys |
| One to one | edge.To("profile").Unique() and edge.From("user").Unique() | A unique foreign key on one side |
The lab used the first. The foreign key was author_books on the books table, named after the edge, and the index in the schema referred to it by the edge name. Ent asks which side owns the foreign key in the one to one case, and the answer is the side with edge.From (the one that holds the reference). A many to many with data on the link (a grade, an enrolment date) needs an edge schema, covered in many to many edges.
A project layout
The conventional layout keeps the generated code and the schema together and the rest of the program outside it:
ent/schema/holds hand written schema files, one per entity.ent/generate.goholds thego:generatedirective.- Everything else under
ent/is generated and committed. Never edit it by hand. - A
storeorrepositorypackage takes an*ent.Clientand exposes domain methods, so handlers do not importentdirectly.
That last point repeats the advice from the GORM articles: translate the persistence errors once, in the store, and keep HTTP code free of the library. With Ent it also keeps the generated types out of your API. The ent.Book struct has the database's shape, and a response type should have the API's.
Reading the SQL
Because Ent builds the SQL, you will want to see it. The client has a Debug() option that logs every statement, and ent.Log accepts a function so you can route the lines into your logger. The queries are conventional: quoted identifiers, $1 placeholders and explicit column lists. The article on queries prints the SQL for predicates, edge checks, eager loads and aggregates, which is the quickest way to learn what your builder calls cost.
Migrations
client.Schema.Create(ctx) creates missing tables, columns and indexes from the schema, like GORM's AutoMigrate. It is convenient in development and tests. For production, Ent integrates with Atlas to compute versioned migration files from the schema, which is the path to use on a database with data (Ent migrations with Atlas).
What to watch for
- Generated code is large. Two entities produced 6747 lines in the lab's generator run. Plan for it in reviews and compile times (the codegen workflow).
- Foreign key names. Without
Field("author_id")on the edge, Ent names the columnauthor_books, after the edge. Name the foreign key if other tools read the table. - Transactions.
client.Tx(ctx)returns a transactional client. Use it, not the outer client, inside a unit of work (transactions). - Context everywhere. Every terminal method takes a context, so deadlines propagate by design.
Why generated code instead of reflection
Reflection based mappers discover your model when the program runs, so a typo in a column name, a wrong type in a condition or a renamed field is found by whichever request exercises it. A generator moves that discovery to the build: the field and predicate names are Go identifiers, so the compiler finds them. The price is the generation step and the volume of output, and the benefit grows with the size of the codebase and the length of its life.
When Ent fits
Ent suits a domain with many related entities, where traversal and typed predicates remove a lot of query code, and where a team wants the compiler to find the effects of a schema change. It fits less well for a service made of hand tuned SQL and reports, where sqlc is the closer tool.
Checklist
- Write the schema in Go, generate, and commit the generated package.
- Use typed predicates, not strings, for filtering.
- Use
WithX()when loading a relation for many parents. - Handle errors with
IsNotFound,IsConstraintErrorandIsValidationError. - Use
Schema.Createfor development and versioned migrations for production.
The reference is the Ent documentation, and the schema API is in the Ent schema package.


