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.