Skip to content
gohacks.dev
Databases

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

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

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:

  1. The schema, ordinary Go types in ent/schema that implement ent.Interface through embedding ent.Schema, with methods Fields(), Edges() and Indexes(). The schema is the single description of your data model.
  2. The generator, entgo.io/ent/cmd/ent generate ./schema, usually triggered by a //go:generate line. It writes the client, one sub package per entity (predicates, field names, where clauses), builders, mutations, migration code and a runtime hook registry.
  3. The runtime, the small entgo.io/ent module 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

Shell
go get entgo.io/[email protected]

The generated package lives in ent/, next to ent/schema/. A generate.go file holds the directive:

ent/generate.goGo
package ent

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

go 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:

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)} }

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:

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()} }

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 and edge.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 is NOT 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:

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)
	}
}

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

main.goGo
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)
}
Shell
set -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go run .
Text
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: 2

Create

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 chained Order(...) and Select(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 in Edges.Books and the lab printed eager loaded books: 3. Ent runs one query for the authors and one for the books, with IN (...) over the author ids, which avoids the N plus one problem the way Preload does 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): Get on id 999 gave Get missing: true. Only and First return it too.
  • ent.IsConstraintError(err): a second author with the same email, and a second book titled Testing for the same author, both gave true. This covers unique, foreign key and similar violations.
  • ent.IsValidationError(err): the empty title failed before any SQL ran, with validator 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): Only found 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:

EdgeDeclared asStored as
One to manyedge.To("books") and edge.From("author").Unique()A foreign key column on the many side
Many to manyedge.To("tags") and edge.From("posts") on both sidesA join table with two foreign keys
One to oneedge.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.go holds the go:generate directive.
  • Everything else under ent/ is generated and committed. Never edit it by hand.
  • A store or repository package takes an *ent.Client and exposes domain methods, so handlers do not import ent directly.

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 column author_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, IsConstraintError and IsValidationError.
  • Use Schema.Create for development and versioned migrations for production.

The reference is the Ent documentation, and the schema API is in the Ent schema package.

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.