GORM Generics API in Newer Releases and How to Use It
Use gorm.G[T] for type safe queries with a required context: Create, Where, Find, First, Count, Update and Delete, run on PostgreSQL with each result printed

For years the shape of a GORM call has been db.Where(...).Find(&dest): you pass a pointer to somewhere to put the result, and you read the error from the returned handle. It works, and it is a source of small mistakes: a Find into a struct where a slice was meant, a forgotten .Error, a forgotten WithContext. Recent releases add a generics based API that makes the result a return value and the context a required argument. This article runs it against PostgreSQL 18.6 with GORM v1.31.2 and prints each result, so you can judge whether it is worth adopting.
Tested with Go 1.25.6, GORM v1.31.2, gorm.io/driver/postgres v1.6.3, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It builds on the GORM tutorial and context handling. For the language feature behind it, Go generics without the confusion is the background, and the code generation alternative with similar goals is GORM Gen.
The entry point
gorm.G[T](db) returns an interface specialised to the model type T. Everything chains from it, and every finisher takes a context first:
package main
import (
"context"
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func must(err error) {
if err != nil {
log.SetFlags(0)
log.Fatal(err)
}
}
// connect recreates a schema and returns a connection whose search_path points at it.
func connect(schema string, cfg *gorm.Config) *gorm.DB {
base := "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
admin, err := gorm.Open(postgres.Open(base), &gorm.Config{Logger: logger.Discard})
must(err)
must(admin.Exec("drop schema if exists " + schema + " cascade").Error)
must(admin.Exec("create schema " + schema).Error)
if cfg == nil {
cfg = &gorm.Config{Logger: logger.Discard}
}
db, err := gorm.Open(postgres.Open(base+" search_path="+schema), cfg)
must(err)
return db
}
type Task struct {
ID uint
Title string
Done bool
}
func main() {
db := connect("l0727", nil)
must(db.AutoMigrate(&Task{}))
ctx := context.Background()
// The generic entry point: the type is a parameter, and every call takes a context.
tasks := gorm.G[Task](db)
must(tasks.Create(ctx, &Task{Title: "write the article"}))
must(tasks.CreateInBatches(ctx, &[]Task{{Title: "run the lab"}, {Title: "publish", Done: true}}, 10))
open, err := tasks.Where("done = ?", false).Order("id").Find(ctx)
must(err)
fmt.Printf("open tasks: %d, first: %q (a []Task, not a destination)\n", len(open), open[0].Title)
first, err := tasks.Where("title = ?", "publish").First(ctx)
must(err)
fmt.Println("First returns a Task value:", first.Title, first.Done)
_, err = tasks.Where("title = ?", "nothing").First(ctx)
fmt.Println("missing:", err)
n, err := tasks.Where("done = ?", true).Count(ctx, "*")
must(err)
fmt.Println("done count:", n)
rows, err := tasks.Where("done = ?", false).Update(ctx, "done", true)
must(err)
fmt.Println("update rows:", rows)
rows, err = tasks.Where("id = ?", 1).Delete(ctx)
must(err)
fmt.Println("delete rows:", rows)
// The old API still works on the same handle.
var legacy []Task
db.Find(&legacy)
fmt.Println("old API sees:", len(legacy))
}open tasks: 2, first: "write the article" (a []Task, not a destination)
First returns a Task value: publish true
missing: record not found
done count: 1
update rows: 2
delete rows: 1
old API sees: 2The type parameter is the model, so gorm.G[Task](db) fixes the table and the result type for the rest of the chain. Create the interface once per call, or keep it in a variable like tasks as the lab does. It holds no state of its own beyond the connection.
What each call returned
Create. tasks.Create(ctx, &Task{...}) inserted one row and returned only an error. CreateInBatches(ctx, &slice, 10) inserted two more. The first argument after the context is a pointer to the value, as before, so IDs are filled in.
Find. tasks.Where("done = ?", false).Order("id").Find(ctx) returned []Task and an error. The lab printed open tasks: 2, first: "write the article". The slice is the return value, so there is no destination variable to declare, and no way to pass the wrong kind of pointer. An empty result is an empty slice and a nil error, the same as the old API.
First. First(ctx) returned a Task value, not a pointer, and the lab printed publish true. A query matching nothing returned record not found. That is gorm.ErrRecordNotFound, so the errors.Is check from the classic API still works unchanged. Take and Last follow the same pattern.
Count. Count(ctx, "*") returned 1. The second argument is the column expression ("*" for all rows, or "distinct owner"), and the result is an int64. It is explicit where the old Count(&n) took a pointer.
Update and Delete. Update(ctx, "done", true) returned 2, the rows affected, and Delete(ctx) returned 1. In the old API you read RowsAffected from the returned *gorm.DB and have to remember to look. Here the number is the first return value, and the habit of checking it is easier to keep: if rows == 0 { return ErrNotFound }.
Mixed use. The last line of the lab called db.Find(&legacy) with the old API on the same connection and saw the 2 remaining rows. The two APIs sit on the same statement builder and connection pool, so a codebase can move over one query at a time.
What it changes
The context is mandatory. Every finisher takes ctx as its first argument. Forgetting WithContext in the classic API is silent, and a query without a deadline can hold a connection forever. Making the argument part of the signature moves that bug from a review comment to a compile error. This is the strongest argument for adopting the generics API in service code.
Results are values. Returning []Task and Task removes the destination pointer and the intermediate .Error read, and reads closer to ordinary Go. It also makes functions easier to write: a store method returns tasks.Where(...).Find(ctx) directly.
The builder is the same. Where, Order, Limit, Joins, Preload and the clauses behave as before, because they build on the same internals. What you know about scopes, locking and Preload applies.
What it does not change
- It is not a type safe column API.
Where("done = ?", false)is still a string. A misspelled column fails at run time. If you want columns as typed fields, that is what GORM Gen generates, at the cost of a generation step. - It does not change the generated SQL. The statements are the same, with the same N plus one risks and the same default transaction behaviour (performance mistakes).
- It does not hide the pointer rules for writes. You still pass a pointer to
Createso that the key can be filled in. - Not every feature is exposed. Some options of the older chain have no counterpart in the generic interface yet, such as certain
Sessionsettings. When one is missing, drop back to the classic call for that query, which the mixed use above allows.
I tested the operations in the lab. Releases add to this interface, so check the version you pin against the release notes before relying on a method.
Adopting it
A conservative path for an existing service:
- Pin GORM at a version that includes the generics API (v1.31.2 here).
- Write new store methods with
gorm.G[T], passing the caller's context. - Leave existing code alone. Convert a method when you next change it.
- Add a lint or review rule: no new
db.Find(&...)withoutWithContextin service code.
For a store layer that wraps GORM behind an interface, the change is invisible to callers: the method signatures already take a context and return values, and only the bodies change. A repository method often shrinks to one line:
func (s *Store) OpenTasks(ctx context.Context) ([]Task, error) {
return gorm.G[Task](s.db).Where("done = ?", false).Order("id").Find(ctx)
}That is the whole method. In the classic API the same function needs a variable, a WithContext, a call and an .Error return.
When not to bother
If your code already passes context everywhere through a store layer and has tests, the gain is a few lines per method. If you rely on features the generic interface lacks, you will have two styles in one codebase, which has its own cost. The generics API is most valuable in new code and in teams that keep forgetting contexts.
Checklist
- Use
gorm.G[T](db)for new store methods and pass the request context to every finisher. - Keep matching
record not foundwitherrors.Is(err, gorm.ErrRecordNotFound). - Use the rows affected return from
UpdateandDelete. - Fall back to the classic API for features the generic interface lacks.
- Do not expect type safe column names; use GORM Gen for that.
The API is described in the GORM generics documentation and the Go language feature in the Go generics tutorial.


