What is GORM?
The ORM that turns Go structs into SQL — models, queries, relations.
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 Productdb.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
package modelsimport "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 UserCreatedAt time.TimeUpdatedAt time.TimeDeletedAt 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/UpdatedAtare managed by GORM automatically — set on insert, updated on save.DeletedAtturns on soft delete. A row never disappears; it's marked deleted with a timestamp. Queries auto-filter it out.
From struct to table — AutoMigrate
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 keyvar p Productdb.First(&p, 42) // SELECT * FROM products WHERE id = 42// 2. Find one by a conditiondb.Where("slug = ?", "fancy-thing").First(&p) // ... WHERE slug = ?// 3. List manyvar ps []Productdb.Where("in_stock = ?", true).Limit(20).Find(&ps) // ... WHERE in_stock = true LIMIT 20// 4. Createdb.Create(&Product{Name: "New", PriceCents: 999}) // INSERT ...// 5. Updatedb.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 uintEmail stringProducts []Product // hasMany: a user can own many products}type Product struct {ID uintName stringUserID uint // belongsTo: a product belongs to a userUser 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 Userdb.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).
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
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
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 int64if err := tx.Count(&total).Error; err != nil { return nil, 0, err }var items []Productif 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
.Wherereturns 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
db.Where(fmt.Sprintf("name = '%s'", input)).First(&p). Why is this a bug?Try it
Practice the five queries:
- In a fresh Grit project, ensure the User model exists. Use
gorm.io/playgroundor a unit test (the generatedauth_test.gohas a SQLite test DB you can borrow). - Insert 3 users with
db.Create. - Find one by email with
.Where("email = ?", ...). - List all users created in the last hour using
.Where("created_at > ?", time.Now().Add(-time.Hour)). - Update one user's name with
db.Model(&u).Update("name", ...). - Soft-delete a user with
db.Delete(&u)— then confirm a normalFindno 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