GORM: Dev's Guide to Go's Most Popular ORM

go dev.to

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 / UpdatedAt are populated automatically by GORM , no extra code needed.
  • DeletedAt gorm.DeletedAt enables soft deletes. Calling db.Delete(&user) won't actually remove the row; it sets deleted_at and 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: "amina@example.com"}
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 = ?", "amina@example.com")

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: "new@example.com"})
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

  1. 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 "" or 0.
  2. Soft delete surprises. If DeletedAt is present on a model, every Find/First/Where call filters out soft-deleted rows by default. To include them, use .Unscoped().
  3. N+1 queries. Forgetting Preload on associations is the most common performance issue in GORM codebases. Turn on logger.Info mode in development so you can actually see the queries GORM is generating.
  4. Context propagation. Use db.WithContext(ctx) in request-scoped code so query cancellation and timeouts actually work with your HTTP handler's context.
  5. 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.

Source: dev.to

arrow_back Back to Tutorials