Skip to content

ent (Go)

An "entity framework for Go" whose signature move is schema-as-code plus code generation: you declare your graph schema as ordinary Go types (type User struct { ent.Schema } with Fields()/Edges()/Indexes() methods), and ent generate emits a fully-typed, fluent client where every query, predicate, mutation, and edge traversal is statically typed and explicit — no interface{}, no reflection at query time.

FieldValue
LanguageGo (you write the schema in Go; entc generates Go)
LicenseApache-2.0 (Copyright 2019-present Facebook Inc.)
Repositoryent/ent
Documentationentgo.io · doc/md/
CategoryFull ORM (data-mapper)schema-as-code + codegen, graph-oriented; but explicit (no identity map / unit of work / lazy load)
Abstraction levelTop of the ladder (entities + relations + migrations), reached by generating a typed fluent builder rather than reflection
Query modelFluent typed builder — a generated method chain with one typed predicate per field (user.AgeGT(30)) and typed edge traversals
Effect/async modelBlocking — generated methods take a context.Context and run synchronously over database/sql; errors are Go error returns
BackendsMySQL, MariaDB, TiDB, PostgreSQL, CockroachDB, SQLite, and Gremlin (graph)
First release≈2019, open-sourced by Facebook Connectivity (web-attested)
Latest versionpre-v1.0; the v0.14.x series (web-attested; the v1 roadmap is issue #46)

NOTE

ent is this survey's data point for schema-as-code driving whole-client code generation — the mirror image of sqlc, which is query-first (raw SQL in, typed Go out). ent is schema-first and graph-shaped: the schema Go objects are the source of truth, and entc emits an entire typed ORM around them. It sits at the full-ORM rung by feature surface (entities, relations, migrations) yet deliberately drops the classic-ORM machinery from concepts — there is no identity map, no unit of work, no automatic change tracking, and no lazy loading (eager loading is explicit via WithX). Its typed client is closer to the compile-checked builders (Diesel, Beam, jOOQ) than to a reflective active-record ORM like GORM. See concepts for the shared vocabulary.


Overview

What it solves

ent removes the trade a Go developer faces between a hand-written database/sql mapper (typed but verbose, and you re-write rows.Scan for every query) and a reflection-based ORM like GORM (terse but stringly-typed, with runtime-string column references and interface{} everywhere). ent keeps full static typing and terseness by generating the mapper. The four headline properties are stated on the first screen of the README.md (README.md):

"Simple, yet powerful entity framework for Go, that makes it easy to build and maintain applications with large data-models."

  • "Schema As Code - model any database schema as Go objects."
  • "Easily Traverse Any Graph - run queries, aggregations and traverse any graph structure easily."
  • "Statically Typed And Explicit API - 100% statically typed and explicit API using code generation."
  • "Multi Storage Driver - supports MySQL, MariaDB, TiDB, PostgreSQL, CockroachDB, SQLite and Gremlin."

The "Schema As Code" and "Statically Typed … using code generation" clauses are the two pillars, and the getting-started guide restates them as design principles (doc/md/getting-started.mdx):

"- Easily model database schema as a graph structure.- Define schema as a programmatic Go code.- Static typing based on code generation.- Database queries and graph traversals are easy to write.- Simple to extend and customize using Go templates."

The lineage is explicit: the project was cloned from an internal Facebook/Meta framework (README.md):

"The ent project was inspired by Ent, an entity framework used internally at Meta (Facebook). It was created by a8m and alexsn from the Facebook Connectivity team. These days, it is developed and maintained by the Atlas team."

The Atlas hand-off matters downstream: ent's migration engine is Atlas (see Schema, migrations & code generation).

Design philosophy

The schema is Go, and it is the single source of truth. An ent schema is a Go type that embeds ent.Schema and answers a fixed set of methods — the codegen contract is the ent.Interface (ent.go):

"The Interface type describes the requirements for an exported type defined in the schema package. It functions as the interface between the user's schema types and codegen loader. Users should use the Schema type for embedding."

That interface exposes Fields(), Edges(), Indexes(), plus the extension hooks Mixin(), Hooks(), Interceptors(), Policy(), and Annotations() (ent.go). Because the schema is code, it is type-checked by the Go compiler before codegen even runs, refactored with ordinary tooling, and composed with Mixin (reusable field/edge/hook bundles). This is the defining contrast with the schema-first families: a .prisma file (Prisma) or a .sql DDL file (sqlc) is a separate artifact in a separate language; ent's schema is Go you import.

Codegen produces an explicit, static API — reflection is a non-goal. ent's promise is "100% statically typed and explicit," and the emitted client honours it: every column becomes a typed predicate function, every edge a typed traversal method, every entity a Go struct. A misspelled field or a type-mismatched comparison is a compile error, not a runtime one — the same guarantee Diesel and jOOQ sell, reached here by generating code rather than by type-level metaprogramming. The trade is the generated-code volume and the go generate step in the loop (see Weaknesses).

The data model is a graph. ent speaks of vertices (entities) and edges (relations) as first-class, not as an afterthought bolted onto tables. Edges are declared symmetrically (edge.To/edge.From with Ref), traversed with generated QueryX methods, and filtered with generated HasX/HasXWith predicates. The graph vocabulary runs all the way down to the SQL layer, whose translation package is literally named for it (dialect/sql/sqlgraph/graph.go): "Package sqlgraph provides graph abstraction capabilities on top of sql-based databases for ent codegen."


Connection, pooling & resource lifetime

ent opens a database through ent.Open(driverName, dataSourceName), which for the SQL dialects wraps the stdlib database/sql and returns a *ent.Client (examples/start/ent/client.go):

go
// Open opens a database/sql.DB specified by the driver name and
// the data source name, and returns a new client attached to it.
func Open(driverName, dataSourceName string, options ...Option) (*Client, error) {
    switch driverName {
    case dialect.MySQL, dialect.Postgres, dialect.SQLite:
        drv, err := sql.Open(driverName, dataSourceName)
        if err != nil {
            return nil, err
        }
        return NewClient(append(options, Driver(drv))...), nil
    default:
        return nil, fmt.Errorf("unsupported driver: %q", driverName)
    }
}

The *ent.Client is the long-lived object; sub-clients (client.User, client.Car, …) hang off it, one per schema type. Pooling is database/sql's job, not ent's: ent holds a dialect.Driver over the stdlib *sql.DB, whose pool sizing (SetMaxOpenConns, SetMaxIdleConns) you configure on the underlying handle via the sql integration driver. ent adds no pool of its own, no scoped acquire/release, and no lifetime type — the same minimalism as sqlc and Go database/sql generally, and the sharp contrast with the effect systems' scoped Acquirer from concepts. Resource cleanup is the idiomatic defer client.Close().


Query construction & injection safety

This is one of ent's two centres of gravity. The user never writes SQL text; they compose a chain of generated, typed methods, and the framework lowers that chain to a parameterized statement.

The generated typed client

From the canonical getting-started program, creating and querying a User (examples/start/start.go):

go
u, err := client.User.
    Create().
    SetAge(30).
    SetName("a8m").
    Save(ctx)

u, err := client.User.
    Query().
    Where(user.NameEQ("a8m")).
    // `Only` fails if no user found, or more than 1 user returned.
    Only(ctx)

SetAge(int) and SetName(string) are generated setters with the field's concrete Go type — passing a string to SetAge does not compile. user.NameEQ("a8m") is a generated typed predicate: the user package (one per schema type) exports a function per field-per-operator, each returning a predicate.User (examples/start/ent/user/where.go):

go
// AgeGT applies the GT predicate on the "age" field.
func AgeGT(v int) predicate.User {
    return predicate.User(sql.FieldGT(FieldAge, v))
}

// Name applies equality check predicate on the "name" field. It's identical to NameEQ.
func Name(v string) predicate.User {
    return predicate.User(sql.FieldEQ(FieldName, v))
}

The signature is the guarantee. user.AgeGT takes an int because age is field.Int; a wrong field name is an undefined symbol, a wrong value type is a type error — both caught by go build before the query ever runs. The available operators are enumerated per Go type in the docs (doc/md/predicates.md): numeric fields get =, !=, >, <, >=, <=, IN, NOT IN, string fields add Contains/HasPrefix/HasSuffix/ContainsFold, optional fields add IsNil/NotNil, and so on. Composite predicates use generated user.And/user.Or/car.Not.

Graph traversal as typed method chains

Edges are traversed, not joined by hand. From the same example, walking Group → Users → Cars (examples/start/start.go):

go
cars, err := client.Group.
    Query().
    Where(group.Name("GitHub")). // (Group(Name=GitHub),)
    QueryUsers().                // (User(Name=Ariel, Age=30),)
    QueryCars().                 // (Car(Model=Tesla, ...), Car(Model=Mazda, ...),)
    All(ctx)

Each QueryX is generated from an edge in the schema and is itself typed (QueryUsers() returns a *UserQuery, QueryCars() a *CarQuery), so the chain is checked end to end. Edge existence is a predicate too — user.HasCars() and user.HasCarsWith(car.ModelEQ("Ford")) compile to a subquery over the edge's foreign key or join table (examples/start/ent/user/where.go):

go
// HasCars applies the HasEdge predicate on the "cars" edge.
func HasCars() predicate.User {
    return predicate.User(func(s *sql.Selector) {
        step := sqlgraph.NewStep(
            sqlgraph.From(Table, FieldID),
            sqlgraph.Edge(sqlgraph.O2M, false, CarsTable, CarsColumn),
        )
        sqlgraph.HasNeighbors(s, step)
    })
}

Injection safety: the SQL builder binds every value

The whole typed chain lowers to dialect/sql, a thin, statically-typed wrapper over database/sql. Its package doc is candid about the division of labour (dialect/sql/builder.go):

"Package sql provides wrappers around the standard database/sql package to allow the generated code to interact with a statically-typed API. Users that are interacting with this package should be aware that the following builders don't check the given SQL syntax nor validate or escape user-inputs. ~All validations are expected to be happened in the generated ent package."

Values never enter the SQL text. The builder's Arg emits a dialect placeholder and stashes the value in a separate argument list (dialect/sql/builder.go):

go
// Arg appends an input argument to the builder.
func (b *Builder) Arg(a any) *Builder {
    // ...
    // Default placeholder param (MySQL and SQLite).
    format := "?"
    if b.postgres() {
        // Postgres' arguments are referenced using the syntax $n.
        format = "$" + strconv.Itoa(b.total+1)
    }
    // ...
    return b.Argf(format, a)
}

When the query executes, the rendered text and the collected arguments travel on separate channels — selector.Query() returns (query string, args []any), and the driver receives them apart (dialect/sql/sqlgraph/graph.go):

go
query, args := selector.Query()
if err := drv.Query(ctx, query, args, rows); err != nil {
    return err
}

Because every user value is a bind parameter, SQL injection is structurally impossible for values — the same parameter-binding model concepts describes, inherited from database/sql and identical in spirit to sqlc's const-string-plus-bind-args. The escape hatch is the raw dialect/sql builder and query modifiers (.Modify(...), sql.Expr), where — as the package doc warns — you own escaping; but the generated predicate/traversal surface never exposes a string-concatenation channel.


Schema, migrations & code generation

The other centre of gravity, and where the "framework" claim is earned: ent is a code generator (entc) plus an Atlas-backed migration engine wrapped around your Go schema.

Declaring entities, fields, edges, and indexes in Go

A schema is a Go struct embedding ent.Schema, with Fields() and Edges() methods returning builder values (examples/start/ent/schema/user.go):

go
type User struct {
    ent.Schema
}

func (User) Fields() []ent.Field {
    return []ent.Field{
        field.Int("age").
            Positive(),
        field.String("name").
            Default("unknown"),
    }
}

func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("cars", Car.Type),
        edge.From("groups", Group.Type).
            Ref("users"),
    }
}

Each builder is a fluent descriptor. field.String(...), field.Int(...), field.Time(...), field.JSON(...), field.Bool(...) create typed field builders (schema/field/field.go); .Optional() marks a field nullable ("Unlike edges, fields are required by default"), .Nillable() makes it a pointer in the struct ("'Nillable' fields are pointers in the generated struct"), .Immutable(), .Default(...), and validators like .Positive() or .Match(regexp) (examples/start/ent/schema/group.go) attach at declaration time. Edges are symmetric: edge.To defines an association, edge.From(...).Ref(...) its back-reference — one being Unique() makes it one-to-many, both makes it one-to-one (schema/edge/edge.go). index.Fields("first", "last").Unique() declares composite/unique indexes (schema/index/index.go). Each builder returns a *Descriptor that the loader reads.

entc: the code generator

go generate runs the ent CLI over the schema package (examples/start/ent/generate.go):

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

entc loads the schema types, builds a *gen.Graph, and renders the client with Go text/templates (entc/entc.go). The code-gen guide lists what one run produces (doc/md/code-gen.md):

"The generate command generates the following assets for the schemas:- Client and Tx objects used for interacting with the graph.- CRUD builders for each schema type.- Entity object (Go struct) for each of the schema types.- Package containing constants and predicates used for interacting with the builders.- A migrate package for SQL dialects.- A hook package for adding mutation middlewares."

The output is committed to the repo (every generated file carries Code generated by ent, DO NOT EDIT.) and is customizable through Go template extensions and schema Annotations (schema/schema.go). This is a code-first stance from concepts: the Go schema is the schema, and codegen flows outward from it — the opposite direction to sqlc (SQL query in) and to the db-first introspecting builders (jOOQ).

Migrations: powered by Atlas

ent does run migrations — via Atlas, the tool its maintainers also build. The generated client exposes an auto-migration entry point (doc/md/migrate.md):

go
if err := client.Schema.Create(ctx); err != nil {
    log.Fatalf("failed creating schema resources: %v", err)
}

"Create creates all database resources needed for your ent project. By default, Create works in an "append-only" mode; which means, it only creates new tables and indexes, appends columns to tables or extends column types."

Under the hood, the migration engine is Atlas — dialect/sql/schema/atlas.go imports ariga.io/atlas/sql/migrate / .../schema / .../sqlclient and defines an Atlas type: "Atlas atlas migration engine" (dialect/sql/schema/atlas.go). ent offers both stances Atlas supports: the declarative Schema.Create above (drive the DB toward the schema's desired state, optionally with migrate.WithDropColumn(true) / WithDropIndex(true)), and versioned migrations, where Atlas diffs the schema against a migration directory and writes numbered, reviewable SQL files (doc/md/versioned-migrations.mdx):

"Atlas loads the current state by executing the SQL files stored in the migration directory onto the provided dev database. It then compares this state against the desired state defined by the ent/schema package and writes a migration plan for moving from the current state to the desired state."

That two-mode migration story (declarative auto-migrate for dev, checksum-verified versioned files for prod) is richer than sqlc's (which reads schema but never applies it) and is a direct benefit of the Atlas dependency.


Type mapping & result decoding

Type mapping is fixed at generation time from the field's declared Go type, not discovered by reflection at query time. field.Int becomes int, field.String becomes string, field.Time becomes time.Time (with PkgPath: "time"), field.JSON(name, typ) becomes the given Go type serialized as JSON (schema/field/field.go). Nullability flows from the field modifiers: a required field is a plain value; .Optional() allows it to be unset, and .Nillable() makes the generated struct field a pointer (*string) so a database NULL is representable (schema/field/field.go). Overriding the storage type per dialect is .SchemaType(map[string]string{...}), and a custom Go type is .GoType(...).

Row hydration is generated Scan code. A query lowers through sqlgraph.QueryNodes, which builds the SELECT, runs it, and scans each row's columns into the entity struct in the fixed field order resolved at codegen — so decoding costs nothing beyond the driver's own Scan (dialect/sql/sqlgraph/graph.go). Related entities are not loaded automatically: eager loading is opt-in per query. client.User.Query().WithCars() populates each user's Edges.Cars by issuing a batched second query keyed on the parent IDs (examples/start/ent/user_query.go, doc/md/eager-load.mdx) — ent's answer to the N+1 problem is that a join or batch is explicit (WithX), never triggered by touching an unloaded field. Reading an edge that was not eager-loaded returns a NotLoadedError rather than silently firing a query (examples/start/ent/ent.go).


Effect model, transactions & error handling

The dimension this survey weights most, and where ent lands squarely at the blocking point — the generated client is ordinary synchronous Go.

Blocking calls, no effect value

Every terminal method takes a context.Context and returns (value, error) synchronously: Save(ctx), All(ctx), Only(ctx), Exec(ctx), Count(ctx) all run the statement and block until it returns (examples/start/start.go). There is no future, Task, IO, or ConnectionIO: a query builder is a mutable value you execute, not a description you interpret. Concurrency is the caller's job (a goroutine per unit of work), exactly as in sqlc and idiomatic Go. For the algebraic-effects lens of this survey, ent is — like GORM and sqlc — a "typed shape, untyped effect" data point: the result is exhaustively typed, but the effect is a bare, eager, side-effecting call, in contrast with doobie/skunk/Quill's reifiable effect values.

Transactions: client.Tx, commit/rollback, no savepoints

A transaction is a transactional client. client.Tx(ctx) returns a *ent.Tx whose sub-clients run inside the transaction; you drive it with Commit/Rollback (doc/md/transactions.md):

go
tx, err := client.Tx(ctx)
if err != nil {
    return fmt.Errorf("starting a transaction: %w", err)
}
hub, err := tx.Group.Create().SetName("Github").Save(ctx)
if err != nil {
    return rollback(tx, err) // rollback wraps tx.Rollback() + the error
}
// ...
return tx.Commit()

Isolation levels are exposed through client.BeginTx(ctx, &sql.TxOptions{Isolation: ...}) (doc/md/transactions.md, examples/start/ent/client.go). Two limits are findings for this survey. First, ent guards against re-entrancy — starting a transaction from a transactional client returns ErrTxStarted ("ent: cannot start a transaction within a transaction", examples/start/ent/client.go) — so there is no nested-transaction / savepoint model; a SAVEPOINT-based nested withTransaction like the effect systems' does not exist. Second, rollback on error is manual (the idiomatic defer/rollback helper), not an automatic property of a transaction combinator. There is a subtle lifetime wrinkle: entities created inside a Tx carry the transactional driver, so Unwrap() must be called to traverse their edges after commit (doc/md/transactions.md).

Errors: typed sentinels, but a plain Go error channel

Every method returns a bare error; ent does not reflect the failure set in the type. But unlike sqlc (which delegates entirely to the driver), ent generates a small taxonomy of typed error structs with Is* predicates (examples/start/ent/ent.go):

  • NotFoundErrorOnly/First found no row; test with ent.IsNotFound(err).
  • NotSingularErrorOnly found more than one row; ent.IsNotSingular(err).
  • ConstraintError"trying to create/update one or more entities and one or more of their constraints failed. For example, violation of edge or field uniqueness"; ent.IsConstraintError(err).
  • ValidationError — a schema validator (e.g. Positive(), Match(regexp)) rejected a value at mutation time; ent.IsValidationError(err).
  • NotLoadedError — an edge accessed on the struct was never eager-loaded; ent.IsNotLoaded(err).

These are recovered with errors.As/errors.Is and the generated helpers — richer than a raw driver error, but still a single untyped error return, not a type-level error channel like Effect TS's SqlError union or doobie's effect error type.

Middleware: hooks, interceptors, and the privacy layer

ent has an explicit policy layer that most lower-rung tools lack, wired through the schema. Three seams, all typed function-middleware (ent.go):

  • Hooks"the 'mutation middleware'. A function that gets a Mutator and returns a Mutator," run around every create/update/delete (the generated hook package).
  • Interceptors"an execution middleware for various types of Ent queries … invoked before the query executions," a natural fit for logging, caching, or soft-delete filters.
  • Privacy PolicyEvalMutation/EvalQuery rules attached in the schema. Its selling point (doc/md/privacy.mdx): "you write the privacy policy once (in the schema), and it is always evaluated. No matter where queries and mutations are performed in your codebase, it will always go through the privacy layer."

These are runtime middleware over the blocking calls — not an effect system — but they give ent a first-class authorization/observability story that sqlc and GORM push onto the caller.


Ecosystem & maturity

ent is a mature, widely-adopted framework under the permissive Apache-2.0 license (headers read Copyright 2019-present Facebook Inc., LICENSE). It was open-sourced in ≈2019 by the Facebook Connectivity team and is now developed and maintained by the Atlas team at Ariga (README.md); it remains pre-v1.0 (the v1 roadmap is tracked in issue #46), with the v0.14.x series current (web-attested).

Backends. The SQL dialects — PostgreSQL, MySQL, MariaDB, TiDB, CockroachDB, SQLite — share the dialect/sql builder and the Atlas-backed migrator; a Gremlin graph backend (AWS Neptune) exists in dialect/gremlin/ for the graph-database case (README.md, dialect/dialect.go). Indexes and some features are SQL-only ("indexes are implemented only for SQL dialects, and does not support gremlin," schema/index/index.go).

Extensibility and companions. ent's codegen is template-driven and extension-friendly; the most prominent extension is entgql (generate a GraphQL server + Relay-style pagination from the same schema), alongside the Atlas migration tooling and the elk/gRPC generators. The whole ecosystem rides the one schema-as-code definition. ent is a CNCF-adjacent, production-grade project with broad industrial use (web-attested).


Strengths

  • Schema-as-code, in Go. The schema is type-checked Go you refactor with normal tooling and compose with Mixin; no separate schema DSL to learn or keep in sync.
  • Fully static, explicit client. Generated typed predicates (user.AgeGT(30)), typed setters, and typed edge traversals mean wrong field/type/edge references are compile errors — no interface{}, no runtime-string columns as in GORM.
  • Graph model is first-class. Edges, traversals (QueryX), edge predicates (HasXWith), and explicit eager loading (WithX) make relations ergonomic without the ORM lazy-load foot-gun.
  • Injection-safe by construction. Every value binds as a ?/$n parameter through dialect/sql; the typed surface exposes no string-concatenation channel.
  • Real migrations via Atlas. Both declarative auto-migrate and versioned, checksum-verified migration files — a capability sqlc deliberately lacks.
  • Policy/hook/interceptor layer. Authorization, logging, and caching are first-class schema-level middleware, evaluated everywhere by construction.
  • Broad backend support. Six SQL engines plus a Gremlin graph backend behind one API.

Weaknesses

  • A go generate step in the loop. The client is a large committed artifact you regenerate on every schema change; the generated tree is verbose (a package and several files per entity).
  • Schema-as-Go learning curve. The edge.To/edge.From/Ref symmetry, Mixin, and the builder-descriptor model are more to learn than a flat DDL file or a reflective struct-tag ORM.
  • Blocking, untyped-effect model. Methods are eager synchronous calls returning a bare error; no effect value to inspect or interpret, and the error channel (though populated with typed sentinels) is Go's single error.
  • No savepoints or nested transactions. Re-entering Tx errors out; there is no SAVEPOINT-based nesting like the effect systems, and rollback-on-error is manual.
  • No dynamic-structure escape within the typed API. Predicates compose, but a truly runtime-shaped query (dynamic column list, dynamic ORDER BY) drops to the raw dialect/sql builder where you own correctness and escaping.
  • Pre-v1.0. The API is stable in practice and heavily used, but has not cut a 1.0.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Schema as Go code (ent.Schema + Fields/Edges methods)Compiler-checked, refactorable, composable (Mixin); no separate schema languageA Go-API learning curve; the schema lives in code, not a declarative file reviewers can diff
Generate the whole typed client (entc templates)100% static, explicit API — wrong field/type/edge is a compile error; no reflectionA go generate step and a large committed, verbose generated tree
Graph model (vertices + typed edges/traversals)Relations are first-class and ergonomic (QueryX, HasXWith, WithX)More schema vocabulary; the graph abstraction adds sqlgraph machinery between you and SQL
Explicit, no identity map / unit of work / lazy loadPredictable SQL; no hidden flush or surprise N+1; NotLoadedError over silent queriesYou lose classic-ORM conveniences (dirty tracking, transparent lazy loading)
Bind every value via dialect/sql (? / $n)Injection-safe by construction; dialect-portableThe raw builder escape hatch is unchecked — you own escaping there
Migrations via Atlas (declarative + versioned)Real, reviewable migrations; reuse the maintainers' dedicated migration engineA heavyweight ariga.io/atlas dependency; two migration modes to understand
Blocking methods, bare error + typed sentinelsMatch Go idiom; typed Is* predicates beat a raw driver errorNo effect value, no type-level error channel; transactions/savepoints are the caller's
Hooks / interceptors / privacy policy in the schemaCross-cutting concerns declared once, always appliedRuntime middleware, not an effect system; another layer of concepts to master

Sources