If you're working with Go services that talk to a relational database, chances are you've bumped into GORM. It's the most widely used ORM in the Go ecosystem, and for good reason. It wraps a lot of the tedium of database/sql ,manual scanning, hand-written migrations, string-built queries in a much friendlier API.
This article walks through GORM from setup to the patterns you'll actually use day to day: models, migrations, CRUD, associations, transactions, and a few gotchas that trip people up.
Why reach for an ORM in Go ?
Go's standard database/sql package is deliberately low-level. You write SQL strings, manually scan rows into structs, and manage connections yourself. That's fine for small projects, but it gets repetitive fast once you have a dozen tables and endpoints that all need similar create/read/update/delete logic.
GORM sits on top of database/sql and gives you:
- Struct-based models mapped to tables
- Auto migrations
- A chainable query builder
- Associations (has-one, has-many, many-to-many, belongs-to)
- Hooks (before/after create, update, delete)
- Built-in support for transactions, connection pooling, and prepared statements
It supports PostgreSQL, MySQL, SQLite, SQL Server, and more, through swappable drivers.
Installation
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres
Enter fullscreen mode Exit fullscreen mode
Swap postgres for mysql, sqlite, or sqlserver depending on your database.
Connecting to a database
package main
import (
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
dsn := "host=localhost user=postgres password=secret dbname=myapp port=5432 sslmode=disable"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("failed to get generic db object: %v", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
}
Enter fullscreen mode Exit fullscreen mode
That db.DB() call gives you the underlying *sql.DB, which is where connection pool settings live. It's easy to forget this step and end up with an ORM that opens far more connections than your database can handle.
Defining models
GORM models are plain structs with tags:
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:100;not null"`
Email string `gorm:"uniqueIndex;not null"`
Posts []Post
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Post struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:255;not null"`
Body string
UserID uint
}
Enter fullscreen mode Exit fullscreen mode
-
CreatedAt/UpdatedAtare populated automatically by GORM , no extra code needed. -
DeletedAt gorm.DeletedAtenables soft deletes. Callingdb.Delete(&user)won't actually remove the row; it setsdeleted_atand every future query filters those rows out automatically. - Field-level tags (
size,not null,uniqueIndex) get translated into actual SQL constraints during migration.
Auto migrations
db.AutoMigrate(&User{}, &Post{})
Enter fullscreen mode Exit fullscreen mode
This creates tables if they don't exist and adds missing columns/indexes. It won't drop columns or change types that could cause data loss , which is a deliberate safety choice, but it also means AutoMigrate isn't a full substitute for a proper migration tool once you're in production. Many teams use it for local dev and rely on something like golang-migrate or atlas for production schema changes.
Basic CRUD
Create:
user := User{Name: "Amina Otieno", Email: "[email protected]"}
result := db.Create(&user)
if result.Error != nil {
log.Println(result.Error)
}
log.Println("New user ID:", user.ID)
Enter fullscreen mode Exit fullscreen mode
Read:
var user User
db.First(&user, 1)
db.First(&user, "email = ?", "[email protected]")
var users []User
db.Where("name LIKE ?", "%Otieno%").Find(&users)
Enter fullscreen mode Exit fullscreen mode
Update:
db.Model(&user).Update("name", "Amina O.")
// Update multiple fields
db.Model(&user).Updates(User{Name: "Amina O.", Email: "[email protected]"})
Enter fullscreen mode Exit fullscreen mode
Note: Updates with a struct only updates non-zero fields. If you need to set a field to its zero value (empty string, 0, false), use a map[string]interface{} instead.
Delete:
db.Delete(&user)
Enter fullscreen mode Exit fullscreen mode
Associations and preloading
Given the User/Post relationship above, GORM can eager-load associations to avoid N+1 query problems:
var users []User
db.Preload("Posts").Find(&users)
Enter fullscreen mode Exit fullscreen mode
This runs two queries total (one for users, one for all related posts), rather than one query per user. If you only need a subset of associated records, Preload accepts conditions too:
db.Preload("Posts", "created_at > ?", someDate).Find(&users)
Enter fullscreen mode Exit fullscreen mode
For many-to-many relationships, GORM manages the join table for you:
type Tag struct {
ID uint
Name string
Posts []Post `gorm:"many2many:post_tags;"`
}
Enter fullscreen mode Exit fullscreen mode
Transactions
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err // rolls back
}
if err := tx.Create(&Post{Title: "First post", UserID: user.ID}).Error; err != nil {
return err
}
return nil
})
if err != nil {
log.Println("transaction failed:", err)
}
Enter fullscreen mode Exit fullscreen mode
This is the pattern you want anywhere multiple writes need to succeed or fail together — say, deducting a balance and recording a ledger entry.
Hooks
GORM calls certain methods automatically if your model defines them:
func (u *User) BeforeCreate(tx *gorm.DB) error {
u.Email = strings.ToLower(u.Email)
return nil
}
Enter fullscreen mode Exit fullscreen mode
Available hooks include BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate, BeforeDelete, AfterDelete, and their *Save equivalents. Useful for normalization, validation, or audit logging without cluttering your handler code.
Things worth knowing
-
Zero values in updates. As mentioned above,
Updates()with a struct silently skips zero-valued fields. This bites people when they try to clear a field to""or0. -
Soft delete surprises. If
DeletedAtis present on a model, everyFind/First/Wherecall filters out soft-deleted rows by default. To include them, use.Unscoped(). -
N+1 queries. Forgetting
Preloadon associations is the most common performance issue in GORM codebases. Turn onlogger.Infomode in development so you can actually see the queries GORM is generating. -
Context propagation. Use
db.WithContext(ctx)in request-scoped code so query cancellation and timeouts actually work with your HTTP handler's context. -
Connection pool tuning. Don't skip
SetMaxOpenConns/SetMaxIdleConns, the defaults aren't tuned for production load.
GORM won't eliminate the need to understand SQL , and you shouldn't want it to , but it removes a lot of boilerplate around scanning rows, building migrations, and wiring up associations. For most Go backend services, especially ones backed by PostgreSQL, it hits a solid balance between productivity and control.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.