GORM Crash Course: Building a Book Management API with Gin + GORM
Before You Start
For readers who know basic Go syntax and want to get a GORM project running from scratch. Three things to confirm first:
- Go 1.22+ (this tutorial uses
errors.Is; 1.24+ recommended) - A local PostgreSQL (or Docker:
docker run --name pg -e POSTGRES_PASSWORD=123456 -p 5432:5432 -d postgres:16) - Create the database by hand:
CREATE DATABASE library;—AutoMigratecan create tables, not databases
MySQL / SQLite work too: the code is portable, only the driver and DSN change (see Part 1). Note that from Part 2 on the series uses PostgreSQL-specific features (ILIKE, functional dependency when grouping by primary key, partial unique indexes); differences are flagged where they appear.
The project layout starts flat and evolves later (see Part 6):
gin-demo/
├── main.go # entry point
├── db/
│ └── db.go # database connection
├── models/
│ └── book.go # data model
├── handlers/
│ └── book.go # business logic (CRUD)
└── go.mod
Why flat? Three packages (
db/models/handlers) are enough to get running and easy to map chapter by chapter. Growing intointernal/+ a Service / Repository split is Engineering (Part 1)’s job.
Build a Mental Model First
GORM’s odd one out isn’t Spring/Django — it’s the hand-written SQL camp (JDBC/MyBatis): there you assemble SQL and map result rows into objects by hand, and GORM is the opposite.
One sentence: Gin turns an HTTP request into a Go function call; GORM translates a Go struct into database SQL. The whole article follows one line: request in → fill the struct → hand it to GORM → fill the result back → return JSON.
You’re Not Writing SQL — You’re Operating on Structs
You describe intent (Create, Find, Updates, Delete); translating it into SQL is the framework’s job. Want to see what it generated? Turn on GORM logging.
Four Mental Models You Must Build
- A struct plays three roles at once. The same struct is the table definition (
gormtags), the request/response carrier (jsontags), and the argument to database operations (&book). The type is the contract — change it in one place and everything follows. - Querying is “filling in the blank”, not “returning a value”.
Find(&books)andFirst(&book, id)must receive a pointer to the destination; GORM fills it via reflection, and forgetting&fills a copy nobody can see. Same forShouldBindJSON(&x),Delete(&models.Book{}),AutoMigrate(&models.Book{})— to fill data in, or to act on the object itself, pass the address. Conversely, a pointer type like*gorm.DB/*gin.Contextmeans “one shared instance, no copy”, while*string/*intmeans “may not be sent” (nil). - Zero value means “not provided”.
Updates(struct)decides what to update by zero values (0,"",false), so setting a field to0is silently skipped. Part 7’s zero-value trap is rooted here. - Errors are part of the result. There are no exceptions: every operation returns a
result, and you checkresult.Errorandresult.RowsAffectedyourself.
Soft Delete: Another Example of GORM Rewriting SQL
Part 8’s Delete never removes the row — GORM rewrites it into a soft delete, and deleted rows stop appearing in queries. The mechanics wait until Part 8; just remember: never assume Delete emits a DELETE statement.
This article stops at single-table CRUD. Comments and pagination, cover uploads, request DTOs, tag associations, layering, and reliability unfold in the following parts.
Part 1: Project Setup
Create the directory and initialize the module:
mkdir gin-demo
cd gin-demo
go mod init gin-demo
Install the dependencies:
go get github.com/gin-gonic/gin
go get gorm.io/gorm
go get gorm.io/driver/postgres
go getresolves the latest compatible version. The tutorial avoidsgo get -u—-uupgrades every indirect dependency too, adding uncertainty for no reason.
Switching databases means switching the driver and DSN:
| Database | Driver | DSN |
|---|---|---|
| PostgreSQL | go get gorm.io/driver/postgres |
host=localhost user=postgres password=123456 dbname=library sslmode=disable |
| MySQL | go get gorm.io/driver/mysql |
user:pass@tcp(localhost:3306)/library?charset=utf8mb4&parseTime=True |
| SQLite | go get gorm.io/driver/sqlite |
./data.db |
Part 2: Connecting to the Database
At startup call db.InitDB(): build the DSN, open the connection with gorm.Open, and hand back a *gorm.DB other packages can use; exit on failure.
A DSN is one line of key=value pairs: host / port / user / password / dbname, plus sslmode=disable for local development (enable SSL in production).
Create db/db.go:
package db
import (
"fmt"
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var DB *gorm.DB
func InitDB() {
host := "localhost"
port := 5432
user := "postgres"
password := "123456"
dbname := "library"
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
var err error
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal("failed to connect to database: ", err)
}
log.Println("database connected")
}
⚠️ In production, manage sensitive config with environment variables — don’t hardcode it. The companion repo accepts
DATABASE_DSNto override the whole DSN, which is how its verification runs point at a scratch database.Note: PostgreSQL needs a manually created database;
AutoMigratecreates tables but not databases;sslmode=disableis for local development only.
Part 3: Defining the Data Model
Create models/book.go and describe the table with one struct:
package models
import "gorm.io/gorm"
type Book struct {
gorm.Model
Title string `json:"title" gorm:"not null"`
Author string `json:"author" gorm:"not null"`
Price int `json:"price" gorm:"not null;check:price >= 0"` // cents — 5990 means 59.90
}
gorm.ModelembedsID,CreatedAt,UpdatedAt, andDeletedAtgorm:"not null"maps to the database’sNOT NULLconstraintgorm:"check:price >= 0"adds a database-level CHECK — negative values are rejected outright (validation can’t live only in the DTO)json:"title"names the field in JSON serialization — the response body uses it too
Three things newcomers most often miss:
gorm.Modelfields appear in JSON under their raw Go names. It carries nojsontags, so responses contain"ID"and"DeletedAt": null. To unify the naming, declare your own fields or use a DTO (see Part 10, “Next Steps”).- Money is an
int, in cents. Floating point has precision loss (0.1 + 0.2 != 0.3), soprice: 5990means 59.90; the frontend divides by 100 when it needs whole units. binding:"required"goes on the DTO, not the model. The model is reused by several endpoints (create, update, …) whose required-field rules differ, so nailing it onto the model rejects legitimate requests with a 400 (you’ll use it once in Part 5).
Naming conventions:
- Type names are singular (
Book, the Go convention); GORM pluralizes the table name tobooksfor you, so a plural type name buys nothing at the database level. File names are up to you — this tutorial usesbook.go. - The same field has three names in three layers: Go field (PascalCase), database column (snake_case, converted by GORM), JSON key (camelCase, via the
json:tag). Rename a column withgorm:"column:...", rename the JSON key withjson:— two independent switches. - To use a custom table name, add one line:
func (Book) TableName() string { return "my_books" }.
Part 4: Auto Migration
Create the table from main.go (no routes yet):
func main() {
db.InitDB()
if err := db.DB.AutoMigrate(&models.Book{}); err != nil {
log.Fatal("migration failed: ", err)
}
// ... server & routes in Part 9
}
- It only adds. It never drops columns and never removes existing constraints — delete a
not nulltag and the column staysNOT NULL; change an association’sOnDeletebehaviour and the existing foreign key stays as it was. Constraint changes need an explicit migration (db.Migrator().DropConstraint()or hand-written SQL) - Changing a field’s
size,precision, or nullability makes GORM try to alter the column type - Renaming a field adds a new column; use
db.Migrator().RenameColumn()to actually rename
The Formula First: The Uniform CRUD Flow
The five handlers in Parts 5–8 are instances of one flow. The six-step skeleton:
- Grab parameters:
id := c.Param("id")orc.Query("keyword") - Bind the request:
c.ShouldBindJSON(&xxx), failure means a straight 400 - Check existence:
db.DB.First(&xxx, id); no record (errors.Ishitsgorm.ErrRecordNotFound) → 404 - Do the operation:
Create/Find/Updates/Delete— each returns aresult - Check the result: a non-“not found”
result.Error→ 500;RowsAffected == 0→ 404 - Respond:
c.JSON(200/201, ...)
Every part is an instance of the formula:
| Operation | Params | Bind | Existence | Operation | Result check | Status |
|---|---|---|---|---|---|---|
POST /books |
– | ✓ | – | Create |
Error → 500 |
201 |
GET /books |
– | – | – | Find |
Error → 500 |
200 |
GET /books/:id |
✓ | – | First → 404 |
– | ErrRecordNotFound → 404 |
200 |
PUT /books/:id |
✓ | ✓ | First → 404 |
Updates |
Error → 500 |
200 |
DELETE /books/:id |
✓ | – | – | Delete |
Error → 500; RowsAffected=0 → 404 |
200 |
Two things that differ most from other frameworks:
- Gin has exactly one data entry point,
*gin.Context: path parameters, query parameters, and the request body all come from it — no controller classes, one function signature serves every request. - Every GORM step returns the same
result:*gorm.DBis both the chain carrier and the holder ofError/RowsAffected— that is mental model 4 landing at the API layer.
Also, every DB call in this article carries the request context: db.DB.WithContext(c.Request.Context()).Xxx(...) (why: Part 5).
Part 5: Creating a Book
Parts 6–8 append their CRUD functions to the same file, handlers/book.go, whose header is:
package handlers
import (
"errors"
"gin-demo/db"
"gin-demo/models"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
The first endpoint, creating a book:
// CreateBook creates a book (POST /books)
func CreateBook(c *gin.Context) {
var book models.Book
// 1. Bind the JSON request body
if err := c.ShouldBindJSON(&book); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
// 2. Insert into the database
result := db.DB.WithContext(c.Request.Context()).Create(&book)
if result.Error != nil {
_ = c.Error(result.Error) // the specific error goes to the log; the client sees the uniform message
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create book"})
return
}
// 3. Return the created data (this is "one struct, three roles": body in, DB in, out as-is)
c.JSON(http.StatusCreated, book)
}
What is that
_ = c.Error(result.Error)line before the 500 for? It hands the specific error to Gin: theLoggermiddleware insidegin.Default()rendersc.Errorsinto the access log (asError #01: ...at the end of the line), while the HTTP response still carries one uniform message — the reason goes to the log, the details never leak out.c.Errorreturns an error of its own (non-nil only whenc.Errorsis full or the argument is not an error), so_drops it; every other 500 branch in this part follows the same line, and production swaps this forslog+ a unified error middleware (Part 7).⚠️ Routes are only registered in Part 9. Jump ahead, copy the
main.gothere, get the server running, then come back and test Parts 5–8 chapter by chapter.
Testing:
curl -X POST http://localhost:8080/books \
-H "Content-Type: application/json" \
-d '{"title":"Go in Action","author":"Alice","price":5990}'
Response example (note the key casing):
{
"ID": 1,
"CreatedAt": "2026-09-05T13:05:46+08:00",
"UpdatedAt": "2026-09-05T13:05:46+08:00",
"DeletedAt": null,
"title": "Go in Action",
"author": "Alice",
"price": 5990
}
ID/CreatedAt/DeletedAtcome out capitalised becausegorm.Modelhas nojsontags (see Part 3);title/author/priceare lowercase because we tagged them.
Request Validation: Receiving the Body with a DTO
The version above validates nothing — {"price":5990} (no title / author) inserts fine. It exists only so the “request → struct → database” line runs first; a real endpoint cannot ship like that. Validation is not a nice-to-have, it is part of the interface contract, and it belongs on the request struct, not on models.Book:
Step one: declare the request struct (DTO) in handlers/book.go, just above CreateBook (lowercase is fine inside the package; export it as CreateBookInput only if another package will ever reference it):
// createBookInput: request-only DTO for creating a book; validation tags belong to it
type createBookInput struct {
Title string `json:"title" binding:"required"`
Author string `json:"author" binding:"required"`
Price int `json:"price" binding:"gte=0"`
}
Step two: replace the old “bind → insert” pair. Declaring the DTO without switching the binding target changes nothing — requests missing fields still insert fine:
var input createBookInput // was: var book models.Book
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON (title and author are required, price must be >= 0)"})
return
}
book := models.Book{Title: input.Title, Author: input.Author, Price: input.Price} // DTO -> model
result := db.DB.WithContext(c.Request.Context()).Create(&book) // unchanged from here
Why not write
Create(&input)?inputis the DTO andbookis the model: the model embedsgorm.Model(primary key, timestamps, soft delete) and will later grow columns the client has no say over, likecover_path. That translation line is a deliberate safety check — only fields you list insidemodels.Book{...}get persisted (why extra keys must be kept out is Part 7’s ⚠️). Validation-error translation, update DTOs and more are developed in full in the DTO article.
Why not put binding:"required" on models.Book? The model gets reused by several endpoints: creation needs a full payload, updates take a different set of inputs — nail validation onto the model and every endpoint that only needs some fields gets a 400. The model owns the schema and JSON naming; validation is an interface contract and belongs to the request struct.
Only after that does a request with missing fields take the 400 branch:
# 400 (the original code returned 201); a full payload → 201, same response as above
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8080/books \
-H "Content-Type: application/json" -d '{"price":5990}'
Why You Must Carry the Context
WithContext(ctx) hangs a Go context.Context on the query chain; when the query executes, it travels through database/sql → the driver and becomes the source of cancellation and timeout signals.
c.Request.Context() is tied to the request lifecycle: when the client disconnects, it is cancelled. Carry it and the query is interrupted too, returning the connection to the pool instead of hanging; the bare form (db.DB.Create(&book)) also runs, but is blind to the request’s lifecycle.
That is why every DB call here is written as db.DB.WithContext(c.Request.Context()).Xxx(...). The server-side timeout middleware that guards against slow queries lives in Part 9’s main.go; when it expires, GORM returns context.DeadlineExceeded, which falls through to 500 — map it to 504 in production (see Part 7).
Part 6: Querying Books
The two read endpoints:
// GetBooks lists all books (GET /books)
func GetBooks(c *gin.Context) {
books := []models.Book{} // empty slice, not nil: an empty table returns [] instead of null
result := db.DB.WithContext(c.Request.Context()).Find(&books)
if result.Error != nil {
_ = c.Error(result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, books)
}
// GetBook returns one book by ID (GET /books/:id)
func GetBook(c *gin.Context) {
id := c.Param("id")
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.Error(result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, book)
}
Testing:
curl http://localhost:8080/books
curl http://localhost:8080/books/1
Why does
Firstcheck errors in two layers? “Not found” surfaces asgorm.ErrRecordNotFound; only whenerrors.Ismatches it do we return 404, while errors like a dropped connection fall through to 500 instead of being misreported as “book not found”.GetBooksuses[]models.Book{}rather thanvar books []models.Bookbecause the latter serializes asnullon an empty table — later parts’ paginated lists keep that habit.
Part 7: Updating a Book
// UpdateBook updates a book (PUT /books/:id)
func UpdateBook(c *gin.Context) {
id := c.Param("id")
// 1. Check whether the book exists (the same two-layer check as Part 6's GetBook — write it yourself this time)
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.Error(result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
// 2. Bind the JSON request body
var input models.Book
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
// 3. Update the fields (WHERE comes from the First above; only non-zero fields are written — see mental model 3)
result = db.DB.WithContext(c.Request.Context()).Model(&book).Updates(input)
if result.Error != nil {
_ = c.Error(result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": "update failed"})
return
}
// 4. Return the updated data (GORM writes the changed fields back into book; untouched fields keep the values loaded by First above)
c.JSON(http.StatusOK, book)
}
PUT or PATCH?
PUT strictly means “replace the whole resource”; partial updates belong to PATCH. The next section turns this endpoint into a real full replacement, which is why PUT is the right verb here; if you later want a partial update (only touch what the client actually sent), that is a different design and this part does not cover it (see GORM Data Engineering §2.2).
The zero-value trap
The direct consequence of mental model 3: {"price":0} is silently skipped, so “set the price to 0” is impossible. The root cause is not GORM but the binding layer: when non-pointer fields receive JSON, “not sent” and “sent as zero” collapse into the same 0 — GORM receives a value, not an intent.
This part commits to exactly one approach: make the endpoint a real PUT and let Select list the columns. PUT means “replace the whole resource”, so zero values are legitimate — no pointers needed. Listed columns are written even at zero, and the same list doubles as a mass-assignment whitelist (replace steps 2–3 of UpdateBook above; step 1’s existence check is untouched, and result is already declared there, so this continues with =):
type updateBookInput struct { // PUT requires a full payload, so `required` is right here
Title string `json:"title" binding:"required"`
Author string `json:"author" binding:"required"`
Price int `json:"price" binding:"gte=0,lte=1000000"`
}
var in updateBookInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON (title and author are required)"})
return
}
result = db.DB.WithContext(c.Request.Context()).Model(&book).
Select("title", "author", "price"). // listed columns are written even at zero — and this is the whitelist
Updates(models.Book{Title: in.Title, Author: in.Author, Price: in.Price})
-- actual SQL for {"title":"","author":"新作者","price":0} (GORM v1.31.2)
UPDATE `books` SET `title`="",`author`="新作者",`price`=0,`updated_at`="..." WHERE ... AND `id` = 1
The cost is inherent to PUT: a client omitting title means “clear the title”, not “leave it alone” — which is exactly why required belongs here. This route is for PUT only; if the endpoint is really a partial update (“not sent means don’t touch”), that is a different design, out of scope for this part — see GORM Data Engineering §2.2.
⚠️ Don’t use the model as the request body.
var input models.Bookhands the request straight to the model, and JSON binding is case-insensitive:{"deletedAt":"2026-01-01T00:00:00Z"}soft-deletes the book,{"coverUrl":"/uploads/x.jpg"}rewrites the cover without going through the upload endpoint — that is mass assignment. The fix is the DTO: what you accept and what you store are two different things. The main text keeps the model version so the zero-value trap stays visible.
Testing:
# full PUT replacement: the 0 really does get written
curl -X PUT http://localhost:8080/books/1 -H "Content-Type: application/json" \
-d '{"title":"","author":"New Author","price":0}'
Part 8: Deleting a Book
Because Book embeds gorm.Model, GORM performs a soft delete by default: the row is never removed, only deleted_at is set, and queries filter it out from then on.
// DeleteBook soft-deletes a book (DELETE /books/:id)
func DeleteBook(c *gin.Context) {
id := c.Param("id")
// Perform the soft delete
result := db.DB.WithContext(c.Request.Context()).Delete(&models.Book{}, id)
if result.Error != nil { // check Error before RowsAffected — order matters
_ = c.Error(result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "book deleted"})
}
How soft delete behaves
What GORM actually runs is UPDATE ... SET deleted_at=NOW() WHERE id=? AND deleted_at IS NULL:
| Operation | GORM behaviour |
|---|---|
| First DELETE | sets deleted_at = NOW(); the row stops appearing in queries |
| Deleting the same row again | deleted_at IS NOT NULL means no match, RowsAffected == 0, so “book not found” |
After a soft delete, First / Find add deleted_at IS NULL automatically; to read deleted rows use db.DB.WithContext(c.Request.Context()).Unscoped().First(&book, id).
A soft delete touches only its own row — it never cascades to child tables (say, this book’s comments). To get “deleting a book hides its comments”, Part 2 §2.4 weighs the two approaches and how to choose between them (read-side gating vs write-side cascade).
⚠️ The unique-index trap: soft-deleted rows still occupy a unique index — re-creating a field with a unique index (say ISBN) after a soft delete violates the constraint. PostgreSQL’s answer is a partial unique index:
CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL, so only live rows participate. Part 5 puts that to work on a realtags.name.
Hard deletion (remove for real)
This function is optional. Part 9’s full
main.goregisters it; delete that line if you don’t want to expose it.
// DeleteBookPermanently hard-deletes a book (DELETE /books/:id/permanent)
func DeleteBookPermanently(c *gin.Context) {
id := c.Param("id")
// Unscoped() bypasses soft delete and removes the row for real
result := db.DB.WithContext(c.Request.Context()).Unscoped().Delete(&models.Book{}, id)
if result.Error != nil { // check Error before RowsAffected — order matters
_ = c.Error(result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "book permanently deleted"})
}
⚠️ It fails once child rows exist: from Part 2 on,
Bookhas a comments association, and the foreign key GORM creates defaults toNO ACTION— deleting a book that has comments is rejected by PostgreSQL (violates foreign key constraint). The fix isconstraint:OnDelete:CASCADEon the association (Part 2’s model already carries it), or delete the comments first in a transaction. Soft delete is unaffected.
Testing: curl -X DELETE http://localhost:8080/books/1.
Part 9: Registering Routes
Wire everything together — the route section looks like this:
r := gin.Default()
r.Use(requestTimeout(5 * time.Second)) // per-request timeout: slow queries are interrupted by the context
r.POST("/books", handlers.CreateBook)
r.GET("/books", handlers.GetBooks)
r.GET("/books/:id", handlers.GetBook)
r.PUT("/books/:id", handlers.UpdateBook) // full-replacement semantics (Select lists the updated columns)
r.DELETE("/books/:id", handlers.DeleteBook)
r.DELETE("/books/:id/permanent", handlers.DeleteBookPermanently) // optional
r.Run(":8080")
requestTimeout is the “timeout fallback for every request” from Part 5: it derives a context.WithTimeout ctx and writes it back onto the request so downstream WithContext calls can see the deadline.
Full main.go (imports, database init, migration, middleware, and the routes above):
Full main.go (click to expand)
package main
import (
"context"
"log"
"time"
"gin-demo/db"
"gin-demo/handlers"
"gin-demo/models"
"github.com/gin-gonic/gin"
)
// Timeout fallback for every request (see Part 5)
func requestTimeout(d time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
func main() {
// Connect to the database
db.InitDB()
// Auto migrate
if err := db.DB.AutoMigrate(&models.Book{}); err != nil {
log.Fatal("migration failed: ", err)
}
r := gin.Default()
// Per-request timeout: slow queries are interrupted by the context → 500 (map to 504 in production)
r.Use(requestTimeout(5 * time.Second))
// RESTful API routes
r.POST("/books", handlers.CreateBook)
r.GET("/books", handlers.GetBooks)
r.GET("/books/:id", handlers.GetBook)
r.PUT("/books/:id", handlers.UpdateBook) // full-replacement semantics (Select lists the updated columns)
r.DELETE("/books/:id", handlers.DeleteBook)
// Optional: the hard-delete example from Part 8 (soft delete is enough by default)
r.DELETE("/books/:id/permanent", handlers.DeleteBookPermanently)
r.Run(":8080")
}
With the server up you can test every chapter with curl, including the hard delete: curl -X DELETE http://localhost:8080/books/1/permanent.
Part 10: Summary
| Operation | GORM call | SQL emitted |
|---|---|---|
| Create | db.Create(&book) |
INSERT INTO ... |
| List all | db.Find(&books) |
SELECT * FROM ... |
| Get one | db.First(&book, id) |
SELECT * FROM ... WHERE id = ? |
| Update | db.Model(&book).Select("...").Updates(x) |
UPDATE ... SET ... (zero values written too) |
| Soft delete | db.Delete(&models.Book{}, id) |
UPDATE ... SET deleted_at = NOW() |
| Hard delete | db.Unscoped().Delete(&models.Book{}, id) |
DELETE FROM ... |
| Read deleted | db.Unscoped().First(&models.Book{}, id) |
SELECT * FROM ... WHERE id = ? (unscoped) |
All five handlers are instances of the six-step skeleton from “The Formula First”: parameters → binding → existence → operation → result → response; every DB call carries
WithContext(c.Request.Context()), and queries useerrors.Isto tell “not found” (404) from other errors (500).
Next Steps
- Transactions:
db.Transaction() - Hooks:
BeforeCreate,AfterUpdate - Slimmer responses: how to unify
gorm.Model’s capitalised keys — declare your own fields, or use a DTO as the response shape - Connection pool: grab the underlying
*sql.DBwithsqlDB, _ := db.DB.DB(), then tuneSetMaxOpenConns()/SetConnMaxLifetime() - Timeout mapping: return 504 when
errors.Is(err, context.DeadlineExceeded), instead of a blanket 500 - SQL debugging:
&gorm.Config{Logger: logger.Default.LogMode(logger.Info)}

