Skip to content
gohacks.dev
Databases

Ent Schema Fields Edges and Indexes Explained in Detail

Ent schema in practice: field types, defaults, validators, enums, optional and nillable, JSON and UUID, edges and indexes, with the CREATE TABLE SQL generated

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

The schema is the one place where an Ent project is written by hand, and everything else follows from it: the generated types, the builders, the validation, the migration SQL. Schemas read easily and hide a number of decisions (nullable or not, pointer or value, validated in Go or in the database), so this article goes through the pieces by building one entity that uses most of them and printing what Ent makes of it on PostgreSQL 18.6. The output is the evidence: the CREATE TABLE Ent would run, and the errors it raises before SQL ever leaves the program.

Tested with Go 1.25.6, Ent v0.14.5, pgx v5.11.0, google/uuid v1.6.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

It follows the Ent tutorial, where the author and book schemas first appear, and leads into queries and mixins. The same questions in GORM are in models and tags.

The shape of a schema file

Every entity is a struct embedding ent.Schema. Its behaviour comes from methods:

  • Fields() []ent.Field: the columns.
  • Edges() []ent.Edge: relations to other entities.
  • Indexes() []ent.Index: composite and unique indexes.
  • Annotations(): options for the SQL builder, such as the table name.
  • Mixin(), Hooks(), Policy(), Interceptors(): reusable fields, mutation hooks, privacy rules and query hooks, covered in later articles.

The lab adds a Profile entity next to Author and Book. Here it is:

ent/schema/profile.goGo
package schema

import (
	"errors"
	"regexp"

	"entgo.io/ent"
	"entgo.io/ent/dialect/entsql"
	"entgo.io/ent/schema"
	"entgo.io/ent/schema/field"
	"entgo.io/ent/schema/index"
	"github.com/google/uuid"
)

type Profile struct{ ent.Schema }

func (Profile) Annotations() []schema.Annotation {
	return []schema.Annotation{entsql.Annotation{Table: "user_profiles"}}
}

var slug = regexp.MustCompile("^[a-z0-9-]+$")

func (Profile) Fields() []ent.Field {
	return []ent.Field{
		field.String("handle").MaxLen(30).Unique().Validate(func(s string) error {
			if !slug.MatchString(s) {
				return errors.New("handle must be lowercase letters, digits and dashes")
			}
			return nil
		}),
		field.Enum("plan").Values("free", "pro", "team").Default("free"),
		field.String("bio").Optional().Nillable(),
		field.Int("credits").Default(0).Min(0),
		field.JSON("links", []string{}).Optional(),
		field.UUID("token", uuid.UUID{}).Optional().Immutable(),
		field.Float("rating").Optional().Range(0, 5),
	}
}

func (Profile) Indexes() []ent.Index {
	return []ent.Index{index.Fields("plan", "credits")}
}

The lab

main.goGo
package main

import (
	"context"
	"fmt"
	"os"
)

func main() {
	ctx := context.Background()
	client := open("l0737")
	defer client.Close()

	fmt.Println("== the SQL Ent would run to create the schema")
	must(client.Schema.WriteTo(ctx, os.Stdout))

	must(client.Schema.Create(ctx))

	fmt.Println("== validators and defaults")
	_, err := client.Profile.Create().SetHandle("Bad Handle").Save(ctx)
	fmt.Println("custom validator:", err)
	_, err = client.Profile.Create().SetHandle("ok").SetCredits(-1).Save(ctx)
	fmt.Println("Min(0):", err)
	_, err = client.Profile.Create().SetHandle("ok").SetRating(9).Save(ctx)
	fmt.Println("Range(0,5):", err)
	_, err = client.Profile.Create().SetHandle("ok").SetPlan("gold").Save(ctx)
	fmt.Println("enum:", err)
	p, err := client.Profile.Create().SetHandle("ada").Save(ctx)
	must(err)
	fmt.Println("defaults:", p.Plan, p.Credits, "bio nil:", p.Bio == nil, "links nil:", p.Links == nil)
	bio := "writes Go"
	p, _ = p.Update().SetBio(bio).Save(ctx)
	fmt.Println("optional + nillable bio:", *p.Bio)
	p, _ = p.Update().ClearBio().Save(ctx)
	fmt.Println("after ClearBio:", p.Bio == nil)
	p, _ = p.Update().SetLinks([]string{"https://example.com"}).Save(ctx)
	fmt.Println("json field:", p.Links)
}
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
== the SQL Ent would run to create the schema
CREATE TABLE "authors" ("id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "name" character varying NOT NULL, "email" character varying NOT NULL, "created_at" timestamptz NOT NULL, PRIMARY KEY ("id"));
CREATE UNIQUE INDEX "authors_email_key" ON "authors" ("email");
CREATE TABLE "user_profiles" ("id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "handle" character varying NOT NULL, "plan" character varying NOT NULL DEFAULT 'free', "bio" character varying NULL, "credits" bigint NOT NULL DEFAULT 0, "links" jsonb NULL, "token" uuid NULL, "rating" double precision NULL, PRIMARY KEY ("id"));
CREATE UNIQUE INDEX "user_profiles_handle_key" ON "user_profiles" ("handle");
CREATE INDEX "profile_plan_credits" ON "user_profiles" ("plan", "credits");
CREATE TABLE "books" ("id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "title" character varying NOT NULL, "pages" bigint NOT NULL, "published" boolean NOT NULL DEFAULT false, "author_books" bigint NOT NULL, PRIMARY KEY ("id"), CONSTRAINT "books_authors_books" FOREIGN KEY ("author_books") REFERENCES "authors" ("id") ON DELETE NO ACTION);
CREATE UNIQUE INDEX "book_title_author_books" ON "books" ("title", "author_books");
== validators and defaults
custom validator: ent: validator failed for field "Profile.handle": handle must be lowercase letters, digits and dashes
Min(0): ent: validator failed for field "Profile.credits": value out of range
Range(0,5): ent: validator failed for field "Profile.rating": value out of range
enum: ent: validator failed for field "Profile.plan": profile: invalid enum value for plan field: "gold"
defaults: free 0 bio nil: true links nil: true
optional + nillable bio: writes Go
after ClearBio: true
json field: [https://example.com]

The SQL it generates

client.Schema.WriteTo(ctx, os.Stdout) prints the statements Ent would run without running them. For Profile the output is:

CREATE TABLE "user_profiles" ("id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "handle" character varying NOT NULL, "plan" character varying NOT NULL DEFAULT 'free', "bio" character varying NULL, "credits" bigint NOT NULL DEFAULT 0, "links" jsonb NULL, "token" uuid NULL, "rating" double precision NULL, PRIMARY KEY ("id"));

followed by CREATE UNIQUE INDEX "user_profiles_handle_key" ON "user_profiles" ("handle"); and CREATE INDEX "profile_plan_credits" ON "user_profiles" ("plan", "credits");. Reading it against the schema:

  • The table is user_profiles, because of entsql.Annotation{Table: "user_profiles"}. The default would be profiles. The entity name in Go and the table name are independent.
  • id is a bigint identity column. Ent adds it unless you define your own.
  • handle has MaxLen(30) and Unique(). The max length is a Go validator here: the column is character varying with no limit, so the database does not enforce it. Ent checks it before the insert, which is a reason not to write to the table from other programs without the same check.
  • plan is an Enum, and on PostgreSQL it is stored as character varying with the default 'free'. On MySQL the same field becomes a native enum(...) column (the databases article). The allowed values are enforced in Go.
  • bio is NULL because of Optional(). credits is NOT NULL DEFAULT 0 because it has a default.
  • links is jsonb NULL, token is uuid NULL and rating is double precision NULL.

Reviewing this output in a pull request is the closest thing to a migration review that Schema.Create offers. For production, use the versioned migration flow (Atlas), where the same SQL lands in a file that a person approves.

Field types

Go sideEnt fieldPostgreSQL column
stringfield.String, field.Textcharacter varying, text
int, int64, uintfield.Int, field.Int64, field.Uintbigint
float64field.Floatdouble precision
boolfield.Boolboolean
time.Timefield.Timetimestamptz
Go slice, map or structfield.JSONjsonb
enumfield.Enum("plan").Values(...)character varying
uuid.UUIDfield.UUID("token", uuid.UUID{})uuid
[]bytefield.Bytesbytea

field.Int is a 64 bit column on PostgreSQL, as the output shows: credits is bigint. Use field.Int32 or field.Int16 if you want smaller columns.

A detail from the lab: field.UUID("token", [16]byte{}) did not compile in my first draft. The second argument must implement driver.Valuer, and a bare byte array does not. The fix was uuid.UUID{} from google/uuid, which does. The same rule applies to any custom type: it needs Value and Scan (or a GoType option naming one).

Validators run before the database

The lab sends five bad inputs and prints each error:

  • SetHandle("Bad Handle") gives validator failed for field "Profile.handle": handle must be lowercase letters, digits and dashes. The custom Validate function returned the message.
  • SetCredits(-1) gives validator failed for field "Profile.credits": value out of range, from Min(0).
  • SetRating(9) gives the same message, from Range(0, 5).
  • SetPlan("gold") gives invalid enum value for plan field: "gold".

These are all ent.IsValidationError. None sent a statement. That is useful (fast, with good messages) and has a consequence: the rules live in Go. A second service writing to the same table, a migration script or someone in psql bypasses them. For invariants that must hold everywhere, repeat them as database constraints (check, not null) in a migration. The Go validator then gives the friendly error and the constraint is the backstop.

Optional, Nillable, Default and Immutable

Four modifiers decide how a field behaves, and people mix them up:

ModifierEffect
Optional()The column is NULL; the create builder does not require it
Nillable()The Go struct field is a pointer, so unset is nil
Default(v)A value applied by Ent when you do not set one
Immutable()Can be set on create, never on update

Optional alone leaves the Go field as a plain value, so a NULL column reads back as the zero value and you cannot tell it from an empty string. Optional().Nillable() gives *string. The lab's bio is that: after creation p.Bio == nil, SetBio("writes Go") stored it, and ClearBio() returned it to NULL (after ClearBio: true). Use Nillable whenever "unknown" and "empty" mean different things.

Default with a function (field.Time("created_at").Default(time.Now)) is evaluated per insert. A default is applied in Go, not in the column definition, except for simple literals, which also appear in the DDL (DEFAULT 'free', DEFAULT 0). Immutable() on created_at means an update builder has no SetCreatedAt at all, so the mistake does not compile.

JSON and UUID

field.JSON("links", []string{}) stores a Go value as jsonb. The lab set []string{"https://example.com"} and read it back as [https://example.com]. For maps and structs the same works with the type as the second argument. JSON fields are convenient and opaque to predicates by default; query inside them with a custom predicate or the SQL modifier feature.

field.UUID with Default(uuid.New) generates ids in Go, which is the usual way to use a UUID primary key. The lab leaves token optional and immutable to show the type mapping.

Indexes

index.Fields("plan", "credits") made a composite index named profile_plan_credits. Unique() makes it unique. Edges("author") includes the foreign key column of an edge, which is how the Book schema gets "unique per author" without naming the column. Unique fields (field.String("email").Unique()) get a unique index automatically, named <table>_<column>_key. Add indexes for the columns you filter and sort on, and check with EXPLAIN, as nothing here adds them for you.

Habits for schemas

  • Print the DDL with WriteTo and read it before the first migration.
  • Use Nillable when null matters, and Immutable for values that must not change.
  • Put the invariants in the database as well as in validators.
  • Name the table with an annotation if other tools read it.
  • Keep one file per entity, and mixins for shared fields.

The field options are catalogued in the Ent schema fields documentation, and the index options in the indexes page.

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.