How to Use ent for Database Schema and Code Generation

Use `ent` by defining your data model in Go structs and then running the CLI to generate a type-safe, performant codebase that handles schema migrations and query building automatically.

When raw SQL gets in the way

You are building a service that tracks inventory. You need a product table, a warehouse table, and a many-to-many relationship between them. You open your editor and start typing raw SQL. Then you remember the last time you changed a column name and missed a query in a nested helper function. The production error log lit up at 3 AM. You want type safety. You want your database schema and your Go code to speak the same language. You also want to avoid the runtime surprises that come with traditional ORMs.

How ent flips the workflow

ent solves this by reversing the typical database workflow. Instead of writing queries that a framework translates to SQL at runtime, you describe your data model in Go. A command-line tool reads your description and generates a complete, type-safe Go package. The generated code handles schema migrations, query building, and relationship traversal. You never write raw SQL for standard operations. The compiler catches missing fields and wrong types before the code runs.

Think of it like a blueprint service. You hand the architect a list of rooms, doors, and plumbing requirements. They return a fully drafted set of construction plans. You do not draw the walls yourself. You just follow the plans. In Go terms, you define the schema, run the generator, and import the resulting package into your application. The generated code lives alongside your business logic, but it stays completely separate from your manual edits.

Defining the schema

Start with a fresh module and a schema/ directory. Create schema/user.go to describe a single entity. The file uses the ent schema package to declare fields and constraints.

// schema/user.go
package schema

import (
	"entgo.io/ent"
	"entgo.io/ent/schema/field"
)

// User holds the schema definition for the User entity.
type User struct {
	ent.Schema // embeds the interface so ent recognizes this as a schema
}

// Fields returns the list of database columns for this entity.
func (User) Fields() []ent.Field {
	return []ent.Field{
		// string maps to TEXT or VARCHAR depending on the driver
		field.String("name").
			NotEmpty(), // rejects empty strings at the application layer
		field.String("email").
			Unique(). // adds a unique index to the generated migration
			NotEmpty(),
		field.Int("age").
			Default(0), // sets the column default in the DDL
	}
}

// Edges defines relationships to other entities.
func (User) Edges() []ent.Edge {
	return nil // no relationships yet
}

The struct embeds ent.Schema so the code generator can identify it. The Fields() method returns a slice of field descriptors. Each descriptor chains configuration methods that translate directly to database constraints. The Edges() method stays empty for now. Run the generator from your project root. The command reads every file in ./schema and writes the output to ./ent.

# generates the Go package from the schema definitions
ent generate ./schema

The CLI parses the Go AST, extracts the schema definitions, and runs them through a template engine. It produces a client.go, a user.go, a predicate/user.go, and migration files. The output directory becomes a regular Go package that you import alongside your application code. Trust the generator. Do not edit files inside ent/. The next run overwrites manual changes.

What the generator actually does

The generator does not interpret your schema at runtime. It reads the Go files, extracts the field names, types, and constraints, and writes static Go code. The generated ent package contains a Client struct that holds a database connection. Each entity gets a builder type. client.User.Create() returns a UserCreate builder. client.User.Query() returns a UserQuery builder. These builders chain methods that accumulate query parameters. When you call Save(ctx) or All(ctx), the builder compiles the accumulated parameters into a prepared statement and executes it.

Because the code is generated at compile time, the Go compiler validates every field name and type. If you rename email to mail in the schema but forget to update a query, the build fails. The error message points directly to the mismatched identifier. You get the safety of an ORM with the transparency of handwritten SQL. The template engine also respects Go conventions. Generated receiver names are short, like (u *User). Public methods start with a capital letter. Private helpers stay lowercase. The output passes gofmt automatically. You do not need to configure indentation rules or argue about formatting.

Using the generated client

Connect to a database and use the generated client. The example below uses SQLite for simplicity, but the generated code works identically with PostgreSQL or MySQL. You pass a context.Context to every database operation. The convention is to name it ctx and place it as the first parameter in custom functions that wrap generated calls.

// main.go
package main

import (
	"context"
	"log"
	"your-project/ent"
	"your-project/ent/user"
)

func main() {
	// opens a connection using the sqlite3 driver
	client, err := ent.Open("sqlite3", "file:ent?mode=memory&_fk=1")
	if err != nil {
		log.Fatalf("failed opening connection to sqlite: %v", err)
	}
	defer client.Close() // ensures the connection pool is drained

	ctx := context.Background()

	// creates a user using the fluent builder API
	u, err := client.User.Create().
		SetName("Alice").
		SetEmail("alice@example.com").
		SetAge(30).
		Save(ctx) // executes the INSERT and returns the populated struct
	if err != nil {
		log.Fatalf("failed creating user: %v", err)
	}

	// queries users matching a predicate
	users, err := client.User.Query().
		Where(user.Email("alice@example.com")).
		All(ctx) // executes the SELECT and returns a slice
	if err != nil {
		log.Fatalf("failed querying users: %v", err)
	}

	log.Printf("Found %d users", len(users))
}

The ent.Open function initializes the client with a driver name and a connection string. The Create() builder chains Set methods that map directly to the schema fields. Calling Save(ctx) runs the INSERT statement and populates the returned struct with the database-generated ID and any default values. The Query() builder chains Where clauses that use the generated predicate package. All(ctx) fetches the results into a slice.

Every database call accepts a context. The generated client respects cancellation and deadlines. If the context expires mid-query, the driver cancels the operation and returns an error. This keeps long-running transactions from hanging your service. Context is plumbing. Run it through every long-lived call site.

Managing schema changes and migrations

When your application grows, you add fields, rename columns, or introduce new relationships. You update the schema/ files and run ent generate ./schema again. The generator updates the Go types and the migration scripts. You apply the changes to the database using the migration command.

# applies pending schema changes to the connected database
ent migrate --dir ./schema --driver sqlite3 --dsn file:ent?mode=memory

The migration tool compares your current schema against the database state and generates CREATE, ALTER, or DROP statements. It handles index creation, foreign key constraints, and default value updates. You can review the generated SQL before applying it. This keeps your database structure in sync with your Go code without manual DDL management. Version your migration files in git. Treat them like infrastructure code.

Common traps and compiler feedback

The biggest mistake developers make is treating the generated code like a black box. You should never edit files inside the ent/ directory. The next time you run ent generate, your manual changes disappear. If you need custom logic, extend the generated types or write wrapper functions in your own package. Accept interfaces, return structs. Let the generated client handle the heavy lifting.

Forgetting to regenerate after a schema change causes immediate build failures. The compiler rejects the program with undefined: user.NewField or cannot use string value as int value in argument when types drift. Run ent generate ./schema after every schema modification. Most teams add a pre-commit hook or a Makefile target to automate this step.

Another common trap is ignoring the error return values. The generated builders return (*Entity, error) or ([]Entity, error). Dropping the error with result, _ := client.User.Query().All(ctx) hides constraint violations and connection drops. The Go community accepts the if err != nil boilerplate because it forces you to handle the unhappy path explicitly. Write the check. Return the error. Let the caller decide how to respond.

Relationship traversal also trips people up. ent generates eager loading and lazy loading methods, but they behave differently. QueryX() methods return the result directly and panic on error. QueryX is useful in CLI tools or tests where you want fast failure. In production HTTP handlers, stick to the error-returning variants and handle the response gracefully. The worst goroutine bug is the one that never logs. Always attach context and error handling to database calls.

Choosing the right data layer

Use ent when you need a type-safe data layer that scales with your schema. Use ent when your team prefers explicit code generation over runtime reflection. Use ent when you want schema migrations and query building handled by a single toolchain. Reach for GORM when you need rapid prototyping with minimal boilerplate and do not mind runtime query compilation. Reach for raw database/sql with pgx when you need maximum control over query execution, connection pooling, and driver-specific features. Stick to sequential SQL scripts when your database schema changes rarely and your team prefers version-controlled migration files over generated Go code.

Where to go next