40.4 GORM Models, Associations, and Migrations
Right, let’s talk GORM. If you’re coming from the stark, type-safe world of sqlc, this is going to feel like trading a meticulously calibrated laser for a magical wand that mostly works but occasionally sets your sleeve on fire. GORM is an ORM (Object-Relational Mapper), which means its entire job is to pretend that your database tables are Go structs and that you can just manipulate these structs without thinking too hard about the SQL being generated. It’s powerful, it’s convenient, and it will absolutely bite you if you don’t understand its incantations.
The core of this whole endeavor is the model. This is where you define the lie we’re telling the Go compiler about what your database looks like.
Your Base Model: The Bedrock of the Illusion
Before you define a single User or Product, you’ll want a base model for your primary keys. GORM has opinions, and its default one is that your primary key is an auto-incrementing integer named ID. You can embed this base struct everywhere.
// This is the foundation. Embed this in all your other models.
type BaseModel struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` // This enables soft delete. Magic!
}
// Now, let's define something real.
type User struct {
BaseModel
Email string `gorm:"uniqueIndex;not null"`
PasswordHash string `gorm:"not null"`
// A user HAS MANY credit cards. We'll get to that in a sec.
CreditCards []CreditCard
}
Why do it this way? Consistency and DRY. Every one of your tables will have an ID and timestamps. The DeletedAt field is your ticket to “soft deletes” – when you call Delete, GORM will just set the timestamp instead of actually nuking the row from orbit. This is fantastic for audit trails and a nightmare if you accidentally filter it out and wonder why you’re getting “deleted” data in your results.
Associations: The “Relational” Part of ORM
This is where GORM’s magic either shines or reveals the cheap tricks. Associations define how your structs relate to each other. Let’s give our user some credit cards.
type CreditCard struct {
BaseModel
Number string `gorm:"not null"`
UserID uint `gorm:"not null"` // This is the foreign key. Crucial.
User User // This is the "belongs to" reference.
}
You’ve just defined a relationship: A CreditCard belongs to a User. The UserID field is the literal foreign key in the database. The User field is for preloading – it’s what GORM populates when you use its eager loading. Now, look back at the User struct. The []CreditCard slice is the inverse: a User has many CreditCards.
To create a user with a card, you can do the “magic” thing:
user := User{
Email: "aria@example.com",
CreditCards: []CreditCard{
{Number: "4111111111111111"},
},
}
db.Create(&user) // GORM will handle both inserts in a transaction.
This is convenient, but it hides the database operations. Is it one query? Two? Is it wrapped in a transaction? (Yes, in this case, Create is transactional). This is the ORM trade-off: simplicity for transparency.
Migrations: Version Controlling Your Database
GORM can create tables for you. The AutoMigrate function is the most seductive and dangerous feature. It’s fantastic for early development.
err := db.AutoMigrate(&User{}, &CreditCard{})
What this does is… mostly what you’d expect. It creates tables, adds columns, adds indexes. What it wretchedly does not do is remove unused columns or alter column types in a destructive way. It’s non-destructive. This sounds good until you rename a field and suddenly have two columns forever: the old one and the new one, filled with NULL.
For any project beyond a prototype, do not rely on AutoMigrate for schema changes. It’s a tool for initial creation. For real migrations, use a dedicated migration tool like golang-migrate/migrate or Atlas. Write your DDL (the ALTER TABLE statements) by hand. You need that control. GORM’s auto-migration is like training wheels; eventually, you have to take them off or you’ll never learn to ride properly.
The Pitfalls: Where the Magic Fizzles
The N+1 Problem: This is the ORM classic. If you fetch a list of users and then loop through each one to access their
CreditCards, GORM will happily execute a separate query for each user. You will murder your database performance. The solution is eager loading:// GOOD: One query for users, one JOINed query for all their cards. var users []User db.Preload("CreditCards").Find(&users) // BAD: One query for users, then 100 queries for each user's cards. var users []User db.Find(&users) for _, user := range users { db.Model(&user).Association("CreditCards").Find(&user.CreditCards) }Selectivity: By default, GORM uses
SELECT *. This is lazy and often bad. You’re fetching data you don’t need. Get in the habit of being specific usingSelect.db.Model(&User{}).Select("id, email").Where("email LIKE ?", "%@example.com").Find(&users)Contexts: Always pass through a
context.Contextfor timeouts and cancellation. TheWithContextmethod is your friend.ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() db.WithContext(ctx).First(&user)
GORM is a tool, not a religion. Use it for the 95% of boring CRUD operations where its magic saves you time. But when you hit a complex query, a performance bottleneck, or need precise control, don’t be afraid to drop down to raw SQL. That’s what db.Raw and db.Exec are for. An ORM should be a supplement to your knowledge of SQL, not a replacement for it.