GORM Many-to-Many in Practice: Books & Tags
Prerequisites: finish the crash course, the relations part, the media part, and the data engineering part — the project with
books+comments+ cover images + paginated search. Conventions are the same as the crash course (WithContexton every DB call,errors.Isfor 404, and the 400 · 404 · 201 semantics).
When the relations part finished laying out “book → comments” (one-to-many), it left a teaser — “the third table, tags (many-to-many), is left for later”. This part pays it off: adding tags to books, and covering GORM’s many2many in one go — the join-table declaration, loading, filtering, and association add/remove.
1. Model & Migration: Declaring the many2many
Goal: declare Book ↔ Tag with many2many, so AutoMigrate automatically creates tags and the join table book_tags.
1.1 The Tag Model
Create models/tag.go:
package models
import "gorm.io/gorm"
type Tag struct {
gorm.Model
Name string `json:"name" gorm:"uniqueIndex;not null"`
}
Namecarries a unique index: the same tag name exists exactly once globally — this is the foundation of “tag deduplication” (FirstOrCreaterelies on it; see Section 3);
Free-form tags vs. a controlled vocabulary (a design decision): this part follows user-defined tags (the Douban-style model) — anyone can tag a book on the fly; the
Nameunique index plusFirstOrCreatededuplicates automatically, and tags are created on first use. If your product ships a controlled vocabulary of official, preset tags (the category model), the change is simple: drop the “create-by-nameFirstOrCreate+ filter-by-name” logic, and instead preset the tag table with the front end only sending existingtagIds; the handler validates the tag first (404) and thenAppends — the relationship goes from “find-or-create by name” to “validate by id”. The vocabulary model has no dedup problem, but it is less flexible; free-form tags need normalization as a safety net (see 3.1).
1.2 Adding the Association Field to Book
In models/book.go, append after CoverPath (existing fields such as Comments stay unchanged):
Tags []Tag `json:"tags,omitempty" gorm:"many2many:book_tags;"` // many-to-many: through the join table book_tags
The essential difference from one-to-many: a one-to-many relationship is written on the child table (
comments.book_id); a many-to-many is written on the association declaration (gorm:"many2many:book_tags;"), and the join table itself needs no model — AutoMigrate creates it for you. What you declare is the “relationship”, not a “table”.
1.3 Migration & Verification
Change main.go’s AutoMigrate to create all three tables at once (tags is created on its first migration; the book_tags join table is created automatically too):
if err := db.DB.AutoMigrate(&models.Book{}, &models.Comment{}, &models.Tag{}); err != nil {
log.Fatal("migration failed:", err)
}
Verify:
\d book_tags
-- you should see two columns, book_id and tag_id, whose primary key is the
-- composite key (book_id, tag_id) (unique by default)
The join table’s composite primary key means the same (book, tag) pair can exist at most once — both “a duplicate append errors out” and “natural deduplication”, covered later, stem from it.
2. Reading: Preload & Filtering by Tag
Goal: on the reading side — Preload pulls tags along on demand, and books can be filtered by tag.
2.1 The Detail Endpoint Carries Tags
The relations part’s GetBook already does Preload("Comments"); chain a second Preload here:
result := db.DB.WithContext(c.Request.Context()).
Preload("Comments").
Preload("Tags").
First(&book, id)
Preloadchains: each association runs its own bulk query (commentsin one,book_tags ⋈ tagsin one), assembled once — neither path triggers N+1;- No Preload on the list: same contract as the relations part (lists stay light, the detail is heavy);
json:"tags,omitempty"plus no default loading.
Testing:
curl http://localhost:8080/books/1
# the detail response should include "tags":[...] (tag the book first, then test — see Section 3)
2.2 Filtering Books by Tag
“List all books tagged Go” — JOIN the join table twice:
// GetBooksByTag lists the books that carry the given tag (GET /tags/:name/books)
func GetBooksByTag(c *gin.Context) {
tagName := c.Param("name")
var books []models.Book
if err := db.DB.WithContext(c.Request.Context()).
Joins("JOIN book_tags ON book_tags.book_id = books.id").
Joins("JOIN tags ON tags.id = book_tags.tag_id").
Where("tags.name = ?", tagName).
Find(&books).Error; err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, books)
}
Why isn’t the route
/books/by-tag? (a real-world pitfall) The crash course already registeredGET /books/:id, and Gin’s route tree doesn’t allow a static segment and a wildcard segment at the same position — registering/books/by-tagtoo makes it panic outright (“conflicts with existing wildcard”). So filtering by tag lives under thetagsprefix,GET /tags/:name/books— more RESTful in spirit, too.
Route registration:
r.GET("/tags/:name/books", handlers.GetBooksByTag)
Testing:
curl "http://localhost:8080/tags/Go/books"
# returns the books that carry the Go tag (tag a book first, then test)
3. Writing & Maintenance: Creating and Removing Associations
Goal: write relationships — add tags to a book (idempotently), remove them, replace the whole set, and what happens to the join table when the parent record is deleted.
3.1 Adding a Tag to a Book (Check First, Then Insert — Idempotent)
FirstOrCreate keeps tags unique (the Name unique index); Association("Tags").Append writes the join table:
// AddBookTag appends a tag to a book: the tag is created first if it doesn't
// exist, then the join table is written (POST /books/:id/tags)
func AddBookTag(c *gin.Context) {
id := c.Param("id")
// 1. The book must exist
var book models.Book
result := db.DB.WithContext(c.Request.Context()).First(&book, id)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
return
}
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
// 2. Bind the tag name
var input struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "please send valid JSON"})
return
}
// 3. Create the tag first if it doesn't exist (Name's unique index keeps it deduplicated)
var tag models.Tag
if err := db.DB.WithContext(c.Request.Context()).
Where("name = ?", input.Name).
FirstOrCreate(&tag, models.Tag{Name: input.Name}).Error; err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process tag"})
return
}
// 4. Write the join table: check first, then insert — idempotent
var count int64
if err := db.DB.WithContext(c.Request.Context()).
Table("book_tags").
Where("book_id = ? AND tag_id = ?", book.ID, tag.ID).
Count(&count).Error; err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check tag relation"})
return
}
if count == 0 {
if err := db.DB.WithContext(c.Request.Context()).
Model(&book).Association("Tags").Append(&tag); err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add tag"})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "tag added"})
}
The price of free-form tags: name normalization.
FirstOrCreate’s “deduplication” only works on byte-identical strings —Go,golang, andGo(trailing space) are three different tags in the database. In production you normalize before it hits the database:strings.ToLower+strings.TrimSpace, plus alias mapping when needed (Go→golang). This part doesn’t implement it, but keep it in mind: a unique index guarantees “string uniqueness”, not “semantic uniqueness”.Why check before inserting in step 4?
Association("Tags").Append(&tag)is a straightINSERTinto the join table.book_tags’s composite primary key(book_id, tag_id)guarantees each pair appears at most once — appending the same tag twice hits the unique constraint and errors out; it is not idempotent. That’s why the exampleCounts first and inserts after. If your project accepts “duplicate requests error out” semantics, dropping step 4’sCountworks too — this part demonstrates the idempotent version.
Route registration:
r.POST("/books/:id/tags", handlers.AddBookTag)
Testing:
curl -X POST http://localhost:8080/books/1/tags \
-H "Content-Type: application/json" \
-d '{"name":"Go"}'
# → 200, tag added; run the exact same command again and it is still 200
# (idempotent — no duplicate row in the join table)
3.2 Removing a Tag (Deleting the Join-Table Row Directly)
Association("Tags").Delete(&tag) needs the Tag fetched by ID first; this example operates on the join table directly instead, which is more straightforward — the delete condition is exactly the composite primary key:
// RemoveBookTag removes one of a book's tags (DELETE /books/:id/tags/:tid)
func RemoveBookTag(c *gin.Context) {
id := c.Param("id")
tid := c.Param("tid")
result := db.DB.WithContext(c.Request.Context()).
Table("book_tags").
Where("book_id = ? AND tag_id = ?", id, tid).
Delete(nil)
if result.Error != nil {
_ = c.Error(result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove tag"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "tag not found or not attached to this book"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "tag removed"})
}
Table("book_tags")...Delete(nil)runsDELETE ... WHERE book_id = ? AND tag_id = ?straight against the join table — reading, writing, and deleting can all ride on the “the join table is just a table” mindset;RowsAffected == 0→ the book doesn’t carry this tag → 404 (the same ownership-check logic as deleting comments).
Route registration:
r.DELETE("/books/:id/tags/:tid", handlers.RemoveBookTag)
Testing:
curl -X DELETE http://localhost:8080/books/1/tags/1
# → 200; deleting the same row again → 404
3.3 Replace: Whole-Set Replacement vs. Incremental Append
Append is incremental (adds on top of the existing relationships); Replace is whole-set replacement (clears all of the book’s tags first, then writes):
// The edit page's "save all tags" scenario: the front end sends the whole set,
// and old tags no longer in it are removed
db.DB.WithContext(c.Request.Context()).Model(&book).Association("Tags").Replace(&tags)
Don’t mix the two semantics: using
ReplacewhereAppendbelongs — on an “append one tag” endpoint — silently wipes the book’s other tags. Add/remove single tags with Append/Delete; only whole-set saves use Replace.
3.4 When the Parent Record Is Deleted, What About the Join Table?
DELETE /books/:id (soft delete) and /books/:id/permanent (physical delete) already exist — many-to-many gives each of the two deletes a layer of nuance:
- Soft delete: stamps
books.deleted_at, and the join-table rows stay exactly as they are; queries and Preloads on the book can’t see it (the book itself is filtered out) — the same logic as the relations part’s comments; - Physical delete: GORM by default does not clean up the join-table rows! Orphan
book_tagsrows don’t block queries (the book is already gone from any book-based filter), but they waste table space, and ID reuse can splice data across records. Two solutions:
// Solution one: when deleting, explicitly cascade the delete to the associations (Select(clause.Associations))
db.DB.WithContext(c.Request.Context()).
Select(clause.Associations).
Unscoped().Delete(&models.Book{}, id)
-- Solution two: give the join table a foreign-key constraint at table-creation time,
-- and let the database cascade the cleanup
ALTER TABLE book_tags
ADD CONSTRAINT fk_book_tags_book FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE;
Use solution one for teaching (no change to the database structure); production often does both — GORM’s explicit cascade plus the database foreign key as the safety net.
4. Advanced: A Join Model with Extra Fields
Goal: attach fields to “the relationship itself” — when a join model with extra fields is called for, and how to declare one.
By default the join table holds only book_id / tag_id. To attach attributes to a relationship (say, “where does this book’s close-reading tag rank” or “when was the tag attached”), you need an explicit join model:
// models/book_tag.go — custom join table: composite primary key + extra fields
type BookTag struct {
BookID uint `gorm:"primaryKey"`
TagID uint `gorm:"primaryKey"`
Position int // sort value: manual order of a book's tags
CreatedAt time.Time // when the tag was attached
}
Each of the two models hangs a has-many pointing at the join model:
type Book struct {
// ...existing fields
BookTags []BookTag `gorm:"foreignKey:BookID"`
}
type Tag struct {
// ...existing fields
BookTags []BookTag `gorm:"foreignKey:TagID"`
}
Highlights after the upgrade (sketched, not exhaustive):
- The association no longer rides on “
Tags []Tag+ the many2many tag” — instead two has-manys point atBookTag, and reads and writes go through the join records directly (db.Create(&models.BookTag{BookID: 1, TagID: 2, Position: 1})); Association("Tags")’s convenient automatic writes no longer apply; “a book’s tags” becomesPreload("BookTags")plus your own mapping;- the default join table covers 90% of scenarios — upgrade to a join model only when the relationship itself needs stored fields (teaching order: default first, upgrade on demand).
Wrap-Up
- New routes in this part:
| Method | Path | Handler |
|---|---|---|
| POST | /books/:id/tags |
AddBookTag |
| DELETE | /books/:id/tags/:tid |
RemoveBookTag |
| GET | /tags/:name/books |
GetBooksByTag |
(main.go additions: add &models.Tag{} to AutoMigrate, plus the three routes above.)
- Your project now: three tables
books+comments+tags, cover-image upload with static serving, paginated search lists, comment CRUD, tags with their join table, batch import and DTO validation; - The series now covers both association shapes — has-many and many2many — along with querying, aggregation, and engineering practices; an optional extension (optional reading): GORM Engineering in Practice (Part 1): Layering, Dependency Injection & Testability — refactor the always-direct
db.DBinto aBookRepositoryinterface + a Service layer + table-driven tests. The tests and transactions that layering unlocks will put everyWithContextin this part to good use.

