Mastering GORM: Go's Powerful ORM

GORM is Go’s most popular Object-Relational Mapping (ORM) library, providing an elegant way to work with databases using Go structs. Instead of writing raw SQL, GORM lets you interact with database records as Go objects, handling the translation between your code and the database automatically.

Why Use an ORM?

The fundamental challenge: bridging two worlds.

flowchart LR
    subgraph GoWorld["Go World"]
        S[Struct]
        M[Methods]
        T[Types]
    end

    subgraph DBWorld["Database World"]
        TB[Tables]
        R[Rows]
        C[Columns]
    end

    GoWorld <-->|ORM| DBWorld

    style GoWorld fill:#e3f2fd
    style DBWorld fill:#e8f5e9

Without an ORM, you’d write the same data definition twice:

1
2
3
4
5
6
7
8
9
10
// Go struct
type Product struct {
ID int
Name string
Description string
Price float64
}

// Plus SQL query
// SELECT id, name, description, price FROM products WHERE id = $1

GORM benefits:

  • Define data once in Go structs
  • Type safety at compile time
  • Automatic relationship management
  • Built-in query builder
  • Migrations and schema management

Getting Started with GORM

Installation

1
2
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres

Connecting to PostgreSQL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"log"

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

func main() {
dsn := "host=localhost user=appuser password=secret dbname=myapp port=5432 sslmode=disable"

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), // Log SQL queries
})
if err != nil {
log.Fatal("Failed to connect to database:", err)
}

// Get underlying sql.DB for connection pool settings
sqlDB, err := db.DB()
if err != nil {
log.Fatal(err)
}

sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
}

GORM Models

Basic Model Definition

GORM uses struct tags to configure database mapping:

1
2
3
4
5
6
7
8
9
10
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"size:50;uniqueIndex;not null"`
Email string `gorm:"size:255;uniqueIndex;not null"`
Password string `gorm:"not null" json:"-"`
IsActive bool `gorm:"default:true"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` // Soft delete support
}

Embedding gorm.Model

GORM provides a base model with common fields:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// gorm.Model includes:
// - ID uint (primary key)
// - CreatedAt time.Time
// - UpdatedAt time.Time
// - DeletedAt gorm.DeletedAt

type Product struct {
gorm.Model
Name string `gorm:"size:255;not null"`
Description string `gorm:"type:text"`
Price float64 `gorm:"not null;check:price > 0"`
Stock int `gorm:"default:0;check:stock >= 0"`
CategoryID uint
}

Common GORM Tags

Tag Description Example
primaryKey Mark as primary key gorm:"primaryKey"
size Column size gorm:"size:255"
type Column type gorm:"type:text"
uniqueIndex Unique index gorm:"uniqueIndex"
not null Not nullable gorm:"not null"
default Default value gorm:"default:0"
check Check constraint gorm:"check:price > 0"
index Create index gorm:"index"
column Custom column name gorm:"column:user_name"

CRUD Operations

Create

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Create single record
user := User{Username: "john", Email: "[email protected]", Password: "hashed"}
result := db.Create(&user)

if result.Error != nil {
log.Error("Failed to create user:", result.Error)
}
fmt.Printf("Created user with ID: %d\n", user.ID)

// Batch create
users := []User{
{Username: "alice", Email: "[email protected]"},
{Username: "bob", Email: "[email protected]"},
}
db.Create(&users)

// Create with selected fields only
db.Select("Username", "Email").Create(&user)

// Omit specific fields
db.Omit("Password").Create(&user)

Read

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Find by primary key
var user User
db.First(&user, 1) // SELECT * FROM users WHERE id = 1

// Find by primary key with string
db.First(&user, "id = ?", "1")

// Find by condition
db.First(&user, "username = ?", "john")

// Get all records
var users []User
db.Find(&users)

// Where conditions
db.Where("is_active = ?", true).Find(&users)
db.Where("username LIKE ?", "%john%").Find(&users)
db.Where("created_at > ?", time.Now().AddDate(0, -1, 0)).Find(&users)

// Multiple conditions
db.Where("is_active = ?", true).
Where("created_at > ?", lastWeek).
Find(&users)

// Or condition
db.Where("username = ?", "john").
Or("email = ?", "[email protected]").
First(&user)

// Select specific fields
db.Select("username", "email").Find(&users)

// Order, Limit, Offset
db.Order("created_at desc").Limit(10).Offset(0).Find(&users)

Update

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Update single field
db.Model(&user).Update("username", "johnny")

// Update multiple fields
db.Model(&user).Updates(User{Username: "johnny", Email: "[email protected]"})

// Update with map (includes zero values)
db.Model(&user).Updates(map[string]interface{}{
"username": "johnny",
"is_active": false,
})

// Update with conditions
db.Model(&User{}).Where("is_active = ?", false).Update("is_active", true)

// Batch update
db.Model(&User{}).Where("created_at < ?", lastYear).Update("is_active", false)

Delete

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Delete by primary key
db.Delete(&user, 1)

// Delete with condition
db.Where("is_active = ?", false).Delete(&User{})

// Soft delete (if DeletedAt field exists)
db.Delete(&user) // Sets deleted_at, doesn't remove row

// Permanently delete (bypass soft delete)
db.Unscoped().Delete(&user)

// Find soft-deleted records
db.Unscoped().Where("deleted_at IS NOT NULL").Find(&users)

Relationships

One-to-Many

1
2
3
4
5
6
7
8
9
10
11
12
13
type Category struct {
gorm.Model
Name string `gorm:"size:100;not null"`
Products []Product `gorm:"foreignKey:CategoryID"` // Has Many
}

type Product struct {
gorm.Model
Name string `gorm:"size:255;not null"`
Price float64
CategoryID uint
Category Category `gorm:"foreignKey:CategoryID"` // Belongs To
}
erDiagram
    CATEGORY ||--o{ PRODUCT : has
    CATEGORY {
        uint id PK
        string name
    }
    PRODUCT {
        uint id PK
        string name
        float price
        uint category_id FK
    }

Many-to-Many

1
2
3
4
5
6
7
8
9
10
11
type Product struct {
gorm.Model
Name string
Tags []Tag `gorm:"many2many:product_tags;"`
}

type Tag struct {
gorm.Model
Name string
Products []Product `gorm:"many2many:product_tags;"`
}

Working with Relationships

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Create with association
category := Category{Name: "Electronics"}
product := Product{Name: "Laptop", Price: 999.99, Category: category}
db.Create(&product)

// Preload associations
var products []Product
db.Preload("Category").Find(&products)

// Nested preload
db.Preload("Category").Preload("Tags").Find(&products)

// Conditional preload
db.Preload("Tags", "name LIKE ?", "%tech%").Find(&products)

// Append to association
db.Model(&product).Association("Tags").Append(&Tag{Name: "Sale"})

// Remove association
db.Model(&product).Association("Tags").Delete(&tag)

// Replace associations
db.Model(&product).Association("Tags").Replace(&newTags)

// Count associations
count := db.Model(&product).Association("Tags").Count()

GORM Hooks

Hooks allow you to execute code before/after CRUD operations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
type User struct {
gorm.Model
Username string
Email string
PasswordHash string
}

// BeforeCreate hook - hash password before saving
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.PasswordHash != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(u.PasswordHash), bcrypt.DefaultCost)
if err != nil {
return err
}
u.PasswordHash = string(hash)
}
return nil
}

// AfterCreate hook - log new user creation
func (u *User) AfterCreate(tx *gorm.DB) error {
log.Printf("New user created: ID=%d, Username=%s", u.ID, u.Username)
return nil
}

// BeforeUpdate hook - validate email format
func (u *User) BeforeUpdate(tx *gorm.DB) error {
if !isValidEmail(u.Email) {
return errors.New("invalid email format")
}
return nil
}

// AfterFind hook - mask sensitive data
func (u *User) AfterFind(tx *gorm.DB) error {
u.PasswordHash = "" // Clear password hash after loading
return nil
}
flowchart TD
    subgraph Create["Create Operation"]
        BC[BeforeCreate] --> DB1[Database Insert]
        DB1 --> AC[AfterCreate]
    end

    subgraph Read["Read Operation"]
        DB2[Database Select] --> AF[AfterFind]
    end

    subgraph Update["Update Operation"]
        BU[BeforeUpdate] --> DB3[Database Update]
        DB3 --> AU[AfterUpdate]
    end

    style Create fill:#e8f5e9
    style Read fill:#e3f2fd
    style Update fill:#fff3e0

Advanced Queries

Raw SQL

1
2
3
4
5
6
// Raw query
var users []User
db.Raw("SELECT * FROM users WHERE is_active = ?", true).Scan(&users)

// Exec for non-SELECT statements
db.Exec("UPDATE users SET is_active = ? WHERE last_login < ?", false, lastYear)

Scopes

Reusable query conditions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Define scopes
func Active(db *gorm.DB) *gorm.DB {
return db.Where("is_active = ?", true)
}

func Recent(days int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("created_at > ?", time.Now().AddDate(0, 0, -days))
}
}

func Paginate(page, pageSize int) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}

// Use scopes
db.Scopes(Active, Recent(7), Paginate(1, 10)).Find(&users)

Joins

1
2
3
4
5
6
7
8
9
10
11
12
type Result struct {
ProductName string
CategoryName string
Price float64
}

var results []Result
db.Model(&Product{}).
Select("products.name as product_name, categories.name as category_name, products.price").
Joins("LEFT JOIN categories ON products.category_id = categories.id").
Where("products.price > ?", 100).
Scan(&results)

Subqueries

1
2
3
4
5
6
7
// Products with above-average price
avgPrice := db.Model(&Product{}).Select("AVG(price)")
db.Where("price > (?)", avgPrice).Find(&products)

// Users with orders
usersWithOrders := db.Model(&Order{}).Select("user_id")
db.Where("id IN (?)", usersWithOrders).Find(&users)

Transactions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func TransferMoney(db *gorm.DB, fromID, toID uint, amount float64) error {
return db.Transaction(func(tx *gorm.DB) error {
// Deduct from sender
if err := tx.Model(&Account{}).
Where("id = ? AND balance >= ?", fromID, amount).
Update("balance", gorm.Expr("balance - ?", amount)).Error; err != nil {
return err
}

// Add to receiver
if err := tx.Model(&Account{}).
Where("id = ?", toID).
Update("balance", gorm.Expr("balance + ?", amount)).Error; err != nil {
return err
}

// Create transaction record
record := Transaction{FromID: fromID, ToID: toID, Amount: amount}
if err := tx.Create(&record).Error; err != nil {
return err
}

return nil // Commit
})
}

Auto-Migration

1
2
3
4
5
6
7
8
// Auto-migrate creates/updates tables
db.AutoMigrate(&User{}, &Product{}, &Category{}, &Order{})

// This will:
// - Create tables if they don't exist
// - Add missing columns
// - Add missing indexes
// - NOT delete columns or change types (safe by default)

Error Handling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func GetUser(db *gorm.DB, id uint) (*User, error) {
var user User
result := db.First(&user, id)

if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, result.Error
}

return &user, nil
}

// Check rows affected
result := db.Model(&User{}).Where("id = ?", id).Update("is_active", false)
if result.RowsAffected == 0 {
return ErrUserNotFound
}

Best Practices

Repository Pattern

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type UserRepository interface {
Create(user *User) error
FindByID(id uint) (*User, error)
FindByEmail(email string) (*User, error)
Update(user *User) error
Delete(id uint) error
List(page, pageSize int) ([]User, int64, error)
}

type userRepository struct {
db *gorm.DB
}

func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}

func (r *userRepository) FindByID(id uint) (*User, error) {
var user User
if err := r.db.First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}

func (r *userRepository) List(page, pageSize int) ([]User, int64, error) {
var users []User
var total int64

r.db.Model(&User{}).Count(&total)
err := r.db.Scopes(Paginate(page, pageSize)).Find(&users).Error

return users, total, err
}

Summary

Feature Key Points
Models Define schema with struct tags
CRUD Create, First, Find, Update, Delete
Relationships BelongsTo, HasMany, ManyToMany with Preload
Hooks BeforeCreate, AfterCreate, BeforeUpdate, etc.
Scopes Reusable query conditions
Transactions db.Transaction for atomic operations

Next post: Database Migrations in Go - Managing schema evolution safely with migration tools.

PostgreSQL Fundamentals for Go Developers Database Migrations in Go

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×