Session 08 · 🪢 Associations

Associations & Preload

The relationships you designed in Session 02, expressed in Go. GORM will load related rows for you — and the default way of asking is also the classic way to turn one query into two hundred.

✓
8ahas one · has many · many2many
✓
8bPreload and the N+1 trap
✓
8cPreload vs Joins

8a

Three relationships, three struct shapes

Association fields carry no column of their own. They tell GORM how to fetch rows from another table.

internal/model — the associations from Session 02
type Patient struct {
    ID            uint
    FullName      string

    // has one — the FK lives on the OTHER table (medical_records.patient_id)
    MedicalRecord *MedicalRecord `gorm:"foreignKey:PatientID"`

    // has many — same direction, many rows
    Appointments  []Appointment   `gorm:"foreignKey:PatientID"`
}

type Appointment struct {
    ID        uint
    PatientID uint      // the actual column
    DoctorID  uint

    // belongs to — the FK lives on THIS table
    Patient   Patient   `gorm:"foreignKey:PatientID"`
    Doctor    Doctor    `gorm:"foreignKey:DoctorID"`
}

type Doctor struct {
    ID           uint
    FullName     string

    // many2many — GORM manages the join table for you
    Specialities []Speciality `gorm:"many2many:doctor_specialities"`
}
has1has one / has many — the foreign key is on the other table. A patient "has" appointments because appointments.patient_id points back.
belbelongs to — the foreign key is on this table. An appointment belongs to a patient because it carries patient_id. Same relationship, opposite end.
m2mmany2many — name the join table and GORM writes both sides. If the join table carries its own columns (like certified_on), model it as an explicit entity instead.
✗Association fields are never populated automatically. A plain Find leaves Patient as a zero struct and Appointments as nil — you must ask.

8b

One list, two hundred queries

The N+1 problem is not exotic — it is what happens when you write the obvious loop. Walk it with Next and watch the query log.

N+1One query for the list, then one per row. It is invisible in development with three rows and fatal in production with two hundred — the page just gets slower every month as data grows.
✓Preload makes it two queries, always. GORM collects the foreign keys from the first result and fetches all the related rows with a single WHERE id IN (...).
→Nested preloads use dotted paths: Preload("Doctor.Specialities") adds one more query, not one per doctor.
→Preloads can be filtered: Preload("Appointments", func(db *gorm.DB) *gorm.DB { return db.Where("status = ?", "booked") }) — a scope, in the place you would expect one.

Turn the SQL log on in development and read it. logger.Default.LogMode(logger.Info) prints every statement. N+1 is obvious the moment you look, and essentially undetectable if you do not.

8c

Preload or Joins — they are not interchangeable

Both fetch related data. They produce different SQL, different row counts, and one of them cannot express a one-to-many at all.

Property
Preload
Joins
Statements
2 (or 1 per level)
1
Rows returned
no duplication
parent repeated per child
has many
correct
duplicates the parent
belongs to / has one
works
works, and is faster
Filter on the related table
filters the preload only
filters the whole result
the rule in practice
// belongs-to, and you need to FILTER on it → Joins. One statement, no duplication.
db.Joins("Doctor").
   Where("\"Doctor\".licence_no = ?", lic).
   Find(&appointments)

// has-many → Preload. A join here would repeat each patient once per appointment.
db.Preload("Appointments").Find(&patients)

// both, nested, still bounded: 3 statements regardless of row count
db.Preload("Appointments.Doctor").Find(&patients)
✓Filtering on a related table requires Joins. A Where alongside Preload filters the parent query, not the preload — and a filter inside the preload narrows the children without removing childless parents.
✗Never Joins a has-many into a slice of models. A patient with four appointments comes back as four patient rows; GORM has no way to collapse them and you get duplicates.
→Loading everything by reflex is its own problem. If the endpoint returns a list of appointment times, preloading each patient's full history is wasted work — Select the columns you will actually serialise.
TASK

Find the N+1, then remove it

(a) With logger.Info on, write the naive loop that lists tomorrow's appointments with each doctor's name, and count the statements in the log. (b) Fix it with Preload and confirm the count drops to two. (c) Now write "appointments whose doctor holds a cardiology speciality" — decide between Preload and Joins, implement it, and explain in one sentence why the other one gives the wrong answer.

Recap

Session 08 in four lines

1has one / has many put the foreign key on the other table; belongs to puts it on this one; many2many names a join table.
2Association fields are never loaded automatically — a plain Find leaves them zero.
3Looping over results to load relations is N+1; Preload makes it a fixed two statements with WHERE id IN (...).
4Use Joins to filter on a related table, Preload for has-many — joining a has-many duplicates the parent.