GORM Save Silently Upserts Associations — How Deleted Records Come Back and How to Stop It
GORM’s Save(&model) upserts all associated records automatically. If another goroutine calls Save at the wrong moment, associations you just deleted will silently come back. That is the bug this article is about.
I hit this while building a concurrent updater with GORM v1.9.16. Below is the investigation log and the two-stage fix I landed on.
Setup That Reproduced the Bug
I had a Product model and a related Shop model being updated concurrently by two goroutines.
package main
import (
"log"
"github.com/my-best/products.my-best.com/go/internal/models"
"golang.org/x/sync/errgroup"
)
func main() {
var product models.Product
eg := errgroup.Group{}
eg.Go(func() error { return updateProduct(&product) })
eg.Go(func() error { return updateProductShop(&product) })
if err := eg.Wait(); err != nil {
log.Fatal(err)
}
}
Inside updateProductShop, I was deleting Shops that were no longer needed.
err := db.GetDB().Transaction(func(tx *gorm.DB) error {
db.GetDB().Model(&product).Related(&product.Shops)
for _, shop := range product.Shops {
if err := tx.Delete(&shop).Error; err != nil {
return fmt.Errorf("failed to delete existing Shop: %v", err)
}
}
return nil
})
Even after main finished, the deleted Shop records kept coming back.
Reading the Logs: UPDATE Arriving Right After DELETE
Checking GORM’s query log, the sequence looked like this (excerpt):
// updateProductShop goroutine
[2022-07-12 10:51:16] [2.35ms] SELECT * FROM `shops` WHERE (`product_id` = 17821591)
[1 rows affected or returned ]
[2022-07-12 10:51:16] [1.40ms] DELETE FROM `shops` WHERE `shops`.`id` = 155648
[1 rows affected or returned ]
// updateProduct goroutine
[2022-07-12 10:51:17] [1.85ms] SELECT * FROM `products` WHERE (product_id = 17821591)
[0 rows affected or returned ]
[2022-07-12 10:51:17] [1.59ms] UPDATE `products` SET ... WHERE `products`.`id` = 17821591
[1 rows affected or returned ]
[2022-07-12 10:51:17] [1.51ms] UPDATE `shops` SET `product_id` = 17821591, ... WHERE `shops`.`id` = 155648
[0 rows affected or returned ]
DELETE succeeds — then, a moment later, an UPDATE from the other goroutine writes the same record back. The culprit was the Save call inside updateProduct.
db.GetDB().Save(&product)
GORM’s documentation explains the behavior:
Associations
Auto Create/UpdateGORM automates the saving of associations and their references when creating or updating records, using an upsert technique that primarily updates foreign key references for exi
GORM will automatically save associations and their references using Upsert when creating/updating a record.
So when Save(&product) runs, every Shop in the product.Shops slice gets upserted. If product.Shops still holds the old value after your Delete call, those records come straight back.
Quick Fix: Association().Delete Removes It from the Slice Too
Deleting the record alone is not enough. You also need to explicitly remove the association reference.
err := db.GetDB().Transaction(func(tx *gorm.DB) error {
db.GetDB().Model(&product).Related(&product.Shops)
for _, shop := range product.Shops {
if err := tx.Delete(&shop).Error; err != nil {
return fmt.Errorf("failed to delete existing Shop: %v", err)
}
+ if err := db.GetDB().Model(&product).Association("Shops").Delete(shop).Error; err != nil {
+ return fmt.Errorf("failed to delete association: %v", err)
+ }
}
return nil
})
Association("Shops").Delete removes the matching shop from the product.Shops slice. Now when Save runs afterward, that shop is no longer in the slice and won’t be upserted back.
Permanent Fix: Skip Association Auto-Save with Omit
The cleaner solution is to disable association auto-save on the Save call itself. GORM provides the Omit option for this.
Associations
Auto Create/UpdateGORM automates the saving of associations and their references when creating or updating records, using an upsert technique that primarily updates foreign key references for exi
// before
db.GetDB().Save(&product)
// after
db.GetDB().Omit("Shops").Save(&product)
Wherever Save is called and you don’t intend to update associations, add Omit. If there are multiple call sites, the safe default is to add Omit everywhere associations are not meant to be touched.
GORM v2 (gorm.io/gorm): FullSaveAssociations
This article is based on v1.9.16 behavior. In v2, the same behavior is controlled by the FullSaveAssociations config option.
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
FullSaveAssociations: false, // default is true
})
Setting it to false disables association auto-upsert globally. If your project’s policy is “never auto-save associations on Save,” v2’s FullSaveAssociations: false is the most explicit way to enforce it.
Get new articles by email
Related Posts
About this site
Personal blog by Amane Inoue. Writing about tech, books, culture, travel, and university life.