A Go Gin GORM REST API gives you a small, fast HTTP layer (Gin) on top of an ORM that handles most of the SQL for you (GORM), backed here by PostgreSQL. This guide builds a working articles API from scratch: project layout, configuration from environment variables, models, versioned migrations, seed data, CRUD handlers, route groups and CORS.
It is the first part of a three-part series. Part two adds JWT authentication and Casbin RBAC, and part three adds validation, file uploads and pagination. All three use the same codebase.
What we are building
A JSON API for publishing articles, with categories:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/articles | List the latest articles |
| GET | /api/v1/articles/:id | Get one article |
| POST | /api/v1/articles | Create an article |
| PUT | /api/v1/articles/:id | Replace an article |
| DELETE | /api/v1/articles/:id | Delete an article |
REST here simply means resources identified by URLs, standard HTTP methods, stateless requests and meaningful status codes.
Project structure for a Gin and GORM API
An MVC-style layout suits an API of this size. JSON responses replace the view layer, so it is models, controllers and routes:
articles-api/
├── main.go
├── .env.example
├── go.mod
├── config/
│ ├── config.go # environment variables
│ └── database.go # GORM connection
├── models/ # structs mapped to tables
├── migrations/ # versioned schema changes
├── seeds/ # demo and initial data
├── controllers/ # HTTP handlers
└── routes/ # route groups and wiringEach folder is a Go package; Go modules and packages explains how the imports connect them. For layout and error handling as the codebase grows, see Go error handling and project layout.
Initialize the module and install the dependencies:
go mod init example.com/articles-api
go get github.com/gin-gonic/gin gorm.io/gorm gorm.io/driver/postgres \
github.com/joho/godotenv github.com/gin-contrib/cors \
github.com/go-gormigrate/gormigrate/v2Older tutorials import github.com/jinzhu/gorm, the unmaintained GORM v1. Use gorm.io/gorm; its API differs (for example, RecordNotFound() became errors.Is(err, gorm.ErrRecordNotFound)).
Configuration with environment variables and godotenv
Ports, database URLs and allowed origins differ per environment, so they belong in environment variables. godotenv loads a local .env file during development:
# .env (never commit this; commit .env.example instead)
PORT=8080
DATABASE_URL=postgres://articles:secret@localhost:5432/articles?sslmode=disable
CORS_ORIGINS=http://localhost:3000package config
import (
"errors"
"io/fs"
"log"
"os"
"strings"
"github.com/joho/godotenv"
)
type Config struct {
Port string
DatabaseURL string
CORSOrigins []string
}
func Load() Config {
// .env is a development convenience. In production the variables
// come from the container or platform, so a missing file is fine.
if err := godotenv.Load(); err != nil && !errors.Is(err, fs.ErrNotExist) {
log.Fatalf("load .env: %v", err)
}
cfg := Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
CORSOrigins: strings.Split(getEnv("CORS_ORIGINS", "http://localhost:3000"), ","),
}
if cfg.DatabaseURL == "" {
log.Fatal("DATABASE_URL is required")
}
return cfg
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}Don't call log.Fatal whenever godotenv.Load() fails. Containers usually ship no .env file, so the service would crash in production. Fail on missing required values instead.
Connecting GORM to PostgreSQL
package config
import (
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func OpenDB(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
// Map unique and foreign key violations to gorm.ErrDuplicatedKey
// and gorm.ErrForeignKeyViolated so handlers can return 409/422.
TranslateError: true,
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(20)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
return db, nil
}*gorm.DB wraps a connection pool and is safe for concurrent use. Create it once and inject it, instead of calling a global GetDB() from every file.
GORM models and associations
package models
import (
"time"
"gorm.io/gorm"
)
type Category struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
}
type Article struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:200;uniqueIndex;not null" json:"title"`
Excerpt string `gorm:"size:500;not null" json:"excerpt"`
Body string `gorm:"type:text;not null" json:"body"`
Image string `gorm:"size:255" json:"image"`
CategoryID uint `gorm:"not null;index" json:"categoryId"`
Category *Category `json:"category,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}CategoryIDplusCategoryis a belongs-to association. GORM uses it to create the foreign key and toPreloadthe category.- As a pointer with
omitempty,Categoryis left out of responses when not loaded. DeletedAt gorm.DeletedAtturns on soft delete.Deletesets a timestamp, and normal queries skip those rows.- Embedding
gorm.Modelgives you the same four fields, but without JSON tags, so they serialize asIDandCreatedAt. Declaring them keeps the API in camelCase.
Database migrations with GORM
db.AutoMigrate(&models.Article{}) creates tables and adds missing columns, but it never drops or renames anything and keeps no history. For production, use versioned migrations:
| Approach | Good for | Limitation |
|---|---|---|
AutoMigrate on startup | Prototypes, tests | No history, no rollback, no renames |
| gormigrate (Go code) | Migrations in Go | DDL still generated by GORM |
| golang-migrate, goose, Atlas | Reviewing raw SQL | Separate tool |
This series uses gormigrate. Each migration has an ID, and the library records applied IDs in a migrations table:
package migrations
import (
"time"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func Run(db *gorm.DB) error {
m := gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
{
ID: "202609240001_create_categories_and_articles",
Migrate: func(tx *gorm.DB) error {
// Snapshot structs: later edits to models/ must not
// change what this migration does.
type Category struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:100;uniqueIndex;not null"`
}
type Article struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:200;uniqueIndex;not null"`
Excerpt string `gorm:"size:500;not null"`
Body string `gorm:"type:text;not null"`
Image string `gorm:"size:255"`
CategoryID uint `gorm:"not null;index"`
Category Category
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
return tx.AutoMigrate(&Category{}, &Article{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("articles", "categories")
},
},
})
return m.Migrate()
}The local structs matter. If the migration used models.Article, adding a field to the model next month would silently change what this old migration creates on a fresh database.
Seeding initial data
Seeding fills a new database with the data it needs, such as default categories and demo content. Make seeds idempotent so running them twice doesn't fail or create duplicates:
package seeds
import (
"example.com/articles-api/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func Run(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
var goCat models.Category
if err := tx.FirstOrCreate(&goCat, models.Category{Name: "Go"}).Error; err != nil {
return err
}
articles := []models.Article{
{Title: "Hello, Gin", Excerpt: "A first route.", Body: "...", CategoryID: goCat.ID},
}
// ON CONFLICT DO NOTHING skips titles that already exist.
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&articles).Error
})
}CRUD handlers with Gin and GORM
Handlers hang off a struct that holds the database, so dependencies are explicit and easy to replace in tests:
package controllers
import (
"errors"
"net/http"
"strconv"
"example.com/articles-api/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ArticleHandler struct{ db *gorm.DB }
func NewArticleHandler(db *gorm.DB) *ArticleHandler { return &ArticleHandler{db: db} }
type articleRequest struct {
Title string `json:"title" binding:"required,max=200"`
Excerpt string `json:"excerpt" binding:"required,max=500"`
Body string `json:"body" binding:"required"`
CategoryID uint `json:"categoryId" binding:"required"`
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return 0, false
}
return id, true
}
func (h *ArticleHandler) List(c *gin.Context) {
var articles []models.Article
err := h.db.WithContext(c.Request.Context()).
Preload("Category").Order("created_at DESC").Limit(20).
Find(&articles).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load articles"})
return
}
c.JSON(http.StatusOK, gin.H{"data": articles})
}
func (h *ArticleHandler) Create(c *gin.Context) {
var req articleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
article := models.Article{
Title: req.Title, Excerpt: req.Excerpt, Body: req.Body, CategoryID: req.CategoryID,
}
err := h.db.WithContext(c.Request.Context()).Create(&article).Error
switch {
case errors.Is(err, gorm.ErrDuplicatedKey):
c.JSON(http.StatusConflict, gin.H{"error": "an article with this title already exists"})
case errors.Is(err, gorm.ErrForeignKeyViolated):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "category does not exist"})
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create article"})
default:
c.JSON(http.StatusCreated, gin.H{"data": article})
}
}Get parses the ID with parseID, calls Preload("Category").First(&article, id) and maps errors.Is(err, gorm.ErrRecordNotFound) to 404. Delete calls db.Delete(&models.Article{}, id) and returns 404 when RowsAffected is 0, otherwise 204. Update loads the row with First, binds the request, then calls db.Model(&article).Updates(models.Article{...}). Note that Updates with a struct skips zero-value fields. That is fine for a full replacement, but for a partial PATCH use pointer fields or a map[string]any so you can tell "not sent" apart from "set to empty".
Two habits worth keeping from the start:
- Pass
c.Request.Context()viaWithContext, so queries stop when the client disconnects. - Return generic 500 messages and log details server side. Raw database errors leak schema names.
Binding errors are returned as raw strings here. Part three replaces them with structured, per-field messages.
Route groups
Groups share a path prefix and, later, middleware. Versioning under /api/v1 costs nothing now and eases breaking changes later:
package routes
import (
"net/http"
"example.com/articles-api/controllers"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func Register(r *gin.Engine, db *gorm.DB) {
articles := controllers.NewArticleHandler(db)
r.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) })
api := r.Group("/api/v1")
{
a := api.Group("/articles")
a.GET("", articles.List)
a.GET("/:id", articles.Get)
a.POST("", articles.Create)
a.PUT("/:id", articles.Update)
a.DELETE("/:id", articles.Delete)
}
}CORS and main.go
CORS (Cross-Origin Resource Sharing) lets a browser decide whether JavaScript on https://admin.example.com may read responses from your API on another origin. Only browsers enforce it, so it doesn't stop curl or other servers and is no substitute for authentication.
package main
import (
"flag"
"log"
"net/http"
"time"
"example.com/articles-api/config"
"example.com/articles-api/migrations"
"example.com/articles-api/routes"
"example.com/articles-api/seeds"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func main() {
seed := flag.Bool("seed", false, "insert seed data and exit")
flag.Parse()
cfg := config.Load()
db, err := config.OpenDB(cfg.DatabaseURL)
if err != nil {
log.Fatalf("connect database: %v", err)
}
if err := migrations.Run(db); err != nil {
log.Fatalf("migrate: %v", err)
}
if *seed {
if err := seeds.Run(db); err != nil {
log.Fatalf("seed: %v", err)
}
log.Println("seed complete")
return
}
r := gin.Default() // Logger and Recovery middleware
if err := r.SetTrustedProxies(nil); err != nil {
log.Fatal(err)
}
r.Use(cors.New(cors.Config{
AllowOrigins: cfg.CORSOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
routes.Register(r, db)
srv := &http.Server{Addr: ":" + cfg.Port, Handler: r, ReadHeaderTimeout: 5 * time.Second}
log.Fatal(srv.ListenAndServe())
}Register CORS before the routes, list explicit origins, and never combine "allow all origins" with credentials. SetTrustedProxies(nil) stops Gin from trusting X-Forwarded-For from any client. Behind a load balancer, list its address range instead.
Run go run . -seed once, then go run . and try curl localhost:8080/api/v1/articles.
FAQ
Is GORM fast enough for production?
For typical CRUD APIs, yes. Slowness usually comes from the queries themselves, such as N+1 loads or missing indexes. db.Debug() prints the SQL GORM generates. SQL query optimization covers the common fixes.
Should I use AutoMigrate in production?
Not as your only strategy. It can't rename or drop columns and keeps no history. Use versioned migrations.
Gin or the standard library router?
Since Go 1.22, net/http supports patterns such as GET /articles/{id}. Gin still adds binding, validation, route groups and middleware helpers, which is why it remains common for JSON APIs.
Why does my API work in curl but fail in the browser?
That is almost always CORS. Check that the page's exact origin (scheme, host and port) is in AllowOrigins and that the preflight OPTIONS request isn't rejected by other middleware.
Checklist before moving on
- Configuration comes from environment variables, and required values fail fast.
- One
*gorm.DBis created at startup and injected into handlers. - Schema changes live in versioned migrations with snapshot structs.
- Seeds are idempotent.
- Handlers map
ErrRecordNotFound, duplicates and foreign key errors to 404, 409 and 422. - CORS lists explicit origins.
Right now anyone can create or delete articles. Part two adds login, JWT and role-based access control. If you want a team to design or review a Go backend like this one, Vectorkub builds and reviews production APIs.
