Skip to content
gohacks.dev
Databases

An Ent Cheat Sheet for Schemas Queries and Mutations

An Ent cheat sheet for Go and PostgreSQL covering schema options, create, query, edges, update, aggregate, transactions and errors, each checked by a run

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

This is a reference for the Ent calls you use every week, in tables with the program that exercised them. Every call was run against PostgreSQL 18.6 and the output is shown. The model is the library from the Ent tutorial: authors with books, a unique email, validated titles and pages. For the explanations behind each row, see schema, queries and transactions and hooks. For the same sheet in GORM, see the GORM cheat sheet.

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 program

main.goGo
package main

import (
	"context"
	"errors"
	"fmt"

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

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

	// create
	ada := client.Author.Create().SetName("Ada").SetEmail("[email protected]").SaveX(ctx)
	client.Book.CreateBulk(
		client.Book.Create().SetTitle("A").SetPages(120).SetAuthor(ada).SetPublished(true),
		client.Book.Create().SetTitle("B").SetPages(300).SetAuthor(ada),
		client.Book.Create().SetTitle("C").SetPages(450).SetAuthor(ada).SetPublished(true),
	).ExecX(ctx)
	fmt.Println("create:", ada.ID, client.Book.Query().CountX(ctx), "books")

	// read
	first := client.Book.Query().Order(ent.Asc(book.FieldTitle)).FirstX(ctx)
	only := client.Book.Query().Where(book.Title("B")).OnlyX(ctx)
	byID := client.Book.GetX(ctx, only.ID)
	pages := client.Book.Query().Where(book.PagesGTE(300)).Order(ent.Desc(book.FieldPages)).AllX(ctx)
	titles := client.Book.Query().Where(book.Published(true)).Order(ent.Asc(book.FieldTitle)).Select(book.FieldTitle).StringsX(ctx)
	fmt.Println("read:", first.Title, only.Title, byID.Pages, len(pages), titles)
	fmt.Println("exist/count/ids:", client.Book.Query().Where(book.PagesGT(400)).ExistX(ctx), client.Book.Query().Where(book.Published(false)).CountX(ctx), client.Book.Query().Order(ent.Asc(book.FieldID)).IDsX(ctx))

	// predicates
	n := client.Book.Query().Where(book.Or(book.PagesLT(150), book.And(book.Published(true), book.Not(book.TitleEQ("C"))))).CountX(ctx)
	fmt.Println("or/and/not:", n)

	// edges
	withBooks := client.Author.Query().Where(author.HasBooksWith(book.PagesGT(400))).WithBooks().OnlyX(ctx)
	fmt.Println("edges:", withBooks.Name, len(withBooks.Edges.Books), "books;", ada.QueryBooks().CountX(ctx), "via QueryBooks;", only.QueryAuthor().OnlyX(ctx).Name, "via QueryAuthor")

	// update
	b2 := only.Update().SetPublished(true).AddPages(5).SaveX(ctx)
	rows := client.Book.Update().Where(book.PagesLT(200)).SetPublished(false).SaveX(ctx)
	fmt.Println("update:", b2.Pages, b2.Published, "| bulk rows:", rows)

	// aggregate
	var agg []struct {
		Sum int `json:"sum"`
		Max int `json:"max"`
	}
	client.Book.Query().Aggregate(ent.Sum(book.FieldPages), ent.Max(book.FieldPages)).ScanX(ctx, &agg)
	fmt.Println("aggregate:", agg)

	// transaction
	tx, _ := client.Tx(ctx)
	tx.Book.Create().SetTitle("D").SetPages(10).SetAuthorID(ada.ID).SaveX(ctx)
	tx.Rollback()
	fmt.Println("rolled back, books:", client.Book.Query().CountX(ctx))

	// errors
	_, err := client.Book.Get(ctx, 999)
	fmt.Println("errors:", ent.IsNotFound(err))
	_, err = client.Author.Create().SetName("Dup").SetEmail("[email protected]").Save(ctx)
	fmt.Println("   constraint:", ent.IsConstraintError(err))
	_, err = client.Book.Create().SetTitle("").SetPages(1).SetAuthor(ada).Save(ctx)
	var ve *ent.ValidationError
	fmt.Println("   validation:", ent.IsValidationError(err), errors.As(err, &ve))
	_, err = client.Book.Query().Where(book.PagesGT(1)).Only(ctx)
	fmt.Println("   not singular:", ent.IsNotSingular(err))

	// delete
	deleted := client.Book.Delete().Where(book.Published(false)).ExecX(ctx)
	client.Book.DeleteOneID(only.ID).ExecX(ctx)
	fmt.Println("delete:", deleted, "left", client.Book.Query().CountX(ctx))
}
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
create: 1 3 books
read: A B 300 2 [A C]
exist/count/ids: true 1 [1 2 3]
or/and/not: 1
edges: Ada 3 books; 3 via QueryBooks; Ada via QueryAuthor
update: 305 true | bulk rows: 1
aggregate: [{875 450}]
rolled back, books: 3
errors: true
   constraint: true
   validation: true true
   not singular: true
delete: 1 left 1

Setup and generation

NeedCommand or call
Generate the clientgo generate ./ent with //go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema
Enable a featuregenerate --feature sql/upsert,privacy,intercept,entql ./schema
Open a clientent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, sqlDB)))
Create tables (development)client.Schema.Create(ctx)
Preview the migrationclient.Schema.WriteTo(ctx, os.Stdout)
Log every statementent.Debug(), or ent.Log(fn) for your own logger

Schema

NeedOption
Required stringfield.String("name").NotEmpty()
Unique.Unique()
Optional, nullable column.Optional()
Pointer in Go for NULL.Optional().Nillable()
Default.Default(v) or .Default(time.Now)
Update default.UpdateDefault(time.Now)
Set once.Immutable()
Range.Min(0), .Positive(), .Range(1, 5)
Length.MaxLen(30)
Custom check.Validate(func(v string) error { ... })
Enumfield.Enum("plan").Values("free", "pro").Default("free")
JSONfield.JSON("links", []string{})
One to manyedge.To("books", Book.Type) and edge.From("author", Author.Type).Ref("books").Unique().Required()
Named foreign key.Field("author_id") on the edge, with field.Int("author_id")
Many to many with data.Through("enrollments", Enrollment.Type) (guide)
Composite indexindex.Fields("title").Edges("author").Unique()
Table nameentsql.Annotation{Table: "user_profiles"}
Shared fieldsa Mixin() (mixins)

Create

NeedCall
One rowclient.Author.Create().SetName("Ada").SetEmail("[email protected]").Save(ctx)
Panic on error (tests)SaveX(ctx)
Set an edgeSetAuthor(a) or SetAuthorID(id)
Many rowsclient.Book.CreateBulk(b1, b2, b3).Save(ctx)
Optional pointerSetNillablePriority(p)
Upsert (feature sql/upsert).OnConflictColumns(account.FieldEmail).UpdateNewValues().Exec(ctx)

The program's first line, create: 1 3 books, is the author id and the number of books after a bulk create.

Query

NeedCall
By idclient.Book.Get(ctx, id)
First, or NotFoundQuery().Order(ent.Asc(book.FieldTitle)).First(ctx)
Exactly oneQuery().Where(book.Title("B")).Only(ctx)
ManyQuery().Where(book.PagesGTE(300)).Order(ent.Desc(book.FieldPages)).All(ctx)
One columnSelect(book.FieldTitle).Strings(ctx)
Exists, count, idsExist(ctx), Count(ctx), IDs(ctx)
PagingLimit(20).Offset(40)
Composebook.Or(a, b), book.And(a, b), book.Not(a)

The output read: A B 300 2 [A C] is the first title, the only title, its pages, two long books and the published titles. exist/count/ids: true 1 [1 2 3] is the exist check, the unpublished count and the id list. or/and/not: 1 is the composed predicate.

Edges

NeedCall
Filter by a related rowauthor.HasBooksWith(book.PagesGT(400))
Has anyauthor.HasBooks()
Eager loadQuery().WithBooks().All(ctx)
Eager load with optionsWithBooks(func(q *ent.BookQuery) { q.Order(...).Where(...) })
Traverse from an entityada.QueryBooks().All(ctx), book.QueryAuthor().Only(ctx)

edges: Ada 3 books; 3 via QueryBooks; Ada via QueryAuthor shows eager loading and both traversals. A Limit inside WithBooks limits the whole child query, not each parent (queries).

Update and aggregate

NeedCall
Update oneb.Update().SetPublished(true).Save(ctx) or UpdateOneID(id)
Arithmetic in SQLAddPages(5)
Clear an optional fieldClearBio()
Bulkclient.Book.Update().Where(book.PagesLT(200)).SetPublished(false).Save(ctx) returns rows changed
AggregatesQuery().Aggregate(ent.Sum(book.FieldPages), ent.Max(book.FieldPages)).Scan(ctx, &rows)
Group byGroupBy(book.AuthorColumn).Aggregate(ent.Count()).Scan(ctx, &rows)

update: 305 true | bulk rows: 1 and aggregate: [{875 450}] are the arithmetic update, the bulk row count, and the sum and max of pages. Scan more than one aggregate into a struct; Ints reads a single column.

Transactions

NeedCall
Begintx, err := client.Tx(ctx)
Usetx.Book.Create()..., never the outer client
Endtx.Commit() or tx.Rollback()

rolled back, books: 3 shows a book created in a transaction disappearing on rollback. Wrap it in a helper that rolls back on error and panic (the helper).

Errors and delete

TestMeaning
ent.IsNotFound(err)Get, First or Only found nothing
ent.IsNotSingular(err)Only found more than one
ent.IsConstraintError(err)unique, foreign key or similar
ent.IsValidationError(err)a schema validator failed, before any SQL
errors.As(err, &ve) with *ent.ValidationErrorread the failing field
DeleteCall
Oneclient.Book.DeleteOneID(id).Exec(ctx)
By conditionclient.Book.Delete().Where(book.Published(false)).Exec(ctx) returns rows removed

The four error tests all printed true, and delete: 1 left 1 is the bulk delete count and the remaining books.

Five habits

  1. Pass ctx to every terminal call.
  2. Use typed predicates, never strings.
  3. Eager load relations you are about to read for many parents.
  4. Print the SQL when a query is new (ent.Debug()).
  5. Map IsNotFound, IsConstraintError and IsValidationError to domain errors in one place.

The full reference is the Ent documentation, and the predicates and builders are listed in the CRUD API 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.