The dashboard that asks for everything
You are building a user dashboard. The frontend team asks for a list of users with names and emails. Two weeks later, the mobile team needs the same list but also wants phone numbers and join dates. A week after that, the admin panel needs users with their last login timestamp and role IDs. If you are using a standard REST endpoint, you are either over-fetching data for the mobile app or writing a new endpoint for the admin panel. GraphQL solves this by letting the client ask for exactly what it needs. GORM handles the database side. Combining them means your database layer stays clean while your API adapts to whatever the client requests.
The concierge and the librarian
Think of GORM as a librarian who knows exactly where every book is stored on the shelves. GORM speaks the language of tables, rows, and SQL. GraphQL is the concierge at the front desk. The concierge does not care about the shelves. The concierge takes a request from a visitor, asks the librarian for the specific books mentioned, and hands back only those books. The visitor never sees the shelves. The librarian never talks to the visitor.
You wire the concierge to the librarian using resolvers. A resolver is a function that bridges the gap. When GraphQL asks for a User, the resolver calls GORM to fetch the user from the database. The resolver translates the GraphQL request into a GORM query and translates the GORM result back into a GraphQL response. This separation keeps your data access logic independent of your API contract.
Wiring the schema to the database
Start with the schema. This file defines the contract between the client and the server. It lists the types and queries available.
# schema.graphql
# User type defines the fields clients can request.
type User {
id: ID!
name: String!
email: String!
}
# Query type lists all available read operations.
type Query {
users: [User!]!
}
GORM needs a struct to map to the database table. The struct fields correspond to columns. Tags configure constraints.
// model.go
// User maps to the users table in the database.
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"not null"`
Email string `gorm:"uniqueIndex;not null"`
}
The resolver connects the query to the database. It holds the GORM instance and implements the methods defined by the schema.
// resolver.go
// QueryResolver handles GraphQL queries.
type QueryResolver struct {
db *gorm.DB
}
// Users returns all users from the database.
func (r *QueryResolver) Users(ctx context.Context) ([]*User, error) {
var users []User
// GORM returns an error if the query fails.
if err := r.db.Find(&users).Error; err != nil {
return nil, err
}
// Convert slice of values to slice of pointers for GraphQL.
result := make([]*User, len(users))
for i := range users {
result[i] = &users[i]
}
return result, nil
}
Notice ctx context.Context is the first parameter. This is a hard convention in Go. Every function that might do I/O or take time should accept a context. It allows cancellation to propagate. The receiver name is r, a single letter matching the type. Go convention favors short receiver names like r or q over this or self. Run gofmt on your resolvers. The tool decides indentation. Do not argue about formatting.
Resolvers bridge the gap. Keep them focused.
How the request flows
When a client sends a query, gqlgen parses the request and looks for the users field in the schema. It finds the Query type and calls the Users method on your resolver. Your resolver runs r.db.Find(&users). GORM translates this into a SQL SELECT * FROM users statement. The database returns rows. GORM scans those rows into the users slice.
If the database is down, GORM returns an error. Your resolver checks err != nil. If there is an error, you return it. GraphQL catches the error and sends an error response back to the client. If everything works, you return the slice. GraphQL serializes the slice to JSON and sends it back.
The key is the separation. The resolver knows about GraphQL types and GORM models. It does not know about HTTP headers or JSON formatting. The if err != nil check is verbose by design. Go makes error handling explicit. You cannot ignore errors. This boilerplate ensures every failure path is visible. In GraphQL, returning an error stops the field resolution and marks the field as failed in the response. The rest of the query continues. This is different from HTTP where a 500 error might stop the whole request.
Context travels with the request. Pass it down.
Writing data and handling errors
Real apps need to write data. Mutations handle creation and updates. Define the input type and mutation in the schema.
# schema.graphql
# Input type defines fields for creating a user.
input CreateUserInput {
name: String!
email: String!
}
# Mutation type lists all available write operations.
type Mutation {
createUser(input: CreateUserInput!): User!
}
The mutation resolver validates input and persists data. It constructs a GORM model from the input and calls Create.
// resolver.go
// MutationResolver handles GraphQL mutations.
type MutationResolver struct {
db *gorm.DB
}
// CreateUser adds a new user to the database.
func (r *MutationResolver) CreateUser(ctx context.Context, input CreateUserInput) (*User, error) {
user := User{
Name: input.Name,
Email: input.Email,
}
// GORM Create returns an error on failure.
if err := r.db.Create(&user).Error; err != nil {
// Wrap the error to provide context.
return nil, fmt.Errorf("create user: %w", err)
}
return &user, nil
}
The resolver wraps the error using fmt.Errorf with %w. This preserves the original error chain. If the database returns a unique constraint violation, the wrapper keeps that information intact. The caller can unwrap the error later to check for specific conditions. This is better than returning a bare error.
gqlgen maps GraphQL types to Go types. You can configure this in gqlgen.yml. If your GORM model has ID uint and GraphQL has id: ID!, gqlgen handles the conversion. You can override this mapping if names differ. The convention is to keep names aligned to reduce configuration.
Mutations change state. Wrap errors to preserve context.
Pitfalls and performance traps
The N+1 problem happens when you fetch a list of users, and for each user, you fetch their posts. If you have 100 users, you run 101 queries. One for users, one per user for posts. This kills performance. The fix is to load related data in batch. Use GORM's Preload method.
// resolver.go
// Users returns users with their posts loaded efficiently.
func (r *QueryResolver) Users(ctx context.Context) ([]*User, error) {
var users []User
// Preload fetches related records in a single query.
if err := r.db.Preload("Posts").Find(&users).Error; err != nil {
return nil, err
}
result := make([]*User, len(users))
for i := range users {
result[i] = &users[i]
}
return result, nil
}
Pass the context to GORM to respect cancellation. GORM methods accept a context via WithContext. This links the database query to the GraphQL request lifecycle. If the client cancels the request, the context is cancelled. GORM sees the cancellation and stops the query. This prevents hanging connections.
// resolver.go
// UsersWithCtx returns users while respecting cancellation.
func (r *QueryResolver) UsersWithCtx(ctx context.Context) ([]*User, error) {
var users []User
// Pass context to GORM to handle cancellation.
if err := r.db.WithContext(ctx).Find(&users).Error; err != nil {
return nil, err
}
result := make([]*User, len(users))
for i := range users {
result[i] = &users[i]
}
return result, nil
}
If you forget to implement a resolver method, gqlgen generates a compile error. The error looks like resolver.go:10:3: cannot use &QueryResolver{} (value of type *QueryResolver) as resolver.QueryResolver value in argument. This happens when your struct does not match the interface gqlgen expects. If you change the schema but do not regenerate code, you might see undefined: User if the type is missing. Always run go generate after changing the schema.
A common runtime panic is nil pointer dereference. This happens if a resolver returns nil for a non-nullable field. If your schema says user: User!, the resolver must return a user or an error. Returning nil without an error causes GraphQL to panic because it cannot serialize nil as a required object.
Preload saves the database. Batch your loads.
When to use GraphQL with GORM
Use GraphQL with GORM when clients need flexible data shapes and you want to avoid over-fetching. Use a REST API with GORM when your data access patterns are simple and predictable. Use a data loader pattern when you have complex nested queries that risk N+1 performance issues. Use raw SQL queries when GORM's abstraction makes a complex join too slow or unreadable. Use gqlgen when you want type-safe code generation and strict schema validation. Use graphql-go when you prefer a fully manual implementation without code generation.
Use the right tool for the query pattern.