Relations

HasMany, BelongsTo, ManyToMany.

8 minmedium

Real domains have shapes — a customer has many invoices, an invoice belongs to a customer, a tag belongs to many products. GORM has one struct tag per relation kind. This lesson covers all three.

1. BelongsTo (the foreign key sits on this row)

apps/api/internal/models/invoice.go
type Invoice struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id"`
Customer Customer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"`
Total decimal.Decimal `gorm:"type:decimal(10,2);not null" json:"total"`
CreatedAt time.Time `json:"created_at"`
}

Two pieces — the FK column (CustomerID) and the relation field (Customer Customer). Use Preload to eager-load:

var invoice Invoice
db.Preload("Customer").First(&invoice, "id = ?", id)
// invoice.Customer.Name is now populated

2. HasMany (the other table owns the FK)

apps/api/internal/models/customer.go
type Customer struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
Invoices []Invoice `gorm:"foreignKey:CustomerID" json:"invoices,omitempty"`
}

Invoices []Invoice means "this customer has many invoices". The FK lives on the Invoice row. Eager-load the same way:

var c Customer
db.Preload("Invoices").First(&c, "id = ?", id)
// c.Invoices is a slice

3. Many-to-Many (a join table)

type Product struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
Tags []Tag `gorm:"many2many:product_tags;" json:"tags,omitempty"`
}
type Tag struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
Name string `gorm:"uniqueIndex;not null"`
}

GORM creates the product_tags join table for you. Add a tag:

db.Model(&product).Association("Tags").Append(&tag)
Naming convention: the FK is always <RelationName>ID in the model and <relation_name>_id in the DB (snake_case). GORM follows this automatically; you only need foreignKey: when overriding.

OnDelete behaviour

What happens when you delete a customer with invoices? Three choices:

// Cascade — delete invoices too
Customer Customer `gorm:"foreignKey:CustomerID;constraint:OnDelete:CASCADE"`
// Set null — keep invoices but null out CustomerID (need nullable FK)
Customer Customer `gorm:"foreignKey:CustomerID;constraint:OnDelete:SET NULL"`
// Default: restrict — refuse to delete the customer if invoices exist

Production-grade Grit projects tend to use RESTRICT + soft-delete the parent. Cascade is irreversible — pick it consciously.

Quick check

You declared Customer Customer `gorm:"foreignKey:CustomerID"` but db.Preload("Customer").First(...) returns Customer with all-zero fields. What's most likely wrong?

Try it

Add the invoice domain to your bench-api:

  1. Customer (HasMany Invoices)
  2. Invoice (BelongsTo Customer, HasMany LineItems)
  3. LineItem (BelongsTo Invoice)

Wire each with the right FK + relation fields. Run grit migrate and confirm via GORM Studio that the FK columns exist. Paste the schema in notes.md.

What's next

Models compile, AutoMigrate runs. But should you use AutoMigrate forever? Last lesson of this chapter: when AutoMigrate is fine and when to switch to explicit migrations.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub