Skip to content
gohacks.dev
Databases

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

13 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

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. Post has AuthorID uint and Author Author. The foreign key lives on the post. GORM finds it because the field is named <Relation>ID.
  • Has many. Author has Posts []Post. GORM looks for AuthorID on Post, the author's name plus ID. 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. Post has Tags []Tag with the tag gorm:"many2many:post_tags", and Tag has Posts []Post with the same tag. GORM creates the join table post_tags with post_id and tag_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:

main.goGo
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)
}
Text
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: 2

The schema GORM built

AutoMigrate(&Author{}, &Post{}, &Tag{}) created the tables in dependency order and three foreign keys:

  • fk_authors_posts on posts(author_id) referencing authors(id)
  • fk_post_tags_post on post_tags(post_id) referencing posts(id)
  • fk_post_tags_tag on post_tags(tag_id) referencing tags(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. Count then returned 2, the original basics plus arrays.
  • Replace. Replace(&basics) made basics the only tag. Count returned 1. The post was no longer linked to arrays.

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:

  1. 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 prints res.Error; a service must check it.
  2. 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, and OnDelete:SET NULL keeps the posts and clears author_id (the column must then be nullable). OnUpdate works the same way. Pick one deliberately. Cascade is convenient and dangerous: a deleted tenant takes its data with it in one statement.
  3. 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:OwnerID names the foreign key field, when it is not <Relation>ID.
  • references:Code names the referenced field, when the parent key is not ID.
  • many2many:post_tags;joinForeignKey:PostRef;joinReferences:TagRef renames the join table columns.
  • A custom join model, set with SetupJoinTable, adds columns to the join table such as added_at or position. 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 .Error on Delete. A foreign key failure is silent if you drop it.
  • Decide the delete policy in the schema. Write the OnDelete rule in a tag or the migration, and test it with a row that has children.
  • Do not Save a loaded parent with its slices attached. Save upserts the associations, which re-writes children you only meant to read. Use Omit(clause.Associations) when saving a struct you loaded with Preload.
  • Load only what you need. A struct that carries Posts and Tags everywhere invites accidental writes and heavy queries. Define a lighter struct or use Select for 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>ID plus the struct, has many with a slice, many to many with many2many: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.
  • Replace and Delete in 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.