How to Write a REST API in Go
Go excels at building REST APIs. The language's built-in concurrency, fast compilation, and comprehensive standard library make it ideal for high-performance web services. Unlike frameworks in other...
Key Insights
- Go’s standard library provides everything needed for production REST APIs without heavy frameworks, giving you full control and excellent performance
- Use established routers like gorilla/mux or chi for clean route definitions and middleware support—the standard library’s ServeMux is too basic for real-world APIs
- Structure your API with proper separation of concerns: handlers for HTTP logic, services for business logic, and models for data representation
Introduction & Setup
Go excels at building REST APIs. The language’s built-in concurrency, fast compilation, and comprehensive standard library make it ideal for high-performance web services. Unlike frameworks in other languages that abstract away HTTP details, Go keeps you close to the protocol while providing enough convenience to be productive.
Start by initializing your project:
mkdir go-rest-api
cd go-rest-api
go mod init github.com/yourusername/go-rest-api
For routing, install gorilla/mux—it’s battle-tested and provides URL parameters, middleware support, and method-based routing:
go get -u github.com/gorilla/mux
Your basic project structure should look like this:
go-rest-api/
├── main.go
├── handlers/
│ └── users.go
├── models/
│ └── user.go
└── go.mod
This separation keeps your code maintainable as the API grows.
Creating the HTTP Server & Router
Go’s net/http package handles HTTP servers out of the box. Combined with gorilla/mux for routing, you get a clean foundation:
// main.go
package main
import (
"log"
"net/http"
"time"
"github.com/gorilla/mux"
)
func main() {
r := mux.NewRouter()
// Define routes
r.HandleFunc("/api/users", GetUsers).Methods("GET")
r.HandleFunc("/api/users/{id}", GetUser).Methods("GET")
r.HandleFunc("/api/users", CreateUser).Methods("POST")
r.HandleFunc("/api/users/{id}", UpdateUser).Methods("PUT")
r.HandleFunc("/api/users/{id}", DeleteUser).Methods("DELETE")
// Configure server
srv := &http.Server{
Handler: r,
Addr: ":8080",
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Println("Server starting on :8080")
log.Fatal(srv.ListenAndServe())
}
Always set timeouts on your server. Without them, slow clients can exhaust server resources. The http.Server struct gives you fine-grained control over these parameters.
Defining Data Models & JSON Handling
Go structs with JSON tags define your API’s data contracts. The encoding/json package handles serialization automatically:
// models/user.go
package models
import "time"
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
type CreateUserRequest struct {
Username string `json:"username"`
Email string `json:"email"`
}
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
Separate your request/response models from internal domain models. This decoupling lets you change internal representations without breaking API contracts.
JSON encoding and decoding is straightforward:
// Encoding
user := User{ID: 1, Username: "john", Email: "john@example.com"}
json.NewEncoder(w).Encode(user)
// Decoding
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// handle error
}
defer r.Body.Close()
Always close the request body and handle decode errors—malformed JSON is common in production.
Implementing CRUD Endpoints
Here’s a complete implementation of CRUD handlers. In production, you’d replace the in-memory storage with a database:
// handlers/users.go
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"sync"
"github.com/gorilla/mux"
"github.com/yourusername/go-rest-api/models"
)
var (
users = make(map[int]models.User)
nextID = 1
usersMu sync.RWMutex
)
func GetUsers(w http.ResponseWriter, r *http.Request) {
usersMu.RLock()
defer usersMu.RUnlock()
userList := make([]models.User, 0, len(users))
for _, user := range users {
userList = append(userList, user)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(userList)
}
func GetUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid user ID")
return
}
usersMu.RLock()
user, exists := users[id]
usersMu.RUnlock()
if !exists {
respondWithError(w, http.StatusNotFound, "User not found")
return
}
respondWithJSON(w, http.StatusOK, user)
}
func CreateUser(w http.ResponseWriter, r *http.Request) {
var req models.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request payload")
return
}
defer r.Body.Close()
usersMu.Lock()
user := models.User{
ID: nextID,
Username: req.Username,
Email: req.Email,
CreatedAt: time.Now(),
}
users[nextID] = user
nextID++
usersMu.Unlock()
respondWithJSON(w, http.StatusCreated, user)
}
func UpdateUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid user ID")
return
}
var req models.CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request payload")
return
}
defer r.Body.Close()
usersMu.Lock()
user, exists := users[id]
if !exists {
usersMu.Unlock()
respondWithError(w, http.StatusNotFound, "User not found")
return
}
user.Username = req.Username
user.Email = req.Email
users[id] = user
usersMu.Unlock()
respondWithJSON(w, http.StatusOK, user)
}
func DeleteUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid user ID")
return
}
usersMu.Lock()
if _, exists := users[id]; !exists {
usersMu.Unlock()
respondWithError(w, http.StatusNotFound, "User not found")
return
}
delete(users, id)
usersMu.Unlock()
w.WriteHeader(http.StatusNoContent)
}
func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(payload)
}
func respondWithError(w http.ResponseWriter, code int, message string) {
respondWithJSON(w, code, models.ErrorResponse{
Error: http.StatusText(code),
Message: message,
})
}
Notice the helper functions for consistent responses and proper use of HTTP status codes—201 for creation, 204 for successful deletion, 404 for not found.
Middleware & Error Handling
Middleware in Go follows a simple pattern—functions that wrap handlers. Here’s logging and panic recovery middleware:
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("%s %s", r.Method, r.RequestURI)
next.ServeHTTP(w, r)
log.Printf("Completed in %v", time.Since(start))
})
}
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("Panic: %v", err)
respondWithError(w, http.StatusInternalServerError,
"Internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
// In main.go
r.Use(RecoveryMiddleware)
r.Use(LoggingMiddleware)
Always implement panic recovery in production. A single panic in a handler shouldn’t crash your entire server.
Testing Your API
Go’s testing tools make API testing straightforward. Use httptest to test handlers without starting a real server:
// handlers/users_test.go
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
)
func TestGetUsers(t *testing.T) {
req, err := http.NewRequest("GET", "/api/users", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler := http.HandlerFunc(GetUsers)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
var users []models.User
if err := json.NewDecoder(rr.Body).Decode(&users); err != nil {
t.Errorf("failed to decode response: %v", err)
}
}
func TestCreateUser(t *testing.T) {
payload := map[string]string{
"username": "testuser",
"email": "test@example.com",
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", "/api/users", bytes.NewBuffer(body))
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler := http.HandlerFunc(CreateUser)
handler.ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusCreated {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusCreated)
}
}
Write tests as you build features. The httptest package makes it painless.
Best Practices & Next Steps
Validate input before processing. Here’s a simple validation approach:
func (r *CreateUserRequest) Validate() error {
if r.Username == "" {
return errors.New("username is required")
}
if len(r.Username) < 3 {
return errors.New("username must be at least 3 characters")
}
if r.Email == "" || !strings.Contains(r.Email, "@") {
return errors.New("valid email is required")
}
return nil
}
For database integration, use database/sql with a driver like pgx for PostgreSQL. Structure your code with a repository pattern to keep database logic separate from HTTP handlers.
Version your API from day one using URL prefixes like /api/v1/users. When you need breaking changes, increment the version rather than breaking existing clients.
For production deployment, use environment variables for configuration, implement health check endpoints, and add metrics collection. Consider using Docker for consistent deployments across environments.
Go’s simplicity and performance make it an excellent choice for REST APIs. Start with the standard library, add proven tools like gorilla/mux, and focus on clean separation of concerns. Your API will be maintainable, testable, and fast.