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

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:
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
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)
}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 ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schemapackage 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()} }set -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go run .== 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 ofentsql.Annotation{Table: "user_profiles"}. The default would beprofiles. The entity name in Go and the table name are independent. idis abigintidentity column. Ent adds it unless you define your own.handlehasMaxLen(30)andUnique(). The max length is a Go validator here: the column ischaracter varyingwith 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.planis anEnum, and on PostgreSQL it is stored ascharacter varyingwith the default'free'. On MySQL the same field becomes a nativeenum(...)column (the databases article). The allowed values are enforced in Go.bioisNULLbecause ofOptional().creditsisNOT NULL DEFAULT 0because it has a default.linksisjsonb NULL,tokenisuuid NULLandratingisdouble 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 side | Ent field | PostgreSQL column |
|---|---|---|
string | field.String, field.Text | character varying, text |
int, int64, uint | field.Int, field.Int64, field.Uint | bigint |
float64 | field.Float | double precision |
bool | field.Bool | boolean |
time.Time | field.Time | timestamptz |
| Go slice, map or struct | field.JSON | jsonb |
| enum | field.Enum("plan").Values(...) | character varying |
uuid.UUID | field.UUID("token", uuid.UUID{}) | uuid |
[]byte | field.Bytes | bytea |
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")givesvalidator failed for field "Profile.handle": handle must be lowercase letters, digits and dashes. The customValidatefunction returned the message.SetCredits(-1)givesvalidator failed for field "Profile.credits": value out of range, fromMin(0).SetRating(9)gives the same message, fromRange(0, 5).SetPlan("gold")givesinvalid 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:
| Modifier | Effect |
|---|---|
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
WriteToand read it before the first migration. - Use
Nillablewhen null matters, andImmutablefor 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.


