In the previous tutorial, you built HTTP servers with the standard net/http package. That works great for simple servers. But as your API grows, you need better routing, input validation, and middleware support.

Gin is the most popular Go web framework with over 80,000 GitHub stars. It is fast, well-documented, and used in production by thousands of companies. In this tutorial, you will learn how to build REST APIs with Gin.

Why Gin?

Go has several web frameworks. Here is a quick comparison:

FrameworkGitHub StarsSpeedFeatures
Gin80K+Very fastRouting, binding, middleware, validation
Echo30K+Very fastSimilar to Gin, slightly different API
Fiber35K+FastestExpress.js-style, uses fasthttp
Chi18K+FastLightweight, stdlib compatible

Gin is the most popular choice. It has great documentation, a large community, and many middleware packages. If you learn Gin, you can easily switch to other frameworks later — they all use similar concepts.

Setting Up Gin

First, install Gin in your project:

go get github.com/gin-gonic/gin

Here is a minimal Gin server:

package main

import (
    "net/http"

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

func main() {
    r := gin.Default()

    r.GET("/", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": "Hello, World!",
        })
    })

    r.Run(":8080") // Listen on port 8080
}

Run the server and open http://localhost:8080 in your browser. You will see:

{"message":"Hello, World!"}

gin.Default() creates a router with two built-in middlewares: Logger (logs every request) and Recovery (catches panics and returns 500).

gin.H is a shortcut for map[string]interface{}. It makes JSON responses easy to write.

Routes and HTTP Methods

Gin supports all HTTP methods:

package main

import (
    "net/http"

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

type User struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

var users = []User{
    {ID: "1", Name: "Alex", Email: "alex@example.com"},
    {ID: "2", Name: "Sam", Email: "sam@example.com"},
}

func main() {
    r := gin.Default()

    r.GET("/users", getUsers)
    r.GET("/users/:id", getUserByID)
    r.POST("/users", createUser)
    r.PUT("/users/:id", updateUser)
    r.DELETE("/users/:id", deleteUser)

    r.Run(":8080")
}

func getUsers(c *gin.Context) {
    c.JSON(http.StatusOK, users)
}

func getUserByID(c *gin.Context) {
    id := c.Param("id")

    for _, user := range users {
        if user.ID == id {
            c.JSON(http.StatusOK, user)
            return
        }
    }

    c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
}

func createUser(c *gin.Context) {
    var newUser User

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

    users = append(users, newUser)
    c.JSON(http.StatusCreated, newUser)
}

func updateUser(c *gin.Context) {
    id := c.Param("id")
    var updated User

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

    for i, user := range users {
        if user.ID == id {
            updated.ID = id
            users[i] = updated
            c.JSON(http.StatusOK, updated)
            return
        }
    }

    c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
}

func deleteUser(c *gin.Context) {
    id := c.Param("id")

    for i, user := range users {
        if user.ID == id {
            users = append(users[:i], users[i+1:]...)
            c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
            return
        }
    }

    c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
}

This gives you a complete CRUD API. Let us test it with curl:

# Get all users
curl http://localhost:8080/users

# Get a single user
curl http://localhost:8080/users/1

# Create a user
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"id":"3","name":"Jordan","email":"jordan@example.com"}'

# Update a user
curl -X PUT http://localhost:8080/users/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Alex Updated","email":"alex.new@example.com"}'

# Delete a user
curl -X DELETE http://localhost:8080/users/1

Path Parameters

Use :name to define path parameters:

r.GET("/users/:id", func(c *gin.Context) {
    id := c.Param("id") // Get the value of :id
    c.JSON(http.StatusOK, gin.H{"id": id})
})

// Wildcard — matches everything after /files/
r.GET("/files/*path", func(c *gin.Context) {
    path := c.Param("path") // "/images/logo.png"
    c.JSON(http.StatusOK, gin.H{"path": path})
})

Query Parameters

Use c.Query() and c.DefaultQuery() for query strings:

// GET /search?q=golang&page=2&limit=10
r.GET("/search", func(c *gin.Context) {
    query := c.Query("q")                     // "golang"
    page := c.DefaultQuery("page", "1")       // "2" (or "1" if missing)
    limit := c.DefaultQuery("limit", "20")    // "10" (or "20" if missing)

    c.JSON(http.StatusOK, gin.H{
        "query": query,
        "page":  page,
        "limit": limit,
    })
})

Request Body Binding

Gin can automatically parse JSON, XML, and form data into Go structs. Use struct tags to control the binding:

type CreateUserRequest struct {
    Name  string `json:"name" binding:"required"`
    Email string `json:"email" binding:"required,email"`
    Age   int    `json:"age" binding:"required,gte=1,lte=150"`
}

r.POST("/users", func(c *gin.Context) {
    var req CreateUserRequest

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

    // req is now a valid CreateUserRequest
    c.JSON(http.StatusCreated, gin.H{
        "name":  req.Name,
        "email": req.Email,
        "age":   req.Age,
    })
})

The binding tag uses the go-playground/validator library under the hood. Common validation rules:

  • required — field must not be empty
  • email — must be a valid email
  • gte=1 — greater than or equal to 1
  • lte=150 — less than or equal to 150
  • min=3 — string minimum length 3
  • max=100 — string maximum length 100

ShouldBindJSON returns an error if validation fails. BindJSON does the same but automatically returns a 400 response — use ShouldBindJSON for more control.

Route Groups

Group related routes together. This keeps your code organized and lets you apply middleware to a group:

func main() {
    r := gin.Default()

    // Public routes
    public := r.Group("/api/v1")
    {
        public.GET("/health", healthCheck)
        public.POST("/login", login)
        public.POST("/register", register)
    }

    // Protected routes (will add auth middleware in GO-18)
    protected := r.Group("/api/v1")
    {
        protected.GET("/users", getUsers)
        protected.GET("/users/:id", getUserByID)
        protected.POST("/users", createUser)
        protected.PUT("/users/:id", updateUser)
        protected.DELETE("/users/:id", deleteUser)
    }

    r.Run(":8080")
}

func healthCheck(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

Route groups also support nesting:

v1 := r.Group("/api/v1")
{
    users := v1.Group("/users")
    {
        users.GET("/", getUsers)
        users.POST("/", createUser)
    }

    posts := v1.Group("/posts")
    {
        posts.GET("/", getPosts)
        posts.POST("/", createPost)
    }
}

Custom Response Formats

Gin supports multiple response formats:

// JSON response
c.JSON(http.StatusOK, gin.H{"key": "value"})

// Pretty-printed JSON
c.IndentedJSON(http.StatusOK, gin.H{"key": "value"})

// String response
c.String(http.StatusOK, "Hello, %s", "World")

// XML response
c.XML(http.StatusOK, gin.H{"key": "value"})

// Redirect
c.Redirect(http.StatusMovedPermanently, "https://example.com")

// File response
c.File("./path/to/file.pdf")

Error Handling Pattern

Here is a clean pattern for handling errors in Gin:

type APIError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

func handleError(c *gin.Context, status int, message string) {
    c.JSON(status, APIError{
        Code:    status,
        Message: message,
    })
}

func getUserByID(c *gin.Context) {
    id := c.Param("id")

    user, err := findUser(id)
    if err != nil {
        handleError(c, http.StatusNotFound, "User not found")
        return
    }

    c.JSON(http.StatusOK, user)
}

This gives you consistent error responses across your API:

{
    "code": 404,
    "message": "User not found"
}

A Complete Example

Here is a complete Gin API with all the concepts together:

package main

import (
    "fmt"
    "net/http"

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

type Book struct {
    ID     string `json:"id"`
    Title  string `json:"title" binding:"required"`
    Author string `json:"author" binding:"required"`
    Year   int    `json:"year" binding:"required,gte=1000,lte=2030"`
}

var books = []Book{
    {ID: "1", Title: "The Go Programming Language", Author: "Alan Donovan", Year: 2015},
    {ID: "2", Title: "Concurrency in Go", Author: "Katherine Cox-Buday", Year: 2017},
}

var nextID = 3

func main() {
    r := gin.Default()

    api := r.Group("/api/v1")
    {
        api.GET("/books", listBooks)
        api.GET("/books/:id", getBook)
        api.POST("/books", addBook)
        api.PUT("/books/:id", updateBook)
        api.DELETE("/books/:id", removeBook)
    }

    r.GET("/health", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"status": "ok"})
    })

    r.Run(":8080")
}

func listBooks(c *gin.Context) {
    // Support pagination with query parameters
    page := c.DefaultQuery("page", "1")
    limit := c.DefaultQuery("limit", "10")

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

func getBook(c *gin.Context) {
    id := c.Param("id")

    for _, book := range books {
        if book.ID == id {
            c.JSON(http.StatusOK, book)
            return
        }
    }

    c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
}

func addBook(c *gin.Context) {
    var book Book

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

    book.ID = fmt.Sprintf("%d", nextID)
    nextID++
    books = append(books, book)

    c.JSON(http.StatusCreated, book)
}

func updateBook(c *gin.Context) {
    id := c.Param("id")
    var updated Book

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

    for i, book := range books {
        if book.ID == id {
            updated.ID = id
            books[i] = updated
            c.JSON(http.StatusOK, updated)
            return
        }
    }

    c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
}

func removeBook(c *gin.Context) {
    id := c.Param("id")

    for i, book := range books {
        if book.ID == id {
            books = append(books[:i], books[i+1:]...)
            c.JSON(http.StatusOK, gin.H{"message": "book deleted"})
            return
        }
    }

    c.JSON(http.StatusNotFound, gin.H{"error": "book not found"})
}

Gin vs net/http — When to Use Each

Use net/http when:

  • You want zero dependencies
  • Your API is simple (few routes)
  • You are building internal tools

Use Gin when:

  • You need route groups and middleware
  • You want built-in validation
  • You are building a production API
  • You want a large ecosystem of middleware

In practice, most Go teams use a framework for production APIs. Gin is the safe, popular choice.

Common Mistakes

1. Using Bind instead of ShouldBind.

BindJSON writes a 400 response automatically if binding fails. ShouldBindJSON returns the error so you can handle it yourself. Use ShouldBindJSON for custom error responses.

2. Forgetting to return after error responses.

// Wrong — continues executing after error
if err := c.ShouldBindJSON(&req); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
// This code still runs!
c.JSON(http.StatusOK, req)

Always return after sending an error response.

3. Not using route groups for versioning.

// Good — easy to add v2 later
v1 := r.Group("/api/v1")
v1.GET("/users", getUsers)

// Bad — hard to change later
r.GET("/users", getUsers)

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-16 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #17: Testing in Go, you will learn:

  • Table-driven tests — Go’s idiomatic testing pattern
  • Benchmarks and fuzzing
  • Testing HTTP handlers with httptest
  • Mocking with interfaces

This is part 16 of the Go Tutorial series. Follow along to learn Go from scratch.