GORM Associations Has Many Belongs To and Many to Many
GORM associations on PostgreSQL: has many, belongs to and many to many, the foreign keys AutoMigrate builds, a nested create that failed, and Association mode

A relational schema is tables and the keys between them, and an ORM earns its keep or loses it on how it handles those keys. GORM models three relationships with struct fields: has many (one author, many posts), belongs to (each post has an author) and many to many (posts and tags through a join table). The declarations are short. The behaviour on create, on update and on delete is where the surprises are, and this article runs all three against PostgreSQL 18.6 and reads the results, including a nested create that fails and a delete that does nothing.
Tested with Go 1.25.6, GORM v1.31.2, gorm.io/driver/postgres v1.6.3, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It continues from the GORM tutorial and models and tags. Loading the related rows efficiently is its own topic, covered in Preload and Joins, and the schema design side belongs to the database cornerstone.
The three shapes
GORM infers a relationship from the field types and a naming convention.
- Belongs to.
PosthasAuthorID uintandAuthor Author. The foreign key lives on the post. GORM finds it because the field is named<Relation>ID. - Has many.
AuthorhasPosts []Post. GORM looks forAuthorIDonPost, the author's name plusID. The same column is the other half of the belongs to above, so the two declarations describe one foreign key from both sides. - Many to many.
PosthasTags []Tagwith the taggorm:"many2many:post_tags", andTaghasPosts []Postwith the same tag. GORM creates the join tablepost_tagswithpost_idandtag_id, and a composite primary key over both.
There is also has one, a single child pointer such as a profile, which behaves like has many with a unique foreign key. The same rules below apply.
You do not have to declare both sides. A Post that only has Tags works for creating and loading tags on posts. Declare the reverse field when you will traverse it (all posts for a tag), because each declaration is also a place for tags and a field that someone will eventually use wrongly.
The lab
The program migrates the three models, prints the foreign keys PostgreSQL received, then creates data through the relationships and tests each operation:
package main
import (
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type Author struct {
ID uint
Name string
Posts []Post // has many
}
type Post struct {
ID uint
Title string
AuthorID uint
Author Author // belongs to
Tags []Tag `gorm:"many2many:post_tags"`
}
type Tag struct {
ID uint
Name string `gorm:"uniqueIndex"`
Posts []Post `gorm:"many2many:post_tags"`
}
func must(err error) {
if err != nil {
log.SetFlags(0)
log.Fatal(err)
}
}
func main() {
base := "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
admin, err := gorm.Open(postgres.Open(base), &gorm.Config{Logger: logger.Discard})
must(err)
must(admin.Exec("drop schema if exists l0708 cascade").Error)
must(admin.Exec("create schema l0708").Error)
db, err := gorm.Open(postgres.Open(base+" search_path=l0708"), &gorm.Config{Logger: logger.Discard})
must(err)
must(db.AutoMigrate(&Author{}, &Post{}, &Tag{}))
var cons []string
db.Raw(`select conrelid::regclass || ' ' || conname || ' ' || pg_get_constraintdef(oid) from pg_constraint
where connamespace = 'l0708'::regnamespace and contype = 'f' order by 1`).Scan(&cons)
for _, c := range cons {
fmt.Println("fk:", c)
}
// One call creates the author, two posts and their tags, but "basics" appears twice.
a := Author{Name: "Mira", Posts: []Post{
{Title: "Pointers", Tags: []Tag{{Name: "basics"}, {Name: "memory"}}},
{Title: "Slices", Tags: []Tag{{Name: "basics"}}},
}}
err = db.Transaction(func(tx *gorm.DB) error { return tx.Create(&a).Error })
fmt.Println("nested create with a repeated tag:", err)
// Resolve the tags first, then attach the same values.
basics := Tag{Name: "basics"}
memory := Tag{Name: "memory"}
must(db.Where(Tag{Name: "basics"}).FirstOrCreate(&basics).Error)
must(db.Where(Tag{Name: "memory"}).FirstOrCreate(&memory).Error)
a = Author{Name: "Mira", Posts: []Post{
{Title: "Pointers", Tags: []Tag{basics, memory}},
{Title: "Slices", Tags: []Tag{basics}},
}}
must(db.Create(&a).Error)
var tagCount, linkCount int64
db.Model(&Tag{}).Count(&tagCount)
db.Table("post_tags").Count(&linkCount)
fmt.Println("tags:", tagCount, "links:", linkCount)
// Reading it back with Preload.
var got Author
must(db.Preload("Posts.Tags").First(&got, a.ID).Error)
for _, p := range got.Posts {
names := ""
for _, t := range p.Tags {
names += t.Name + " "
}
fmt.Printf("post %q tags: %s\n", p.Title, names)
}
// Association mode: append, replace, count, delete.
var p Post
db.First(&p, "title = ?", "Slices")
must(db.Model(&p).Association("Tags").Append(&Tag{Name: "arrays"}))
fmt.Println("tags on Slices:", db.Model(&p).Association("Tags").Count())
must(db.Model(&p).Association("Tags").Replace(&basics))
fmt.Println("after replace:", db.Model(&p).Association("Tags").Count())
db.Table("post_tags").Count(&linkCount)
db.Model(&Tag{}).Count(&tagCount)
fmt.Println("links:", linkCount, "tags:", tagCount, "(Replace removes the link, not the Tag row)")
// The foreign key GORM created stops the parent from being deleted while posts point at it.
res := db.Delete(&Author{}, a.ID)
fmt.Println("delete author:", res.RowsAffected, "rows, error:", res.Error)
var left int64
db.Model(&Post{}).Where("author_id = ?", a.ID).Count(&left)
fmt.Println("posts left pointing at deleted author:", left)
}fk: post_tags fk_post_tags_post FOREIGN KEY (post_id) REFERENCES posts(id)
fk: post_tags fk_post_tags_tag FOREIGN KEY (tag_id) REFERENCES tags(id)
fk: posts fk_authors_posts FOREIGN KEY (author_id) REFERENCES authors(id)
nested create with a repeated tag: ERROR: insert or update on table "post_tags" violates foreign key constraint "fk_post_tags_tag" (SQLSTATE 23503)
tags: 2 links: 3
post "Pointers" tags: basics memory
post "Slices" tags: basics
tags on Slices: 2
after replace: 1
links: 3 tags: 3 (Replace removes the link, not the Tag row)
delete author: 0 rows, error: ERROR: update or delete on table "authors" violates foreign key constraint "fk_authors_posts" on table "posts" (SQLSTATE 23503)
posts left pointing at deleted author: 2The schema GORM built
AutoMigrate(&Author{}, &Post{}, &Tag{}) created the tables in dependency order and three foreign keys:
fk_authors_postsonposts(author_id)referencingauthors(id)fk_post_tags_postonpost_tags(post_id)referencingposts(id)fk_post_tags_tagonpost_tags(tag_id)referencingtags(id)
No tag was written for any of them. The names follow the pattern fk_<parent table>_<child field> for has many, and fk_<join table>_<relation> for the join table. Knowing the names matters because the error messages quote them, and a migration that drops or alters a constraint has to use the same ones.
These are real database constraints, which is good: the database refuses orphans no matter which program writes. They are also what makes the surprises below visible. An ORM that did not create foreign keys would have let these bugs write bad data instead of failing.
If you do not want GORM to own constraints, because the schema is managed elsewhere, set DisableForeignKeyConstraintWhenMigrating: true in the config. The associations still work for loading and saving, with the database no longer checking them.
Creating through a relationship
GORM saves associated values when you create the parent. The Author in the first attempt carried two posts, and the posts carried tags. In order, GORM inserts the author, then the posts with their author_id set, then the tags, then the rows in post_tags.
That is a lot of convenience, and the first lab step shows its limit. The two posts both mention a tag named basics, created as a new Tag{Name: "basics"} each time. The unique index on tags.name means the second insert is a duplicate. GORM inserts tags with ON CONFLICT DO NOTHING, so the duplicate is skipped without an error, and the skipped tag never receives an id. The join row for the second post is then written with tag_id pointing at a row that is not the one just created, and PostgreSQL rejects it:
nested create with a repeated tag: ERROR: insert or update on table "post_tags" violates foreign key constraint "fk_post_tags_tag" (SQLSTATE 23503)
The lab ran this inside a transaction, so nothing was left behind, and the rollback is also why the next step starts clean. The lesson is general. A nested create is safe when every child is new and unique. It is not safe when two parents share a child you have not resolved.
The fix is to resolve shared children first, then attach the same value. FirstOrCreate found or inserted basics and memory in the lab. Then both posts used the resolved Tag values, which carry real ids, and the Create succeeded: tags: 2 links: 3. Two tag rows, and three links: Pointers to basics and memory, Slices to basics.
This pattern applies to any shared reference: tags, categories, a country, a currency. In a request handler, resolve the references in a loop or with a single WHERE name IN (...) query, build the parent with the resolved values, and create it in one transaction.
Reading it back
Preload("Posts.Tags").First(&got, a.ID) loaded the author, the author's posts and each post's tags in three further queries, and the output shows both posts with the right tags: Pointers with basics memory, Slices with basics. The dotted path nests the preload. Preloading is the topic of its own article, including the choice between Preload and Joins and the cost of the extra queries. The short version is that Preload issues one query per level and is safe against N plus one, and Joins issues one query with a join and fits belongs to and has one better than a collection.
Association mode
Association mode is GORM's API for changing a relationship without touching the owner's other columns. You get it with db.Model(&owner).Association("Name"), and it has five operations: Find, Append, Replace, Delete and Clear, plus Count.
The lab used three of them on the post Slices:
- Append.
Append(&Tag{Name: "arrays"})created a new tag and linked it.Countthen returned 2, the originalbasicsplusarrays. - Replace.
Replace(&basics)madebasicsthe only tag.Countreturned 1. The post was no longer linked toarrays.
Then the lab counted rows: links: 3 tags: 3. Replace removed the link between Slices and arrays, so post_tags was back to 3 rows, and it left the arrays tag in the tags table. That is by design. For many to many, Association mode edits the join table and never deletes the other side. A tag is a first class row that other posts might use. If you want orphaned tags gone, run that cleanup yourself, with a query for tags that have no links.
Two cautions. Replace with a value that is not saved yet creates it, and with a duplicate of an existing unique value it falls into the same trap as the nested create above, which is why the lab passes the resolved &basics. And Delete in association mode removes the link, not the child, for many to many; for has many it sets the child's foreign key to NULL if the column allows it, and fails if the column is NOT NULL.
Deleting a parent
The final step tried to delete the author. The output is delete author: 0 rows, error: ERROR: update or delete on table "authors" violates foreign key constraint "fk_authors_posts" on table "posts" (SQLSTATE 23503), and a follow up count found 2 posts still pointing at the author.
Three things to take from that:
- GORM does not cascade by default. The foreign key it creates is a plain
REFERENCES, which refuses the delete. A program that ignored the returned error would believe the delete succeeded. The lab printsres.Error; a service must check it. - You choose the policy. The constraint can be set with a tag on the has many field:
gorm:"constraint:OnDelete:CASCADE;"deletes the posts with the author, andOnDelete:SET NULLkeeps the posts and clearsauthor_id(the column must then be nullable).OnUpdateworks the same way. Pick one deliberately. Cascade is convenient and dangerous: a deleted tenant takes its data with it in one statement. - Application level cleanup exists too.
db.Select("Posts").Delete(&author)deletes the author and its posts in the same call, with GORM issuing the delete for the children. It is explicit and visible in code, and it works even when the database has no cascade rule.
For a many to many, deleting a post removes the rows in post_tags through the foreign key on the join table only if the constraint cascades. GORM creates the join table's constraints with ON DELETE CASCADE for the relationship tag; check the migrated DDL in your version if you rely on it, and test it, rather than assuming.
Customising keys and tables
The conventions can be overridden with tags:
foreignKey:OwnerIDnames the foreign key field, when it is not<Relation>ID.references:Codenames the referenced field, when the parent key is notID.many2many:post_tags;joinForeignKey:PostRef;joinReferences:TagRefrenames the join table columns.- A custom join model, set with
SetupJoinTable, adds columns to the join table such asadded_atorposition. Once the join row carries data of its own, model it as a struct and treat it as its own entity, since Association mode cannot fill extra columns.
Use these for legacy schemas. For a new schema, the defaults are better because the next developer already knows them.
Has one, and polymorphic relations
A has one relationship is a single child pointer, such as Profile *Profile on User with UserID on the profile. It behaves like has many with one row, and it is worth putting a uniqueIndex on UserID so that the database enforces the "one", because GORM will not. Without it, two profiles for one user is a legal row, and First quietly returns whichever comes first.
Polymorphic associations let one table point at several parents, for example a Comment with OwnerID and OwnerType that can belong to a Post or a Photo. GORM supports them with the polymorphic:Owner tag. They cannot carry a real foreign key, because one column cannot reference two tables, and that is the trade: flexibility in the model, no referential integrity in the database. Two nullable foreign keys, post_id and photo_id, with a check constraint that exactly one is set, keep the guarantee at the cost of two columns. Choose the polymorphic form only when the number of parent types is large or open ended.
Testing relationships
A relationship is a good candidate for a test against a real PostgreSQL, because the interesting failures are constraint failures. The cases worth a test are the ones in this lab: the nested create with a shared child, the delete of a parent with children, and the replace of a many to many link. The GORM testing article shows how to run these quickly. An SQLite in memory database accepts foreign keys only when you turn them on with a pragma, which is one more reason the dialect used in tests should match production for any test about constraints.
Habits that keep associations out of trouble
- Create parents with shared children in two steps. Resolve the shared rows, then attach them.
- Check
.ErroronDelete. A foreign key failure is silent if you drop it. - Decide the delete policy in the schema. Write the
OnDeleterule in a tag or the migration, and test it with a row that has children. - Do not
Savea loaded parent with its slices attached.Saveupserts the associations, which re-writes children you only meant to read. UseOmit(clause.Associations)when saving a struct you loaded withPreload. - Load only what you need. A struct that carries
PostsandTagseverywhere invites accidental writes and heavy queries. Define a lighter struct or useSelectfor list endpoints.
The last two items deserve one more sentence. GORM's Save and Create follow associations by default, which is what made the nested create work. The same behaviour means a handler that loads an author with posts and calls Save(&author) after editing the name also upserts every post. Put db.Omit(clause.Associations).Save(&author) in the habit, or update the single column with Update.
Checklist
- Declare belongs to with
<Relation>IDplus the struct, has many with a slice, many to many withmany2many:table. - Expect real foreign keys from
AutoMigrate, and read their names in error messages. - Resolve shared children before a nested create. A repeated new child fails on the join row.
ReplaceandDeletein Association mode change links, not child rows.- Nothing cascades unless you set
OnDelete; a parent delete with children returns an error. - Use
Omit(clause.Associations)when saving a struct you loaded with relations.
The behaviour is described in the GORM associations guide, with the related options in the has many and many to many pages.


