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:
| Framework | GitHub Stars | Speed | Features |
|---|---|---|---|
| Gin | 80K+ | Very fast | Routing, binding, middleware, validation |
| Echo | 30K+ | Very fast | Similar to Gin, slightly different API |
| Fiber | 35K+ | Fastest | Express.js-style, uses fasthttp |
| Chi | 18K+ | Fast | Lightweight, 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 emptyemail— must be a valid emailgte=1— greater than or equal to 1lte=150— less than or equal to 150min=3— string minimum length 3max=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:
Related Articles
- Go Tutorial #15: Building HTTP Servers with net/http — Go’s built-in HTTP server
- Go Tutorial #17: Testing in Go — Test your Gin handlers
- Go Tutorial #18: Middleware and JWT Authentication — Add authentication to your Gin API
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.