Gin Basics #6 Database Integration (GORM)
In the previous post we covered middleware. By this point you have the fundamentals in place — receiving requests, validating them, responding, and applying shared processing. This post adds a database on top of that. We build a CRUD API that stores real data with GORM.
- #1 Getting started and your first server
- #2 Routing and handlers
- #3 Request binding and validation
- #4 Responses — JSON, status codes, errors
- #5 Middleware
- #6 Database integration (GORM) ← this post
- #7 Project structure and a mini REST API
What is GORM? #
GORM is the most widely used ORM in Go. Instead of writing SQL directly, it lets you work with the database through Go structs and methods. The approach of handling SQL directly was covered in Go in Practice #4 Database Integration, so comparing the two approaches makes the difference clear.
This post uses SQLite as the example since it is simple to install. PostgreSQL and MySQL work too — just swap the driver and almost the same code runs unchanged.
go get gorm.io/gorm
go get gorm.io/driver/sqliteConnecting to the database #
You open a connection with gorm.Open. The *gorm.DB it returns is the starting point for every query from here on.
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupDB() *gorm.DB {
db, err := gorm.Open(sqlite.Open("app.db"), &gorm.Config{})
if err != nil {
log.Fatal("DB connection failed:", err)
}
return db
}A connection failure is a situation where the server cannot operate normally, so we terminate immediately with log.Fatal.
Defining a model #
GORM maps Go structs to tables. The plural form of the struct name becomes the table name (User → users).
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name"`
Email string `json:"email" gorm:"uniqueIndex"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}gorm:"primaryKey"— designates the primary key. A field namedIDis automatically recognized as the primary key by GORM, but being explicit is finegorm:"uniqueIndex"— a unique index that blocks duplicate emails at the DB levelCreatedAt/UpdatedAt— conventional field names that GORM fills in automatically with the creation and update timestamps
Notice that both the json tag and the gorm tag are attached. The same struct serves double duty for API response serialization (#4) and DB mapping.
Migration — AutoMigrate #
AutoMigrate creates tables or adds columns to match your models. It is handy during development for quickly bringing your schema in line.
db.AutoMigrate(&User{})In production you usually manage the schema with a dedicated migration tool, but at the beginner stage AutoMigrate is enough.
Basic CRUD operations #
Let’s first lay out GORM’s core methods.
db.Create(&user) // create
db.First(&user, id) // fetch one row by primary key
db.Find(&users) // fetch multiple rows
db.Save(&user) // update (save the whole record)
db.Model(&user).Updates(...) // update (some fields)
db.Delete(&user, id) // deleteWhen there is no matching row, First returns a gorm.ErrRecordNotFound error. We use the pattern of catching this error and responding with 404 below.
Combining with Gin handlers #
Now we wire these methods into Gin handlers. Since a handler needs access to *gorm.DB, we capture db in a closure to build the handler.
func createUser(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user := User{Name: req.Name, Email: req.Email}
if err := db.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
c.JSON(http.StatusCreated, user)
}
}A GORM method returns a *gorm.DB, and you check the result through its .Error field. On a successful create, user.ID gets filled in, and as we saw in #4 we respond with 201 Created.
Here is the read handler.
func getUser(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
var user User
if err := db.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, user)
}
}With errors.Is we distinguish “record not found” from other DB errors and respond with 404 and 500 respectively. Listing is even simpler.
func listUsers(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var users []User
if err := db.Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
c.JSON(http.StatusOK, users)
}
}Update and delete follow the same frame.
func updateUser(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var user User
if err := db.First(&user, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db.Model(&user).Update("name", req.Name)
c.JSON(http.StatusOK, user)
}
}
func deleteUser(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if err := db.Delete(&User{}, c.Param("id")).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
c.Status(http.StatusNoContent)
}
}Wiring up the routes #
We register the closure-built handlers on the routes, passing db to each handler.
func main() {
db := setupDB()
db.AutoMigrate(&User{})
r := gin.Default()
users := r.Group("/users")
{
users.POST("", createUser(db))
users.GET("", listUsers(db))
users.GET("/:id", getUser(db))
users.PUT("/:id", updateUser(db))
users.DELETE("/:id", deleteUser(db))
}
r.Run()
}At this point you have a working CRUD API that creates and reads users.
curl -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"Curtis","email":"a@b.com"}'
curl http://localhost:8080/usersRelationship mapping is separate #
Relationship mapping such as a 1:N where a user has many posts is a core GORM topic, but it is large in volume so we don’t cover it here. Relationship modeling and querying associated data are organized separately in the Go GORM 1:N Relationships post, so read that next.
Wrapping up #
What this post covered:
- GORM is an ORM that handles the DB through Go structs, portable to another DB by just swapping the driver
- Connect with
gorm.Openand query through*gorm.DB, manage the schema during development withAutoMigrate - Models specify the primary key and indexes with
gormtags, andCreatedAt/UpdatedAtare managed automatically - CRUD is
Create/First/Find/Updates/Delete, and you check the result with.Error - Handlers are built by capturing
dbin a closure, anderrors.Isbranches “not found” into a 404 - Relationship mapping continues in the separate GORM post
In the next post (#7 Project structure and a mini REST API) we split the code we have gathered into a single file into layers, separate out the configuration, and wrap up the series.