I'm trying to create a custom Preload() . I need a custom preload because I have to take two many to many tables into account when pulling in the data. Something like this:

db.Preload("Roles", preloadUserRoles(ctx, id)).First(&user).Error
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).

`gorm:"->;many2many:"`

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.