How to Use ent (Facebook's ORM) for Go Database Access

Define Go schemas, run entc generate, and use the generated client to perform type-safe database operations.

The problem with hand-rolled SQL

You spend twenty minutes writing a raw SQL query. You double-check the column names. You map the results to a struct. You run the program and get a type mismatch error because the database returned a NULL where you expected a string. You fix it, thread a context through three layers, and repeat the process for every endpoint. Hand-rolling database access in Go works, but it turns you into a string formatter and a manual type mapper. The ent framework removes that tedium by shifting the work from runtime to compile time.

What ent actually does

ent is not a traditional ORM that relies on reflection to inspect your structs at runtime. It is a code generator. You write a small set of Go files that describe your database schema. You run a command-line tool. The tool outputs a directory of fully formed Go packages containing type-safe query builders, mutation helpers, and relationship resolvers. Think of it like a custom compiler for your database layer. You provide the blueprint, and the tool builds the entire wiring harness before your application ever starts. Because the output is plain Go code, you get zero runtime overhead, full IDE autocomplete, and standard compiler checks. The framework treats your database schema as first-class Go types.

Your first schema and generated client

Start with a fresh module. Create a schema directory and define a single entity. The framework expects a struct that embeds ent.Schema and implements a Fields() method. The receiver is typically the type name itself, following Go convention for schema definitions.

// schema/user.go
// Embeds the base schema type so ent recognizes this as a valid entity definition.
type User struct {
    ent.Schema
}

// Fields returns the columns that will be created in the database table.
func (User) Fields() []ent.Field {
    return []ent.Field{
        // String type maps to VARCHAR. The name is used for both the Go field and DB column.
        field.String("name"),
        // Int type maps to INTEGER. Default values are evaluated at insertion time.
        field.Int("age").Default(0),
        // Unique constraint prevents duplicate entries at the database level.
        field.String("email").Unique(),
    }
}

Save the file and run the generator. The entc tool reads the schema directory and outputs the client code. The command scans all files in the path and merges them into a single graph model.

# Generates the ent client package and all query/mutation builders.
entc generate ./schema

The generator creates an ent directory in your project root. You import it and open a connection. The client handles connection pooling and context propagation automatically. Database drivers follow the standard database/sql interface, so you can swap SQLite for PostgreSQL by changing a single string.

// main.go
// Open creates a new database client. The driver name matches go-sql-driver conventions.
client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
    log.Fatalf("failed opening connection to sqlite: %v", err)
}
defer client.Close()

// Create builds a mutation, sets fields, and executes the INSERT statement.
u, err := client.User.Create().SetName("Alice").SetAge(28).Save(context.Background())
if err != nil {
    log.Fatalf("failed creating user: %v", err)
}

// The returned struct is fully typed. No scanning or reflection required.
fmt.Println("User created:", u.Name, u.Age)

Run the program and you get a clean output. The framework handled the SQL generation, parameter binding, and result mapping. You only interact with Go types. Generated code is cheap. Runtime reflection is expensive.

How the code generator works under the hood

What actually happens when you call client.User.Create()? The generator created a UserCreate struct that implements a builder pattern. Each SetX() method stores the value in the builder's internal state. When you call Save(ctx), the builder compiles the accumulated state into a single INSERT statement, binds the parameters safely, executes the query through the standard database/sql package, and maps the result back to a *User struct. Because this code is generated ahead of time, there is no reflection loop walking through struct tags. The compiler inlines the field assignments. Execution speed matches hand-written sqlx code, but you get type safety for free.

The generated code also respects Go conventions. Every function that touches the database accepts a context.Context as its first parameter. The receiver names are short and match the type. Error handling follows the standard if err != nil pattern. You can run gofmt on the generated directory and it will pass without changes. The tool treats the output as production code, not a black box. The generator also creates companion packages for migrations, testing, and schema introspection. You never need to write raw SQL unless you deliberately opt out.

A realistic query with filtering and joins

Real applications need filtering, ordering, and relationships. ent handles these through method chaining that mirrors SQL clauses. Suppose you need to fetch active users older than twenty-five, sorted by name, and limit the results. The builder exposes strongly typed predicates that prevent column name typos.

// query.go
// Query builds a SELECT statement with WHERE, ORDER BY, and LIMIT clauses.
users, err := client.User.Query().
    // Filter matches the database column name. The builder handles parameter binding.
    Where(user.AgeGT(25)).
    // OrderBy accepts generated ordering functions to prevent typos.
    OrderBy(user.ByAge(ent.Desc)).
    // Limit caps the result set to prevent memory exhaustion on large tables.
    Limit(10).
    // All executes the query and returns a slice of typed structs.
    All(context.Background())
if err != nil {
    return nil, fmt.Errorf("query users: %w", err)
}

// Iterate over the results. Each element is a concrete *User struct.
for _, u := range users {
    fmt.Printf("Found user: %s (age %d)\n", u.Name, u.Age)
}

Relationships work the same way. Define an edge in the schema, regenerate, and the client exposes QueryX() methods on the returned structs. The generator creates the JOIN logic and handles foreign key constraints automatically. You never write raw JOIN syntax. The type system guarantees that you only query relationships that actually exist in your schema. If you try to query a non-existent edge, the compiler rejects the program with an undefined method error. You catch the mistake before deployment.

Advanced patterns: hooks and transactions

Complex applications need cross-cutting concerns like logging, auditing, and transaction management. ent provides hooks and interceptors that wrap the generated query and mutation methods. Hooks run at the entity level. Interceptors run at the client level. Both follow the standard middleware pattern.

// hooks.go
// Hook wraps the mutation to log field changes before execution.
func auditLog(h ent.MutationHook) ent.MutationHook {
    return func(next ent.Mutator) ent.Mutator {
        return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
            // Log the operation type and changed fields before proceeding.
            log.Printf("mutation: %s, fields: %v", m.Op(), m.Fields())
            // Delegate to the next hook or the actual database executor.
            return next.Mutate(ctx, m)
        })
    }
}

Register the hook in the client configuration. The framework chains them in order. Transactions work through the Tx interface. You start a transaction, pass the transaction client to your domain logic, and commit or rollback. The generated client detects when it is operating inside a transaction and routes queries accordingly. Context cancellation propagates through the entire chain. If the deadline expires, the database driver aborts the query and returns a context error. Always check for context.DeadlineExceeded in your error handlers.

Where things break

The code generation approach introduces a few workflow changes. The biggest adjustment is the regeneration step. Every time you add a field, change a type, or create a new relationship, you must run entc generate. If you skip this step, the compiler rejects your code with an undefined field or method not found error. The generated package is also large. A modest schema with ten tables can produce hundreds of files. This is intentional. The verbosity trades disk space for compile-time safety and runtime performance. Never edit the generated files manually. Your changes will be overwritten on the next generation.

Schema migrations require careful handling. ent provides a migration tool that compares your Go schema against the live database and generates ALTER TABLE statements. Run migrations in a separate step from your application startup. Mixing schema changes with query execution can lock tables or cause race conditions in production. Always wrap database calls in a context with a timeout. The generated client respects cancellation, but a missing deadline will leave goroutines hanging if the database hangs.

Error handling follows standard Go patterns. The framework returns concrete errors that wrap the underlying driver error. Check for ent.IsNotFound when a query returns zero rows instead of a single record. The compiler will catch type mismatches early. If you pass a string to an integer field, you get a cannot use "value" (untyped string constant) as int value in argument error at compile time. You never hit a runtime panic for basic type errors. The worst database bug is the one that silently swallows an error. Return it. Wrap it. Let the caller decide.

When to reach for ent

Database access in Go offers several paths. Pick the tool that matches your project constraints.

Use ent when you want a fully type-safe, code-generated ORM that scales to complex schemas and relationships. Use sqlc when you prefer writing raw SQL queries and want the compiler to generate Go structs and type-safe wrappers around them. Use database/sql with sqlx when you need maximum control over query execution, connection pooling, and custom result mapping. Use an in-memory store or simple JSON files when your application is a prototype or handles less than a hundred records per day. Use a document database driver when your data is highly nested and schema flexibility matters more than relational integrity. Use a raw connection pool when you are building a database proxy or need to intercept queries at the protocol level.

Where to go next