GORM Many-to-Many in Practice: Books & Tags
๐ Series Navigation
This series has seven parts:
- GORM Crash Course: Building a Book Management API with Gin + GORM โ single-table CRUD, soft delete, the zero-value trap, a complete runnable project
- GORM Relations in Practice: Comment Model, CRUD & Preload โ second table
comments, comment CRUD, on-demand detail loading - GORM Media & Query Enhancement: Cover Upload, Pagination/Search & Comment Count โ uploads & static serving, pagination/search/sort, comment-count aggregation
- GORM Data Engineering: Batch Import, Request DTOs & Validation-Error Translation โ batch import, request DTOs, validation-error translation
- GORM Many-to-Many in Practice: Books & Tags โ many2many join table, tag filtering, association add/remove
- GORM Engineering in Practice (Part 1): Layering, Dependency Injection & Testability โ Repository/Service layering, constructor injection, table-driven tests (P4, optional reading)
- GORM Engineering in Practice (Part 2): Reliability & Production Readiness โ unified errors, security hardening, object storage, connection pool (P4, optional reading)
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.

