}

Go REST API with Gin: From Zero to Production (2026)

Building a REST API in Go with Gin gives you excellent performance with a developer-friendly framework. This guide goes from zero to a deployable API with database integration, middleware, and proper error handling.

Why Gin for Go APIs?

Gin is the most popular Go web framework. It's fast (uses httprouter under the hood), has a clean API, and includes everything you need for production APIs:

  • Routing with path parameters and query strings
  • Middleware support (logging, auth, CORS)
  • JSON binding and validation
  • Built-in error handling

Project Setup

mkdir go-api && cd go-api
go mod init github.com/yourname/go-api
go get github.com/gin-gonic/gin
go get github.com/lib/pq
go get github.com/joho/godotenv

Project structure:

go-api/
├── main.go
├── handlers/
│   └── books.go
├── models/
│   └── book.go
├── db/
│   └── db.go
├── middleware/
│   └── auth.go
├── .env
└── Dockerfile

Database Model

models/book.go:

package models

import "time"

type Book struct {
    ID        int       `json:"id" db:"id"`
    Title     string    `json:"title" db:"title" binding:"required"`
    Author    string    `json:"author" db:"author" binding:"required"`
    ISBN      string    `json:"isbn" db:"isbn"`
    Year      int       `json:"year" db:"year"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
}

type CreateBookRequest struct {
    Title  string `json:"title" binding:"required,min=1,max=255"`
    Author string `json:"author" binding:"required,min=1,max=255"`
    ISBN   string `json:"isbn"`
    Year   int    `json:"year" binding:"min=1000,max=2100"`
}

type UpdateBookRequest struct {
    Title  string `json:"title" binding:"omitempty,min=1,max=255"`
    Author string `json:"author" binding:"omitempty,min=1,max=255"`
    ISBN   string `json:"isbn"`
    Year   int    `json:"year" binding:"omitempty,min=1000,max=2100"`
}

Database Connection

db/db.go:

package db

import (
    "database/sql"
    "fmt"
    "log"
    "os"

    _ "github.com/lib/pq"
)

var DB *sql.DB

func Init() {
    dsn := fmt.Sprintf(
        "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
        os.Getenv("DB_HOST"),
        os.Getenv("DB_PORT"),
        os.Getenv("DB_USER"),
        os.Getenv("DB_PASSWORD"),
        os.Getenv("DB_NAME"),
    )

    var err error
    DB, err = sql.Open("postgres", dsn)
    if err != nil {
        log.Fatalf("Error opening database: %v", err)
    }

    if err = DB.Ping(); err != nil {
        log.Fatalf("Error connecting to database: %v", err)
    }

    DB.SetMaxOpenConns(25)
    DB.SetMaxIdleConns(5)

    log.Println("Database connected successfully")
    createTable()
}

func createTable() {
    query := `
    CREATE TABLE IF NOT EXISTS books (
        id SERIAL PRIMARY KEY,
        title VARCHAR(255) NOT NULL,
        author VARCHAR(255) NOT NULL,
        isbn VARCHAR(20),
        year INT,
        created_at TIMESTAMP DEFAULT NOW()
    )`
    if _, err := DB.Exec(query); err != nil {
        log.Fatalf("Error creating table: %v", err)
    }
}

Handlers

handlers/books.go:

package handlers

import (
    "database/sql"
    "net/http"
    "strconv"

    "github.com/gin-gonic/gin"
    "github.com/yourname/go-api/db"
    "github.com/yourname/go-api/models"
)

func GetBooks(c *gin.Context) {
    rows, err := db.DB.Query("SELECT id, title, author, isbn, year, created_at FROM books ORDER BY created_at DESC")
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch books"})
        return
    }
    defer rows.Close()

    var books []models.Book
    for rows.Next() {
        var b models.Book
        if err := rows.Scan(&b.ID, &b.Title, &b.Author, &b.ISBN, &b.Year, &b.CreatedAt); err != nil {
            c.JSON(http.StatusInternalServerError, gin.H{"error": "Error scanning books"})
            return
        }
        books = append(books, b)
    }

    if books == nil {
        books = []models.Book{}
    }

    c.JSON(http.StatusOK, gin.H{"data": books, "count": len(books)})
}

func GetBook(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
        return
    }

    var b models.Book
    err = db.DB.QueryRow(
        "SELECT id, title, author, isbn, year, created_at FROM books WHERE id = $1", id,
    ).Scan(&b.ID, &b.Title, &b.Author, &b.ISBN, &b.Year, &b.CreatedAt)

    if err == sql.ErrNoRows {
        c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
        return
    }
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"data": b})
}

func CreateBook(c *gin.Context) {
    var req models.CreateBookRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    var id int
    err := db.DB.QueryRow(
        "INSERT INTO books (title, author, isbn, year) VALUES ($1, $2, $3, $4) RETURNING id",
        req.Title, req.Author, req.ISBN, req.Year,
    ).Scan(&id)

    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create book"})
        return
    }

    c.JSON(http.StatusCreated, gin.H{"message": "Book created", "id": id})
}

func UpdateBook(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
        return
    }

    var req models.UpdateBookRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    result, err := db.DB.Exec(
        "UPDATE books SET title = COALESCE(NULLIF($1,''), title), author = COALESCE(NULLIF($2,''), author), isbn = $3, year = COALESCE(NULLIF($4,0), year) WHERE id = $5",
        req.Title, req.Author, req.ISBN, req.Year, id,
    )
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update book"})
        return
    }

    rows, _ := result.RowsAffected()
    if rows == 0 {
        c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "Book updated"})
}

func DeleteBook(c *gin.Context) {
    id, err := strconv.Atoi(c.Param("id"))
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
        return
    }

    result, err := db.DB.Exec("DELETE FROM books WHERE id = $1", id)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete book"})
        return
    }

    rows, _ := result.RowsAffected()
    if rows == 0 {
        c.JSON(http.StatusNotFound, gin.H{"error": "Book not found"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"message": "Book deleted"})
}

Middleware

middleware/auth.go — simple API key middleware:

package middleware

import (
    "net/http"
    "os"

    "github.com/gin-gonic/gin"
)

func APIKeyAuth() gin.HandlerFunc {
    return func(c *gin.Context) {
        apiKey := c.GetHeader("X-API-Key")
        expectedKey := os.Getenv("API_KEY")

        if apiKey == "" || apiKey != expectedKey {
            c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
                "error": "Invalid or missing API key",
            })
            return
        }
        c.Next()
    }
}

Main Application

main.go:

package main

import (
    "log"
    "os"

    "github.com/gin-gonic/gin"
    "github.com/joho/godotenv"
    "github.com/yourname/go-api/db"
    "github.com/yourname/go-api/handlers"
    "github.com/yourname/go-api/middleware"
)

func main() {
    // Load .env in development
    if err := godotenv.Load(); err != nil {
        log.Println("No .env file found, using environment variables")
    }

    // Init database
    db.Init()

    // Set Gin mode
    if os.Getenv("GIN_MODE") == "release" {
        gin.SetMode(gin.ReleaseMode)
    }

    r := gin.Default()

    // Global middleware
    r.Use(gin.Logger())
    r.Use(gin.Recovery())

    // Health check (no auth)
    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "ok"})
    })

    // API v1 routes
    v1 := r.Group("/api/v1")
    v1.Use(middleware.APIKeyAuth())
    {
        books := v1.Group("/books")
        {
            books.GET("", handlers.GetBooks)
            books.GET("/:id", handlers.GetBook)
            books.POST("", handlers.CreateBook)
            books.PUT("/:id", handlers.UpdateBook)
            books.DELETE("/:id", handlers.DeleteBook)
        }
    }

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    log.Printf("Server starting on port %s", port)
    r.Run(":" + port)
}

.env:

DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=secret
DB_NAME=goapi
API_KEY=my-secret-api-key
PORT=8080

Testing the API

# Start the server
go run main.go

# Create a book
curl -X POST http://localhost:8080/api/v1/books \
  -H "X-API-Key: my-secret-api-key" \
  -H "Content-Type: application/json" \
  -d '{"title":"The Go Programming Language","author":"Alan Donovan","year":2015}'

# Get all books
curl -H "X-API-Key: my-secret-api-key" http://localhost:8080/api/v1/books

# Get one book
curl -H "X-API-Key: my-secret-api-key" http://localhost:8080/api/v1/books/1

# Update
curl -X PUT http://localhost:8080/api/v1/books/1 \
  -H "X-API-Key: my-secret-api-key" \
  -H "Content-Type: application/json" \
  -d '{"isbn":"9780134190440"}'

# Delete
curl -X DELETE -H "X-API-Key: my-secret-api-key" http://localhost:8080/api/v1/books/1

Dockerfile

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o api ./main.go

FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/api .
EXPOSE 8080
CMD ["./api"]

Build and run:

docker build -t go-api .
docker run -p 8080:8080 --env-file .env go-api

Docker Compose with PostgreSQL

docker-compose.yml:

version: "3.9"
services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_USER=postgres
      - DB_PASSWORD=secret
      - DB_NAME=goapi
      - API_KEY=my-secret-api-key
      - GIN_MODE=release
    depends_on:
      postgres:
        condition: service_healthy

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: goapi
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pgdata:
docker compose up --build

Adding Query Parameters and Pagination

Extend GetBooks to support pagination:

func GetBooks(c *gin.Context) {
    limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
    offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
    search := c.Query("search")

    query := "SELECT id, title, author, isbn, year, created_at FROM books"
    args := []interface{}{}

    if search != "" {
        query += " WHERE title ILIKE $1 OR author ILIKE $1"
        args = append(args, "%"+search+"%")
        query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
    } else {
        query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
    }

    args = append(args, limit, offset)
    // ... rest of handler
}

Production Tips

Use gin.SetMode(gin.ReleaseMode) — disables debug output and improves performance.

Rate limiting — add golang.org/x/time/rate or use nginx upstream.

Structured logging — replace gin.Logger() with zerolog or zap for JSON logs.

Graceful shutdown:

srv := &http.Server{Addr: ":" + port, Handler: r}

go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("listen: %v", err)
    }
}()

quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(ctx)

Summary

You now have a full Go REST API with Gin that includes:

  • Clean project structure with separated handlers, models, and db packages
  • PostgreSQL integration with connection pooling
  • Input validation via ShouldBindJSON
  • API key authentication middleware
  • Multi-stage Docker build for small production images
  • Docker Compose for local development

Go's compile-time safety and Gin's performance make this combination excellent for microservices handling thousands of requests per second.

Leonardo Lazzaro

Software engineer and technical writer. 10+ years experience in DevOps, Python, and Linux systems.

More articles by Leonardo Lazzaro