Gin Basics #6 Database Integration (GORM)

6 min read

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.

Install
go get gorm.io/gorm
go get gorm.io/driver/sqlite

Connecting 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.

Connect
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 (Userusers).

Model
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 named ID is automatically recognized as the primary key by GORM, but being explicit is fine
  • gorm:"uniqueIndex" — a unique index that blocks duplicate emails at the DB level
  • CreatedAt / 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.

Migration
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.

CRUD 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)          // delete

When 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.

Create — POST /users
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.

Read — GET /users/:id
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.

List — GET /users
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.

Update and delete
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.

main.go
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.

Check it works
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Curtis","email":"a@b.com"}'

curl http://localhost:8080/users

Relationship 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.Open and query through *gorm.DB, manage the schema during development with AutoMigrate
  • Models specify the primary key and indexes with gorm tags, and CreatedAt/UpdatedAt are managed automatically
  • CRUD is Create/First/Find/Updates/Delete, and you check the result with .Error
  • Handlers are built by capturing db in a closure, and errors.Is branches “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.

X