func preloadUserRoles(ctx context.Context, memberID int) func(db *gorm.DB) *gorm.DB {
serverID := contexts.GetServerID(ctx)
return func(db *gorm.DB) *gorm.DB {
return // some join/where query
However it seems no matter what tag I add to the struct my preload won't work.
// This ignores my preload
Roles []Role `gorm:"-"`
// These throw a "define a valid foreign key" error
Roles []Role `gorm:"->"`
Roles []Role `gorm:"<-:false"`
// This calls my custom preload, but GORM adds in an additional where clause that filters everything out
Roles []Role `gorm:"->;foreignKey:ID;references:ID"`
I'm not able to do something like this as I have two many to many tables that I need to take into account (hence the need for the custom Preload in the first place).
Maybe the only solution here is to manually fetch and add the data after the query? If that's the case I can do that; I had just wanted to use the Preload() as it's a bit cleaner code-wise.
GORM's Preload() with a custom function works well for one many-to-many relationship, but when your relationship spans two join tables, GORM's automatic preloading can't model it — and you're right that manual fetching is the cleanest solution.
Why the struct tags fail:
gorm:"-" — GORM skips the field entirely, so Preload("Roles", ...) can't find anything to preload
gorm:"->"/gorm:"<-:false" — marks the field as read-only, but GORM still tries to infer a foreign key and fails
gorm:"->;foreignKey:ID;references:ID" — this tells GORM to JOIN on roles.id = user.id, which creates the spurious WHERE clause you're seeing
For a standard single many-to-many (one join table), this works:
type User struct {
Roles []Role `gorm:"many2many:user_roles;"`
db.Preload("Roles", func(db *gorm.DB) *gorm.DB {
return db.Where("server_id = ?", serverID)
}).First(&user, id)
The scope function filters within GORM's generated query, so it works cleanly.
For two join tables (your case):
GORM can't natively express this relationship. Manual fetching is the correct approach and is perfectly idiomatic:
func (r *UserRepo) FindWithRoles(ctx context.Context, userID, serverID int) (*User, error) {
var user User
if err := r.db.WithContext(ctx).First(&user, userID).Error; err != nil {
return nil, err
var roles []Role
err := r.db.WithContext(ctx).
Table("roles").
Joins("JOIN user_role_assignments ura ON ura.role_id = roles.id AND ura.user_id = ?", userID).
Joins("JOIN server_assignments sa ON sa.id = ura.server_assignment_id AND sa.server_id = ?", serverID).
Find(&roles).Error
if err != nil {
return nil, err
user.Roles = roles
return &user, nil
Mark the struct field as gorm:"-" so GORM ignores it during regular queries/migrations, and populate it manually in the repository methods that need it.
This approach is explicit, testable, and doesn't fight GORM's preload mechanism.