Ent GraphQL Integration With entgql and gqlgen Together
What entgql generates from an Ent schema: a Relay GraphQL schema, pagination, filters and mutation inputs, shown from a run, and what is left for gqlgen

GraphQL and Ent share a worldview. Both are graphs of typed entities with relations, and both reward a client that asks for exactly the fields it needs. The Ent project's GraphQL extension, entgql, acts on that: from your schema it writes a GraphQL schema with the Relay conventions already applied, and the Go glue that implements pagination, filtering and mutation inputs. You supply resolvers with gqlgen, which wires it all into a server. This article runs the extension on an entity, shows what it generates, and lists what remains for you. It is deliberately limited to what ran: the generated schema and code, and that the code builds with gqlgen. I did not build resolvers or serve queries.
Tested with Go 1.25.6, Ent v0.14.5, entgo.io/contrib (entgql), gqlgen v0.17.78, PostgreSQL schema only, macOS on Apple Silicon (arm64). The gqlgen resolvers and a running GraphQL server were not built.
It builds on the Ent tutorial and sits with gRPC and OpenAPI, which generate other kinds of contract. The query machinery behind the generated pagination is in Ent queries, and the authorisation layer you will want underneath is Ent privacy.
The pieces and who does what
| Piece | Who writes it |
|---|---|
The Ent schema with entgql annotations | you |
ent.graphql (types, connections, inputs) | entgql |
gql_*.go (pagination, filters, node lookup, inputs) | entgql |
gqlgen.yml and the resolvers | you (gqlgen generates stubs) |
Mutation type and mutation resolvers | you |
| Authentication, authorisation, limits | you |
The division matters because it shows where the generated code stops. entgql makes the data model queryable. It does not make a GraphQL API safe to expose.
The schema
The Todo entity has the three ingredients of a real list: a required text, an enum status and an integer priority, plus a self referential edge so a todo can have a parent and children. The annotations tell entgql what to expose:
package schema
import (
"entgo.io/contrib/entgql"
"entgo.io/ent"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)
type Todo struct{ ent.Schema }
func (Todo) Annotations() []schema.Annotation {
return []schema.Annotation{
entgql.RelayConnection(),
entgql.QueryField(),
entgql.Mutations(entgql.MutationCreate(), entgql.MutationUpdate()),
}
}
func (Todo) Fields() []ent.Field {
return []ent.Field{
field.String("text").NotEmpty(),
field.Enum("status").Values("IN_PROGRESS", "COMPLETED").Default("IN_PROGRESS").Annotations(entgql.Type("TodoStatus")),
field.Int("priority").Default(0).Annotations(entgql.OrderField("PRIORITY")),
}
}
func (Todo) Edges() []ent.Edge {
return []ent.Edge{edge.To("children", Todo.Type).From("parent").Unique()}
}entgql.RelayConnection()makes list fields follow the Relay connection specification: edges, cursors andpageInfo.entgql.QueryField()adds atodosfield to the rootQuerytype.entgql.Mutations(entgql.MutationCreate(), entgql.MutationUpdate())generatesCreateTodoInputandUpdateTodoInput, not mutations themselves.entgql.Type("TodoStatus")names the GraphQL enum for the Ent enum.entgql.OrderField("PRIORITY")makes the field sortable and adds it to the ordering enum.
The generator program
As with the other extensions, a small program runs entc.Generate with the extension:
//go:build ignore
package main
import (
"log"
"entgo.io/contrib/entgql"
"entgo.io/ent/entc"
"entgo.io/ent/entc/gen"
)
func main() {
ex, err := entgql.NewExtension(
entgql.WithSchemaGenerator(),
entgql.WithSchemaPath("../ent.graphql"),
entgql.WithWhereInputs(true),
)
if err != nil {
log.Fatalf("creating the entgql extension: %v", err)
}
if err := entc.Generate("./schema", &gen.Config{}, entc.Extensions(ex)); err != nil {
log.Fatalf("running ent codegen: %v", err)
}
}package ent
//go:generate go run -mod=mod entc.goWithSchemaGenerator() asks entgql to write the GraphQL schema, WithSchemaPath says where, and WithWhereInputs(true) adds filter input types. The script runs it, lists what appeared and prints the relevant parts of the schema:
set -e
(cd ent && GOFLAGS=-mod=mod go generate .)
go mod tidy > /dev/null 2>&1
echo "== files the extension added next to the usual client"
ls ent | grep '^gql_' | sed 's/^/ /'
echo "== lines of GraphQL schema: $(wc -l < ent.graphql | tr -d ' ')"
echo "== the generated types"
grep -E '^(type|input|enum|interface|scalar|extend type) ' ent.graphql | sed 's/ @.*//; s/ {$//'
echo "== the Todo type"
awk '/^type Todo implements Node/,/^}/' ent.graphql | grep -vE '^\s*(\"\"\"|#)' | grep -vE '^\s*$'
echo "== queries and mutations"
awk '/^type Query/,/^}/' ent.graphql | grep -E '^ [a-z]'
echo "mutations in the schema: $(grep -c '^type Mutation' ent.graphql || true)"
echo "== the generated Go compiles against gqlgen"
go build ./... && echo ok== files the extension added next to the usual client
gql_collection.go
gql_edge.go
gql_mutation_input.go
gql_node.go
gql_pagination.go
gql_transaction.go
gql_where_input.go
== lines of GraphQL schema: 256
== the generated types
input CreateTodoInput
scalar Cursor
interface Node
enum OrderDirection
type PageInfo
type Query
type Todo implements Node
type TodoConnection
type TodoEdge
input TodoOrder
enum TodoOrderField
enum TodoStatus
input TodoWhereInput
input UpdateTodoInput
== the Todo type
type Todo implements Node {
id: ID!
text: String!
status: TodoStatus!
priority: Int!
parent: Todo
children: [Todo!]
}
== queries and mutations
node(
nodes(
todos(
mutations in the schema: 0
== the generated Go compiles against gqlgen
okWhat it generated
Seven Go files appeared next to the usual client: gql_collection.go (field collection, so a query loads only the columns and edges the GraphQL selection asks for), gql_edge.go (edge resolvers), gql_mutation_input.go (the input types and their SetInput methods), gql_node.go (global object identification), gql_pagination.go (the connection types and the Paginate builder), gql_transaction.go (a transaction middleware for mutations) and gql_where_input.go (the filters).
A 256 line GraphQL schema. Its types, from the script:
- Relay plumbing:
scalar Cursor,interface Node,type PageInfo,enum OrderDirection. - Per entity:
type Todo implements Node,type TodoConnection,type TodoEdge,input TodoOrder,enum TodoOrderField,enum TodoStatus,input TodoWhereInput. - Mutation inputs:
input CreateTodoInputandinput UpdateTodoInput.
The entity type reads as the Ent schema did: id: ID!, text: String!, status: TodoStatus!, priority: Int!, parent: Todo and children: [Todo!]. The edge direction became fields, and the unique edge a nullable single value.
Three query fields. The generated Query has node, nodes and todos. node(id: ID!) and nodes(ids: [ID!]!) fetch any entity by its global id, which is how Relay clients refetch. todos is the connection with after, first, before, last, orderBy and where arguments (the schema text puts each on its own line; the script printed the first line of each field).
No Mutation type. The line mutations in the schema: 0 is the point to notice. entgql generates the input types (CreateTodoInput has text, status, priority, parentID and childIDs) and the Go methods that apply them (SetInput), and leaves the Mutation type to you. You declare it in your own .graphql file, for example a createTodo(input: CreateTodoInput!): Todo! field, and write a resolver that calls the client with the input. That is a sensible split: which mutations your API offers, and with what rules, is a product decision.
The Go compiles. go build ./... printed ok against gqlgen v0.17.78. entgql's generated files import gqlgen packages, so the pin matters. The latest gqlgen releases can require a Go toolchain newer than the one you run, and a generator that cannot build blocks the whole module. Pin gqlgen to a version that builds with your Go version and upgrade on purpose.
What the generated pieces do for you
The reason to use this at all is the list of problems it solves before you write a resolver.
Pagination. Relay cursor pagination is fiddly to implement. The generated Paginate method on a query takes after, first, before and last plus ordering and filters, and returns a connection with edges, cursors and pageInfo, using keyset style cursors over the ordered columns. That is the pagination shape keyset paging recommends, produced without hand written SQL.
Selecting only what was asked. gql_collection.go inspects the GraphQL selection set and applies Select and With... to the Ent query, so a request for todos { edges { node { text } } } does not load the priority column and a request that includes children eager loads them in one extra query instead of a query per todo. This is the part that avoids the N plus one problem GraphQL servers otherwise fall into (Preload and Joins describes the trap). It works when your resolvers pass the GraphQL context to the generated collection hooks, which is a step you must not skip.
Filtering. TodoWhereInput has a field for each predicate Ent generates (textContains, priorityGT, hasChildrenWith), combinable with and, or and not. Clients get a typed filter language without you designing one. It is also a large attack surface, covered below.
Global ids and node. The Node interface lets clients refetch any object by id. The generated code maps ids to types, so you do not write the lookup.
Where the work remains
Resolvers and gqlgen configuration. gqlgen.yml binds GraphQL types to the Ent types (autobind to the ent package, the ID scalar, the Node interface), and gqlgen generate writes resolver stubs that you fill in with calls to the Ent client. The generated query resolvers are one line each.
Authorisation. A generated GraphQL API exposes every entity you annotate, with arbitrary filters, to every caller. The place for rules is under the transport, in Ent privacy rules: a viewer in the context filters every query the resolvers issue, including those the collection hooks build. Add the viewer in an HTTP middleware.
Query cost limits. GraphQL lets a client write an expensive query: deep children nesting, huge first values, broad where filters. Set a maximum page size, a depth or complexity limit in gqlgen (it has a complexity calculator) and a statement timeout in the database. The generated filters make every column filterable, so index the columns you expect clients to use, or exclude the filter from fields that should not be filtered.
Error handling. Map Ent errors (IsNotFound, IsConstraintError, IsValidationError) to GraphQL errors with codes in an error presenter, so clients can tell a validation failure from an outage.
Schema evolution. Renaming a Go field can rename a GraphQL field. Annotate names where they matter, and diff ent.graphql in CI. GraphQL deprecation (@deprecated) is the tool for retiring fields.
The Go surface you will call
The generated Go code gives resolvers a small set of functions to call. I describe their roles from the generated file names and the Ent documentation, without having run a resolver, so check the signatures in your own generated package.
Paginateon a query builder.client.Todo.Query().Paginate(ctx, after, first, before, last, opts...)is the whole implementation of thetodosfield. It returns the connection type the schema declares, withEdges,PageInfoandTotalCount. The options carry the ordering and the filter:ent.WithTodoOrder(orderBy)andent.WithTodoFilter(where.Filter), converting the GraphQL inputs into Ent predicates.SetInputon create and update builders. A mutation resolver forcreateTodoisclient.Todo.Create().SetInput(input).Save(ctx). The input type's method applies only the fields the client sent, which is how optional fields stay optional.Noderand thenodelookups.client.Noder(ctx, id)resolves a global id to the right entity type, which backs thenodeandnodesfields.- Field collection. The generated
CollectFieldshook reads the GraphQL selection set from the context and turns it intoSelectandWith...calls on the Ent query, which is where the avoided N plus one comes from. A resolver that builds its own query and skips the hook loses that benefit and silently reintroduces per row queries.
The upshot is that a full set of resolvers for an entity is a handful of one line functions. The hard parts are the ones the framework cannot decide for you, listed above.
Testing a generated GraphQL API
Test it the way a client uses it, with real queries against a real database:
- Schema snapshot. Commit
ent.graphqland fail CI when it changes without review, as with the other generated contracts. A removed field is a breaking change for clients you have not met. - Query tests. Send documents through the gqlgen handler with
httptestand assert the JSON: a list query, a filtered query, a nested edge query, a pagination walk with cursors. - Statement counts. Count the SQL statements for a nested query and assert a constant, the check that confirms the field collection works (the same assertion for Ent reads 2 for a project with 30 tasks).
- Authorisation. Run every query as a viewer without rights and assert that nothing leaks, including through the
nodeandnodesfields, which fetch by id from any entity type. - Cost. Send a deliberately expensive query (deep nesting, a large
first) and assert that the limits reject it.
Compared with the other generated contracts
The same Ent schema can drive three contracts. They suit different clients:
| Contract | Strength | Weakness |
|---|---|---|
| GraphQL (entgql) | One endpoint, clients choose fields, relations traverse in one request | Cost control, caching and authorisation need deliberate design |
| gRPC (entproto) | Compact, typed, streaming, code generated clients in many languages | Browser access needs a proxy, field numbers are forever |
| REST (entoas) | Familiar, cacheable, easy for third parties | Many round trips for related data, a shape fixed per endpoint |
A common arrangement is gRPC between services, REST or GraphQL at the edge for web and mobile clients, and Ent privacy rules under all of them so the access rules live in one place.
Versioning and deprecation
GraphQL lets you evolve a schema without version numbers, and the rule is to add and deprecate, never remove. A new Ent field appears in the GraphQL type on the next generation, which is safe. A removed or renamed field breaks any client that selects it. Mark fields with @deprecated(reason: "...") through an entgql annotation for a release or two, watch usage in your server logs, and remove them once nothing asks for them. Treat ent.graphql as the public record: it is reviewed like code, and a pull request that deletes a line from it is a pull request that needs a plan.
When it fits
It fits when the API is mostly the entity graph: an admin console, an internal dashboard, a product with a rich client that explores relations. The pagination, filtering and selection-aware loading that you would otherwise write for each entity come for free. It fits less well when the API is a set of operations rather than a graph, or when it is public and its shape must be designed and frozen. As with the other generated contracts, a hand designed GraphQL schema over the Ent client is a fair middle road: you keep Ent for the data access and write the types yourself.
Checklist
- Annotate entities deliberately:
QueryField,RelayConnection, and only the mutation inputs you want. - Declare the
Mutationtype yourself and callSetInput. - Pin gqlgen to a version that builds on your Go version.
- Put authorisation in Ent privacy rules, not in resolvers.
- Add page size, depth and complexity limits, and index the filterable columns.
- Diff
ent.graphqlin CI.
The extension is documented in the entgql guide and the server framework in the gqlgen documentation.


