Skip to content
gohacks.dev
Databases

GORM Gen Code Generation for Type Safe Queries Explained

Generate typed query code from a PostgreSQL schema with GORM Gen, run it end to end, and see the Go 1.25 pins it needed, the CI loop and where it fits

13 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

GORM's builder takes strings: Where("pages > ?", 300). A misspelled column is a run time error, a renamed column is a hunt through the codebase, and nothing stops you comparing a number column with a string. GORM Gen fixes that by generating Go code from the schema, so a column is a typed field and a wrong comparison is a compile error. It is the same idea as sqlc, reached from the other side: you keep GORM's query model and gain generated types, and you do not write SQL files. This article runs the whole loop against PostgreSQL 18.6: generate, build, query, and print what it did.

Tested with Go 1.25.6, GORM v1.31.2, gorm.io/gen v0.3.29, dbresolver v1.6.2, golang.org/x/tools v0.37.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

It follows the GORM tutorial and sits next to the generics API, which tries to reduce the same mistakes without a generation step. The comparison of the tools is in GORM vs sqlc vs Ent vs sqlx. For the database cluster as a whole, see the database cornerstone.

What Gen is

Gen has two parts. A generator, run during development, reads a source of truth (a live database, or your Go structs) and writes Go files. A runtime library, gorm.io/gen, which the generated code imports, provides the field types (field.Int32, field.String, field.Bool, field.Time...) and the query object that wraps GORM's *gorm.DB.

You get three things:

  1. A model struct per table, with GORM tags and JSON tags, derived from the column types.
  2. A query struct per table whose members are typed fields: Book.Pages, Book.Title. Each field has methods suited to its type: Gt, Between, Like, Desc, Add, Is.
  3. A query entry point, query.Use(db) or query.SetDefault(db) with a package level Book, from which you build statements.

The generated code is committed to the repository. That is a deliberate choice: the compiler checks it, reviewers can read what changed in a schema update, and the build does not need a database.

The generator

The generator is an ordinary Go program in its own directory. It creates the table for this lab (a real project points it at a migrated development database), configures output paths and runs the generator:

gen/main.goGo
package main

import (
	"log"

	"gorm.io/driver/postgres"
	"gorm.io/gen"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func main() {
	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})
	if err != nil {
		log.Fatal(err)
	}
	admin.Exec("drop schema if exists l0728 cascade")
	admin.Exec("create schema l0728")
	db, err := gorm.Open(postgres.Open(base+" search_path=l0728"), &gorm.Config{Logger: logger.Discard})
	if err != nil {
		log.Fatal(err)
	}
	db.Exec(`create table books (
		id bigserial primary key,
		title text not null,
		author text not null,
		pages int not null,
		published boolean not null default false
	)`)

	g := gen.NewGenerator(gen.Config{
		OutPath:      "query",
		ModelPkgPath: "model",
		Mode:         gen.WithDefaultQuery | gen.WithQueryInterface,
	})
	g.UseDB(db)
	g.ApplyBasic(g.GenerateModel("books"))
	g.Execute()
}

The settings that matter:

  • OutPath: "query" is where the query package goes.
  • ModelPkgPath: "model" sets the model package. Without it the models are written under the query path.
  • Mode: gen.WithDefaultQuery | gen.WithQueryInterface generates the package level query.Book and query.SetDefault, and a Query struct for dependency injection. Add gen.WithoutContext if you want to call the query without WithContext, which is not recommended for services.
  • g.GenerateModel("books") builds the model from the table. g.GenerateModelAs("books", "Book", opts...) renames it and lets you override field types or tags with options such as gen.FieldType and gen.FieldGORMTag.
  • g.ApplyBasic(...) generates the query code for those models. g.ApplyInterface(...) adds custom SQL methods (below).

Overriding what it generates

Defaults are right most of the time, and a schema with unusual types needs overrides. GenerateModel takes options. gen.FieldType("pages", "uint16") changes the Go type for one column. gen.FieldGORMTag("title", func(tag field.GormTag) field.GormTag { ... }) edits the tag. gen.FieldIgnore("internal_flag") leaves a column out of the model, and gen.FieldNew adds a field that has no column, such as a computed value. A global map on the config, WithDataTypeMap, rewrites a database type everywhere: map numeric to decimal.Decimal, or jsonb to your document type, once, instead of per column.

Keep the overrides in the generator program, in one place, so the generated code is reproducible. An edit made by hand to a generated file is lost on the next run, and if it was needed, nobody will know why the build changed.

Using the generated code

The application imports the two generated packages and uses them like this:

main.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	"example.test/lab/model"
	"example.test/lab/query"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

func main() {
	log.SetFlags(0)
	base := "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
	db, err := gorm.Open(postgres.Open(base+" search_path=l0728"), &gorm.Config{Logger: logger.Discard})
	if err != nil {
		log.Fatal(err)
	}
	query.SetDefault(db)
	b := query.Book
	ctx := context.Background()

	err = b.WithContext(ctx).Create(
		&model.Book{Title: "The Go Programming Language", Author: "Donovan", Pages: 380, Published: true},
		&model.Book{Title: "Concurrency in Go", Author: "Cox-Buday", Pages: 238, Published: true},
		&model.Book{Title: "Learning Go", Author: "Bodner", Pages: 375},
	)
	if err != nil {
		log.Fatal(err)
	}

	// Fields are typed: Pages is a field.Int32, Title is a field.String.
	long, err := b.WithContext(ctx).Where(b.Pages.Gt(300), b.Published.Is(true)).Order(b.Pages.Desc()).Find()
	if err != nil {
		log.Fatal(err)
	}
	for _, x := range long {
		fmt.Println("long and published:", x.Title, x.Pages)
	}

	// Find returns []*model.Book, First returns *model.Book.
	first, err := b.WithContext(ctx).Where(b.Author.Eq("Bodner")).First()
	fmt.Println("first by author:", first.Title, err)

	_, err = b.WithContext(ctx).Where(b.Author.Eq("nobody")).First()
	fmt.Println("missing:", err)

	n, _ := b.WithContext(ctx).Where(b.Title.Like("%Go%")).Count()
	fmt.Println("titles containing Go:", n)

	// The SQL that would run, without running it.
	sql := b.WithContext(ctx).Where(b.Pages.Between(200, 300)).Or(b.Author.Eq("Bodner")).UnderlyingDB().Session(&gorm.Session{DryRun: true}).Find(&[]model.Book{}).Statement.SQL.String()
	fmt.Println("sql:", sql)

	info, err := b.WithContext(ctx).Where(b.Published.Is(false)).Update(b.Published, true)
	fmt.Println("update rows:", info.RowsAffected, err)

	// Selecting a few columns.
	rows, _ := b.WithContext(ctx).Select(b.Title, b.Pages).Order(b.Title).Find()
	for _, r := range rows {
		fmt.Printf("slim row: %q author=%q pages=%d\n", r.Title, r.Author, r.Pages)
	}

	// A transaction hands back a Query bound to the transaction.
	q := query.Use(db)
	err = q.Transaction(func(tx *query.Query) error {
		res, err := tx.Book.WithContext(ctx).Where(tx.Book.Title.Eq("Learning Go")).Delete()
		fmt.Println("deleted inside tx:", res.RowsAffected, err)
		return errors.New("force a rollback")
	})
	left, _ := b.WithContext(ctx).Count()
	fmt.Println("transaction error:", err, "| books left:", left)

	// Pluck one column into a typed slice.
	var titles []string
	b.WithContext(ctx).Order(b.Pages).Pluck(b.Title, &titles)
	fmt.Println("titles by pages:", titles)
}

query.SetDefault(db) binds the package level query.Book to a connection. A service would use query.Use(db) and pass the *query.Query into its stores, so tests can supply their own.

The run

The script runs the generator, tidies the module and runs the program:

Shell
set -e
GOFLAGS=-mod=mod go run ./gen > gen.log 2>&1 || { cat gen.log; exit 1; }
rm gen.log
echo "generated:"
ls model query
go mod tidy 2>&1 | tail -3
go run .
Text
generated:
model:
books.gen.go

query:
books.gen.go
gen.go
long and published: The Go Programming Language 380
first by author: Learning Go <nil>
missing: record not found
titles containing Go: 3
sql: SELECT * FROM "books" WHERE ("books"."pages" BETWEEN $1 AND $2) OR "books"."author" = $3
update rows: 1 <nil>
slim row: "Concurrency in Go" author="" pages=238
slim row: "Learning Go" author="" pages=375
slim row: "The Go Programming Language" author="" pages=380
deleted inside tx: 1 <nil>
transaction error: force a rollback | books left: 3
titles by pages: [Concurrency in Go Learning Go The Go Programming Language]

The first lines list what the generator wrote: model/books.gen.go and query/books.gen.go with query/gen.go. The generated model is plain:

Go
type Book struct {
	ID        int64  `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
	Title     string `gorm:"column:title;not null" json:"title"`
	Author    string `gorm:"column:author;not null" json:"author"`
	Pages     int32  `gorm:"column:pages;not null" json:"pages"`
	Published bool   `gorm:"column:published;not null" json:"published"`
}

PostgreSQL's int became int32, bigserial became int64 with autoIncrement, text became string, and not null columns are not pointers. A nullable column would be a pointer, so absence is visible in the type. The JSON tags are snake case copies of the column names, which is convenient for a quick API and usually not what a public contract wants; map to a response type at the edge.

What the queries did

Typed filters. b.Pages.Gt(300), b.Published.Is(true) returned the one published book over 300 pages, The Go Programming Language 380. Gt on a field.Int32 takes an int32, so b.Pages.Gt("300") does not compile. The same field has Between, In, Add and Desc. A string field has Like, Regexp and Eq. That is the entire pitch: the type of the column is part of the call.

First and not found. b.Author.Eq("Bodner") with First() returned Learning Go. A filter matching nothing returned record not found, the same error as the rest of GORM, so errors.Is(err, gorm.ErrRecordNotFound) still applies.

Count. Where(b.Title.Like("%Go%")).Count() returned 3. Like the generics API, Gen's Count() returns an int64, not a destination.

Generated SQL. The program asks the statement for its SQL through a dry run: SELECT * FROM "books" WHERE ("books"."pages" BETWEEN $1 AND $2) OR "books"."author" = $3. Values are bound parameters. Typed does not mean different: it is the same GORM underneath, so the logger, the pool and the hooks behave as in the rest of this cluster.

Writes. Update(b.Published, true) over the unpublished books returned 1 row affected. Create(&a, &b, &c) took three models at once.

Select. Select(b.Title, b.Pages) returned rows with author="", as it should: the column was not loaded, and the zero value is what the struct holds. This is the usual partial load caveat, now spelled with typed columns, so the column list cannot contain a typo.

Custom SQL with typed methods

Gen can also generate methods from SQL you write in an interface comment. The shape is:

Go
type Querier interface {
	// SELECT * FROM @@table WHERE pages > @min ORDER BY pages
	LongerThan(min int) ([]gen.T, error)
}

You register it with g.ApplyInterface(func(Querier) {}, g.GenerateModel("books")), and the generator writes a LongerThan method on the query object with the SQL and a typed result. It gives a path for the queries the typed builder cannot express, and it comes close to what sqlc does with named queries. I did not run this part in the lab. It is described in the Gen documentation, and its templating (@@table, {{where}}, {{if}}) is a small language of its own.

Transactions and Pluck

The extended lab shows two more calls. q.Transaction(func(tx *query.Query) error {...}) hands the closure a Query bound to the transaction. Inside it, tx.Book.WithContext(ctx)...Delete() deleted one book (deleted inside tx: 1 <nil>), and the closure returned an error. The output afterwards is transaction error: force a rollback | books left: 3: the delete was undone, exactly as with GORM's own Transaction (transactions). The rule carries over unchanged: inside the closure use tx, never the outer b, or the statement runs on another connection and escapes the rollback. With Gen the mistake is easier to see, since tx.Book and query.Book are different objects.

Pluck(b.Title, &titles) filled a []string ordered by b.Pages: [Concurrency in Go Learning Go The Go Programming Language]. The typed column both selects and gives the destination its element type. Used with Order(b.Pages), the sort column is a field and not a string. That removes the allow list problem from the pagination article for sorts you offer, because only fields that exist as members of the generated struct can be named.

Generating from your structs

The lab generates from a table, which makes the database the source of truth. Gen can also start from Go: g.ApplyBasic(model.Book{}) generates the query code for a struct you wrote by hand, and you keep full control of tags, embedded types and field names. Choose by who owns the schema. If SQL migrations own it, generate models from the database so they cannot drift. If GORM models own it and you create tables with a migrator, generate from the structs. Mixing them, with hand written structs generated against a database that migrations own, brings back the drift problem described in the migrations article.

Relations

Gen generates relation fields for associations declared on the model (HasOne, HasMany, BelongsTo, Many2Many), including typed Preload. A call such as q.Author.Preload(q.Author.Books) takes the association as a field, not a string, so a rename is a compile error. The cost is a more complicated generated file per relation. The N plus one analysis in the Preload article applies unchanged: Gen builds the same statements, and you still count queries in a test. I did not generate relations in this lab, which uses one table.

Testing code that uses Gen

The generated Query struct is the seam for tests. A store takes a *query.Query rather than reading the package level query.Book, so a test can build one over a schema per test database (GORM testing). Avoid query.SetDefault in library code: it is a global, and parallel tests that call it overwrite each other. Use it in main for small programs and prefer Use(db) and dependency injection everywhere else.

The two pins this run needed

Running this on Go 1.25.6, the released gorm.io/gen v0.3.29 did not build as resolved. Two transitive dependencies broke:

  1. gorm.io/plugin/dbresolver v1.5.3 failed against GORM v1.31.2 with undefined: gorm.Stmt. Pinning dbresolver v1.6.2 fixed it.
  2. An old golang.org/x/tools (from May 2024) failed on Go 1.25 with invalid array length -delta * delta. Pinning golang.org/x/tools v0.37.0 fixed it.

Both pins are in the deps= list of the first block of the lab. If you adopt Gen and see either error, add the same requirements with go get. The general lesson applies to any generator: it is a dependency that must build with your toolchain, so pin it, run it in CI on the Go version you ship, and read the module graph when it fails. Also needed, in the lab's script: GOFLAGS=-mod=mod for the generator's first run so Go can add the missing go.sum lines before go mod tidy has the generated imports to see.

A workflow for a team

  1. Migrations own the schema. Run them against a development database.
  2. The generator reads that database. go run ./gen (or go generate) rewrites query/ and model/.
  3. Commit the generated files. Reviewers see exactly how a schema change affects types.
  4. CI regenerates and diffs. Run the generator against the migrated CI database, then git diff --exit-code. A migration without regenerated code fails the build, like sqlc's diff check (sqlc vet and diff).
  5. Hand written code stays outside the generated directories. Anything inside is overwritten.

Pin the generator version in go.mod and upgrade it deliberately, since the output format changes between releases and a bump should show up as one reviewed diff.

When Gen fits

  • You already use GORM and want compile time column names without leaving it.
  • The schema changes often and you want the compiler to find every affected query.
  • You prefer building queries in Go over writing SQL files.

When it does not

  • If you want to review the SQL text, a tool that stores it in files (sqlc) is more direct.
  • If the team is small and the schema stable, the generation step is overhead.
  • If your queries are mostly raw SQL, Gen's typed builder is not what you will use.
  • The generated package is large: this one table produced about 400 lines of query code. A schema with 200 tables produces a lot of generated code to compile and review.

Checklist

  • Run the generator from its own directory and commit the output.
  • Pin gorm.io/gen and its transitive modules on the Go version you ship.
  • Regenerate in CI and fail on a diff.
  • Put WithContext(ctx) on every statement in services.
  • Map generated models to response types at the API edge.

The generator options are documented in the GORM Gen guide, and the module is at github.com/go-gorm/gen.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.