Despite Go’s “just write SQL” culture, ORMs exist and are popular. GORM is the most widely used. If you’re coming from Entity Framework and miss the productivity, GORM might ease the transition.
Fair warning: GORM is controversial in the Go community. Some love it, many avoid it. Let’s look at what it offers and the trade-offs.
Basic GORM Usage
Define a model:
import "gorm.io/gorm"
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:255;not null"`
Email string `gorm:"uniqueIndex;not null"`
Age int
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` // soft delete
}
Connect and auto-migrate:
import (
"gorm.io/gorm"
"gorm.io/driver/postgres"
)
dsn := "host=localhost user=postgres password=secret dbname=myapp"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
// Create/update tables from structs
db.AutoMigrate(&User{})
CRUD Operations
Create:
user := User{Name: "Alice", Email: "alice@example.com", Age: 30}
result := db.Create(&user)
// user.ID is populated after insert
if result.Error != nil {
return result.Error
}
fmt.Printf("Inserted %d rows\n", result.RowsAffected)
Read:
// By primary key
var user User
db.First(&user, 1) // find by ID
// By condition
db.First(&user, "email = ?", "alice@example.com")
// Multiple
var users []User
db.Where("age > ?", 25).Find(&users)
// All
db.Find(&users)
Update:
// Update single field
db.Model(&user).Update("Name", "Bob")
// Update multiple fields
db.Model(&user).Updates(User{Name: "Bob", Age: 31})
// Update with map (includes zero values)
db.Model(&user).Updates(map[string]interface{}{"Name": "Bob", "Age": 0})
Delete:
db.Delete(&user) // soft delete if DeletedAt field exists
db.Unscoped().Delete(&user) // hard delete
Comparing to Entity Framework
// EF Core
var user = await _context.Users.FindAsync(1);
user.Name = "Bob";
await _context.SaveChangesAsync();
// GORM
var user User
db.First(&user, 1)
db.Model(&user).Update("Name", "Bob")
Similar concepts, different APIs. GORM doesn’t have change tracking like EF—you explicitly call updates.
Relationships
One-to-Many:
type User struct {
ID uint
Name string
Orders []Order // has many
}
type Order struct {
ID uint
UserID uint // foreign key
Total float64
User User // belongs to
}
// Load with association
var user User
db.Preload("Orders").First(&user, 1)
for _, order := range user.Orders {
fmt.Println(order.Total)
}
Many-to-Many:
type User struct {
ID uint
Name string
Roles []Role `gorm:"many2many:user_roles;"`
}
type Role struct {
ID uint
Name string
}
// Load
db.Preload("Roles").First(&user, 1)
// Associate
db.Model(&user).Association("Roles").Append(&Role{Name: "admin"})
Preloading vs Eager Loading
Like EF’s Include, GORM has Preload:
// Single association
db.Preload("Orders").Find(&users)
// Nested
db.Preload("Orders.Items").Find(&users)
// Conditional preload
db.Preload("Orders", "total > ?", 100).Find(&users)
// All associations
db.Preload(clause.Associations).Find(&users)
No lazy loading by default. Explicit preloading only.
Raw SQL
When the ORM isn’t enough:
// Raw query
var users []User
db.Raw("SELECT * FROM users WHERE age > ?", 25).Scan(&users)
// Raw exec
db.Exec("UPDATE users SET age = age + 1 WHERE birthday = ?", today)
// Mix GORM and raw
db.Where("age > ?", 25).Order("name").Find(&users)
Transactions
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err // rollback
}
if err := tx.Create(&order).Error; err != nil {
return err // rollback
}
return nil // commit
})
Or manual:
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// ... operations on tx ...
tx.Commit()
Hooks (Lifecycle Events)
func (u *User) BeforeCreate(tx *gorm.DB) error {
u.CreatedAt = time.Now()
return nil
}
func (u *User) AfterCreate(tx *gorm.DB) error {
// Send welcome email
return sendWelcomeEmail(u.Email)
}
Like EF’s SaveChanges interceptors or domain events.
The Controversies
GORM has critics. Common complaints:
1. Magic and Reflection
GORM uses reflection heavily. Errors can be cryptic, and behaviour isn’t always obvious.
db.Where("name = ?", name).First(&user)
// What SQL does this generate? Have to check docs or logs.
2. Struct Tag Complexity
type Product struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Code string `gorm:"type:varchar(100);uniqueIndex"`
Price float64 `gorm:"precision:2"`
CreatedAt time.Time `gorm:"autoCreateTime"`
}
The tags can get complex. Errors are runtime, not compile time.
3. Query Builder Limitations
Complex queries sometimes fight the API:
// This gets awkward
db.Where("status = ? AND (priority = ? OR deadline < ?)",
"active", "high", time.Now())
4. Performance
Reflection has overhead. For high-throughput scenarios, raw SQL is faster.
Alternatives to GORM
sqlx: Not an ORM, just convenience over database/sql:
var users []User
db.SelectContext(ctx, &users, "SELECT * FROM users WHERE age > $1", 25)
ent: Facebook’s ORM, generates type-safe code:
client.User.Query().
Where(user.AgeGT(25)).
All(ctx)
sqlc: Generates Go code from SQL:
-- queries.sql
-- name: GetUser :one
SELECT * FROM users WHERE id = $1;
// Generated code
user, err := queries.GetUser(ctx, userID)
bun: Lightweight ORM with better SQL control:
err := db.NewSelect().
Model(&users).
Where("age > ?", 25).
Scan(ctx)
The Honest Take
GORM vs EF Core isn’t a fair fight. EF is more mature, better integrated with .NET, and has superior tooling.
When GORM makes sense:
- You want EF-like productivity
- Your queries are mostly CRUD
- You’re okay with the magic
- Team is familiar with ORMs
When to avoid GORM:
- Complex queries are common
- Performance is critical
- You prefer explicit SQL
- Team prefers “no magic”
What GORM does well:
- Fast development
- Relationship handling
- Migrations (basic)
- Familiar to ORM users
What EF does better:
- LINQ (type-safe queries)
- Change tracking
- Migration tooling
- Integration with ASP.NET
The verdict:
If you’re building a CRUD service and miss EF’s productivity, GORM is reasonable. If you’re doing complex queries or care about explicit control, stick with database/sql + sqlx.
Many Go developers start with GORM, then move to sqlx or sqlc as they get comfortable with Go’s explicit style. That’s a valid journey.
Next up: tooling—linting, formatting, and why gofmt is non-negotiable.