The API that stores data
You build a service that accepts JSON, saves it to a database, and returns it when asked. A client sends a payload. Your server parses it, validates it, writes it to storage, and sends back a status code. The client asks for the data later. Your server finds it, formats it, and returns it. This pattern repeats for every resource in your application.
Writing raw SQL for every endpoint is tedious. You repeat the same connection logic, the same error handling, and the same string interpolation. Writing a router from scratch is reinventing the wheel. You need to match URL patterns, extract parameters, and bind request bodies to structs.
Gin handles the HTTP routing. GORM handles the database mapping. Together they form a standard stack for Go web services. Gin is a framework built on top of net/http. It adds a router that matches URL patterns to handler functions. GORM is an Object-Relational Mapper. It reads Go structs and generates SQL queries. You define a struct with tags, GORM creates the table, and you call methods like Create or Find instead of writing INSERT or SELECT.
Models and conventions
GORM maps Go structs to database tables. It follows conventions to keep the code clean. Table names are pluralized by default. Column names are converted to snake_case. You can override these rules with struct tags.
Here's a model for a user resource. The tags tell GORM how to map the struct to the database.
type User struct {
// primaryKey tells GORM this field is the table's primary key.
// GORM will auto-increment this value on insert.
ID uint `gorm:"primaryKey"`
// uniqueIndex creates a unique constraint on the name column.
// The database will reject duplicate names.
Name string `gorm:"uniqueIndex"`
// No tag means GORM uses the default convention.
// The column name becomes "age" in the database.
Age int
}
Convention aside: Go struct fields must be exported to be visible to GORM. If a field starts with a lowercase letter, GORM ignores it. The database column won't exist. Public names start with a capital letter. Private start lowercase.
GORM also supports soft deletes. If you add a DeletedAt field of type gorm.DeletedAt, GORM marks records as deleted instead of removing them. Queries automatically filter out soft-deleted records. You can restore them by clearing the field.
Connecting and migrating
You need a database connection before you can run queries. GORM supports multiple drivers. SQLite is useful for development and testing. PostgreSQL or MySQL are common for production.
Here's the setup code. It opens a connection and creates the tables.
package main
import (
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func initDB() *gorm.DB {
// Open creates the database file if it doesn't exist.
// The second argument configures GORM behavior.
db, err := gorm.Open(sqlite.Open("app.db"), &gorm.Config{})
if err != nil {
// Panic is acceptable during startup if the database is unreachable.
// The application cannot function without storage.
log.Fatal("failed to connect database")
}
// AutoMigrate creates tables for missing structs.
// It adds columns if they are missing.
// It does not delete data or drop columns.
db.AutoMigrate(&User{})
return db
}
AutoMigrate is a development tool. It creates tables if they don't exist. It adds new columns if you update the struct. It does not drop columns. It does not change column types safely. For production migrations, use a dedicated migration tool like golang-migrate. AutoMigrate is safe for prototypes and local development.
The request flow
Gin routes map HTTP methods and paths to handler functions. A handler receives a *gin.Context. The context holds the request, the response writer, and extracted parameters.
Here's a minimal create handler. It binds JSON, saves the record, and returns the result.
func createUser(c *gin.Context) {
// Declare a variable to hold the parsed data.
var user User
// ShouldBindJSON reads the request body and decodes it into the struct.
// It returns an error if the JSON is invalid or fields are missing.
if err := c.ShouldBindJSON(&user); err != nil {
// Return 400 Bad Request if the client sent malformed data.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create inserts the record into the database.
// GORM populates the ID field after the insert.
if err := db.Create(&user).Error; err != nil {
// Return 500 Internal Server Error on database failure.
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
// Return 201 Created with the saved user data.
c.JSON(http.StatusCreated, user)
}
The flow is linear. Gin matches the route. It calls the handler. ShouldBindJSON parses the body. db.Create runs the SQL. c.JSON writes the response. If any step fails, you return an error and stop.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You see every error check. You know exactly where failures can occur.
Full CRUD operations
A complete API supports Create, Read, Update, and Delete. Each operation has specific requirements. Read can return a single record or a list. Update must find the record first. Delete must verify the record exists.
Here's the read handler. It supports fetching a single user by ID or listing all users.
func getUsers(c *gin.Context) {
// Check if an ID parameter is provided in the URL.
id := c.Param("id")
if id != "" {
// Single record fetch.
var user User
// First queries by primary key.
// It returns ErrRecordNotFound if no match exists.
if err := db.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, user)
return
}
// List all records.
var users []User
// Find returns all matching records.
// It returns zero records if the table is empty.
// No error is returned for empty results.
if err := db.Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
c.JSON(http.StatusOK, users)
}
db.First expects exactly one record. It queries by the primary key. If zero records match, it returns gorm.ErrRecordNotFound. If multiple match, it returns the first one and an error. You must check the error. db.Find returns a slice. It returns zero records if nothing matches. No error.
Here's the update handler. It finds the record, updates the fields, and saves it.
func updateUser(c *gin.Context) {
id := c.Param("id")
var user User
// Find the existing record first.
if err := db.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// Bind the new data into the existing struct.
// This overwrites the fields with client data.
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Save updates the record if the primary key exists.
// It creates a new record if the key is missing.
if err := db.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
c.JSON(http.StatusOK, user)
}
db.Save updates the record if the primary key exists. It creates a new record if the key is missing. Use db.Save when you want upsert behavior. Use db.Model(&user).Updates(attrs) when you want to update specific fields without touching others.
Here's the delete handler. It finds the record and removes it.
func deleteUser(c *gin.Context) {
id := c.Param("id")
var user User
// Verify the record exists before deleting.
if err := db.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// Delete removes the record from the database.
// If the model has a DeletedAt field, GORM performs a soft delete.
if err := db.Delete(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}
Pitfalls and gotchas
GORM hides SQL, but you still need to understand the database. Common mistakes lead to silent failures or performance issues.
GORM does not panic on database errors. It returns them. You must check the .Error field. If you ignore it, your code proceeds with invalid data. The compiler won't stop you. The runtime will fail silently or return wrong results. The compiler rejects undefined variables with undefined: pkg if you forget an import. It complains with cannot use x as y if types mismatch. GORM errors are runtime values. You handle them in code.
N+1 queries happen when you fetch a list and then fetch related data for each item. GORM generates a separate query for each association. Use db.Preload to fetch related data in a single query.
// Preload fetches associated records in one query.
// This avoids the N+1 problem.
db.Preload("Posts").Find(&users)
AutoMigrate has limits. It creates tables. It adds columns. It does not drop columns. It does not change column types. It does not handle complex migrations. Use a migration tool for production databases.
Soft deletes require a DeletedAt field. GORM filters out soft-deleted records automatically. If you need to query deleted records, use db.Unscoped().
Transactions are essential for data integrity. If you update multiple tables, wrap the operations in a transaction. GORM provides db.Transaction.
err := db.Transaction(func(tx *gorm.DB) error {
// All operations use the tx instance.
// If any operation returns an error, the transaction rolls back.
if err := tx.Create(&user).Error; err != nil {
return err
}
return tx.Create(&profile).Error
})
Gin routes are strict. GORM structs are contracts. Check the error. The database never lies.
When to use this stack
Use Gin with GORM when you need a fast prototype or a standard CRUD API with minimal boilerplate. Use the standard library with database/sql and sqlx when you need precise control over queries and want fewer dependencies. Use raw SQL when performance is critical and ORMs add too much overhead. Use a microservices architecture when your domain requires independent deployment and scaling.
Gin routes are strict. GORM structs are contracts. Check the error. The database never lies.