Why Go for Web APIs?
Go has become a mainstream choice for backend APIs and microservices, competing directly with Node.js and Python. The reasons are practical: compilation to a single binary, excellent performance without tuning, built-in concurrency, and a standard library that handles ~80% of HTTP needs without third-party frameworks.
This guide builds a complete REST API from scratch, covering patterns you'll use in production.
Starting with net/http
Go's standard library net/http is production-ready:
package main
import (
"encoding/json"
"log"
"net/http"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("GET /users/{id}", getUser) // Go 1.22+ path parameters
mux.HandleFunc("POST /users", createUser)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Println("Starting server on :8080")
log.Fatal(server.ListenAndServe())
}
func listUsers(w http.ResponseWriter, r *http.Request) {
users := []User{
{ID: 1, Name: "Alice", Email: "alice@example.com"},
{ID: 2, Name: "Bob", Email: "bob@example.com"},
}
writeJSON(w, http.StatusOK, users)
}
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // Go 1.22+
user, err := findUserByID(id)
if err != nil {
writeError(w, http.StatusNotFound, "User not found")
return
}
writeJSON(w, http.StatusOK, user)
}
// Helper functions
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("Error encoding response: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
Using Chi for Complex Routing
For more complex APIs, chi provides clean routing without a heavy framework:
// go get github.com/go-chi/chi/v5
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
// Built-in middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
// Custom middleware
r.Use(corsMiddleware)
r.Use(authMiddleware)
// Route groups
r.Route("/api/v1", func(r chi.Router) {
r.Route("/users", func(r chi.Router) {
r.Get("/", listUsers)
r.Post("/", createUser)
r.Route("/{userID}", func(r chi.Router) {
r.Use(userCtx) // Load user into context
r.Get("/", getUser)
r.Put("/", updateUser)
r.Delete("/", deleteUser)
r.Get("/posts", getUserPosts)
})
})
r.Route("/posts", func(r chi.Router) {
r.Get("/", listPosts)
r.With(requireAuth).Post("/", createPost)
})
})
http.ListenAndServe(":8080", r)
}
Middleware Pattern
// Middleware is a function that wraps an http.Handler
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Wrap ResponseWriter to capture status code
wrapped := &responseWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(wrapped, r)
log.Printf(
"%s %s %d %v %s",
r.Method, r.URL.Path,
wrapped.status,
time.Since(start),
r.RemoteAddr,
)
})
}
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(status int) {
rw.status = status
rw.ResponseWriter.WriteHeader(status)
}
// Authentication middleware
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if !strings.HasPrefix(token, "Bearer ") {
writeError(w, http.StatusUnauthorized, "Missing token")
return
}
claims, err := validateJWT(strings.TrimPrefix(token, "Bearer "))
if err != nil {
writeError(w, http.StatusUnauthorized, "Invalid token")
return
}
// Store user in request context
ctx := context.WithValue(r.Context(), userKey, claims.UserID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Retrieve from context
func currentUser(r *http.Request) int {
return r.Context().Value(userKey).(int)
}
JSON Request Handling
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=0,lte=150"`
}
func createUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
// Decode JSON body
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
return
}
// Validate (using github.com/go-playground/validator)
if err := validate.Struct(req); err != nil {
errors := formatValidationErrors(err)
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
"error": "Validation failed",
"fields": errors,
})
return
}
user, err := userService.Create(r.Context(), req)
if err != nil {
switch {
case errors.Is(err, ErrEmailTaken):
writeError(w, http.StatusConflict, "Email already registered")
default:
log.Printf("Error creating user: %v", err)
writeError(w, http.StatusInternalServerError, "Internal server error")
}
return
}
writeJSON(w, http.StatusCreated, user)
}
Database Integration with pgx
// go get github.com/jackc/pgx/v5
import "github.com/jackc/pgx/v5/pgxpool"
// Connection pool
func newDB(ctx context.Context, connStr string) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(connStr)
if err != nil {
return nil, err
}
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = 5 * time.Minute
config.HealthCheckPeriod = 30 * time.Second
return pgxpool.NewWithConfig(ctx, config)
}
// Repository pattern
type UserRepository struct {
db *pgxpool.Pool
}
func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
var user User
err := r.db.QueryRow(ctx,
"SELECT id, name, email, created_at FROM users WHERE id = $1",
id,
).Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("FindByID: %w", err)
}
return &user, nil
}
func (r *UserRepository) List(ctx context.Context, limit, offset int) ([]User, error) {
rows, err := r.db.Query(ctx,
"SELECT id, name, email FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
limit, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0)
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// Transaction
func (r *UserRepository) Transfer(ctx context.Context, fromID, toID int, amount float64) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) // No-op if committed
_, err = tx.Exec(ctx,
"UPDATE accounts SET balance = balance - $1 WHERE user_id = $2",
amount, fromID,
)
if err != nil {
return fmt.Errorf("debit: %w", err)
}
_, err = tx.Exec(ctx,
"UPDATE accounts SET balance = balance + $1 WHERE user_id = $2",
amount, toID,
)
if err != nil {
return fmt.Errorf("credit: %w", err)
}
return tx.Commit(ctx)
}
Error Handling Patterns
// Sentinel errors — specific error values for comparison
var (
ErrNotFound = errors.New("not found")
ErrEmailTaken = errors.New("email already taken")
ErrUnauthorized = errors.New("unauthorized")
)
// Wrapped errors — add context while preserving the original
func getUser(id int) (*User, error) {
user, err := db.QueryUser(id)
if err != nil {
return nil, fmt.Errorf("getUser(%d): %w", id, err) // %w wraps the error
}
return user, nil
}
// Check error type with errors.Is (works through wrapping chain)
err := getUser(123)
if errors.Is(err, sql.ErrNoRows) {
// Handle not found
}
// Custom error types for rich error information
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: %s - %s", e.Field, e.Message)
}
// Check with errors.As
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Field %s: %s", valErr.Field, valErr.Message)
}
Project Structure
myapi/
├── cmd/
│ └── api/
│ └── main.go # Entry point
├── internal/
│ ├── handler/ # HTTP handlers
│ │ ├── users.go
│ │ └── posts.go
│ ├── service/ # Business logic
│ │ ├── user_service.go
│ │ └── post_service.go
│ ├── repository/ # Data access
│ │ ├── user_repo.go
│ │ └── post_repo.go
│ ├── middleware/ # HTTP middleware
│ │ ├── auth.go
│ │ └── logging.go
│ └── model/ # Domain types
│ ├── user.go
│ └── post.go
├── config/
│ └── config.go # Configuration loading
├── go.mod
└── go.sum
Go's approach to web development is refreshingly pragmatic — a minimal standard library, explicit error handling, and straightforward concurrency. The lack of "magic" (no ORMs, no DI containers by default) makes Go code easy to read and debug, even in large codebases.
→ Encode and decode URL parameters with the URL Encoder/Decoder tool.