What is GORM?

The ORM that turns Go structs into SQL — models, queries, relations.

9 minmedium

Gin gets the request to your handler. The handler calls a service. The service calls GORM to actually touch the database. This lesson is GORM — the ORM that turns Go structs into SQL.

New to one of these? Skim the primers first so the rest of this lesson sticks.

What is an ORM?

An ORM (Object-Relational Mapper) is a library that lets you work with database rows as if they were native language objects. Instead of:

SELECT id, name, price_cents FROM products WHERE id = 42;

You write:

var p Product
db.First(&p, 42)

GORM generates the SQL, executes it, and unmarshals the row into your Product struct. You get type safety, less boilerplate, and code that reads like your domain.

The Model — your domain in a struct

apps/api/internal/models/product.go
package models
import "time"
type Product struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"not null;index"`
Slug string `gorm:"uniqueIndex;not null"`
PriceCents int `gorm:"not null"`
InStock bool `gorm:"default:true"`
UserID uint // foreign key — points to User
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` // soft delete
}

Four things to notice:

  • It's just a Go struct. No special base class, no "active record" magic. The TYPE is your domain; the tags tell GORM how to map it.
  • gorm: struct tags control SQL behaviour: primary key, indexes, uniqueness, defaults, not-null.
  • CreatedAt / UpdatedAt are managed by GORM automatically — set on insert, updated on save.
  • DeletedAt turns on soft delete. A row never disappears; it's marked deleted with a timestamp. Queries auto-filter it out.

From struct to table — AutoMigrate

apps/api/internal/db/db.go
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(
&models.User{},
&models.Product{},
&models.Order{},
)
}

AutoMigrate reads your struct definitions and either creates the table or adds missing columns. It will NOT drop columns or change types — by design. For schema removal you write a migration explicitly.

The five queries you'll write 95% of the time

// 1. Find one by primary key
var p Product
db.First(&p, 42) // SELECT * FROM products WHERE id = 42
// 2. Find one by a condition
db.Where("slug = ?", "fancy-thing").First(&p) // ... WHERE slug = ?
// 3. List many
var ps []Product
db.Where("in_stock = ?", true).Limit(20).Find(&ps) // ... WHERE in_stock = true LIMIT 20
// 4. Create
db.Create(&Product{Name: "New", PriceCents: 999}) // INSERT ...
// 5. Update
db.Model(&p).Update("price_cents", 1299) // UPDATE ... SET price_cents = ?

Notice the ? placeholders — GORM parameterises every value, so you can't SQL-inject by passing a string. Never build queries with fmt.Sprintf — always ?.

Relationships — the "hasMany" / "belongsTo" pair

type User struct {
ID uint
Email string
Products []Product // hasMany: a user can own many products
}
type Product struct {
ID uint
Name string
UserID uint // belongsTo: a product belongs to a user
User User // optional — for preload
}

Two-way: the "has many" side has a slice; the "belongs to" side has the foreign key (UserID) and optionally a struct field (User) for preloading.

Preloading — joining without joining

var u User
db.Preload("Products").First(&u, 7)
// Executes TWO queries:
// SELECT * FROM users WHERE id = 7
// SELECT * FROM products WHERE user_id = 7
// Then GORM stitches them together into u.Products

Preload runs a separate query rather than a JOIN. Two small queries usually beat one big JOIN with duplicated user data. Use Joins when you need a true SQL JOIN (e.g., filtering by a related field).

N+1 is the classic ORM trap. If you loop over users and call db.First(&p, u.ProductID) inside the loop, you'll fire one query per user. 1000 users = 1001 queries. The fix is Preload or Find(&products, ids) outside the loop. We'll revisit this when you build your first list endpoint.

How the DB connection reaches your service

DB injection in Grit

main.go │ │ db, _ := gorm.Open(postgres.Open(dsn)) // 1. open once │ ▼ internal/db.Migrate(db) // 2. ensure tables exist │ ▼ services.New(db) // 3. inject into services │ (every service holds a *gorm.DB pointer) │ ▼ handlers.New(services) // 4. handlers call services │ ▼ routes.Register(r, handlers) // 5. routes call handlers
GORM is opened once at startup and passed by pointer wherever a service needs it.

The *gorm.DB is a long-lived value with its own connection pool. Pass the pointer down; never open a new one per request.

The real list query — pagination + filter + count

apps/api/internal/services/product_service.go (typical)
func (s *ProductService) List(ctx context.Context, q ListQuery) ([]Product, int64, error) {
tx := s.db.WithContext(ctx).Model(&Product{})
if q.Search != "" {
tx = tx.Where("name ILIKE ?", "%"+q.Search+"%")
}
if q.InStockOnly {
tx = tx.Where("in_stock = ?", true)
}
var total int64
if err := tx.Count(&total).Error; err != nil { return nil, 0, err }
var items []Product
if err := tx.
Order("created_at DESC").
Offset((q.Page - 1) * q.PageSize).
Limit(q.PageSize).
Find(&items).Error; err != nil { return nil, 0, err }
return items, total, nil
}

Three observations:

  • WithContext — passes the request context so a cancelled request also cancels the DB query. Always do this in services.
  • Chaining — each .Where returns a new *gorm.DB, so you build up the query step-by-step. Reading bottom-up: ORDER, OFFSET, LIMIT, then execute.
  • Count BEFORE limit. Count(&total) ignores LIMIT/OFFSET (GORM is smart) so you get the true row count for pagination.

Quick check

You write db.Where(fmt.Sprintf("name = '%s'", input)).First(&p). Why is this a bug?

Try it

Practice the five queries:

  1. In a fresh Grit project, ensure the User model exists. Use gorm.io/playground or a unit test (the generated auth_test.go has a SQLite test DB you can borrow).
  2. Insert 3 users with db.Create.
  3. Find one by email with .Where("email = ?", ...).
  4. List all users created in the last hour using .Where("created_at > ?", time.Now().Add(-time.Hour)).
  5. Update one user's name with db.Model(&u).Update("name", ...).
  6. Soft-delete a user with db.Delete(&u) — then confirm a normal Find no longer returns them (GORM auto-filters soft-deleted rows).

What's next

Next lesson — Handler → Service pattern. Now that you know how Gin gets the request to your code AND how GORM gets it to the DB, the pattern in between is what every Grit endpoint follows.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub