Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 017 / 100

Golang REST API tutorial: a hands-on guide for engineers

An HTTP API with the standard library.

Expona Admin6 min read

Building production-ready APIs in Go doesn't require third-party frameworks—the standard library is mature, fast, and battle-tested. This Golang REST API tutorial: a hands-on guide for engineers walks you through building a real API using only net/http, covering routing, request handling, JSON marshalling, and the often-overlooked timeout configuration that saves you from hanging connections in production.

Go's standard library gives us everything we need to ship a REST API quickly. You've probably reached for a framework like Gin or Echo at some point—and they're useful tools—but understanding the foundation matters. Go 1.22 introduced native route parameter matching, which changed the game for standard library users. Let's build something concrete.

Routing in Go 1.22+ with net/http

Starting with Go 1.22, the net/http package gained a significant upgrade: method-aware routing with path parameters. Before this, you'd either use a third-party router or manually inspect r.Method and parse the URL yourself. Now we have clean, declarative route registration.

Here's the basic setup:

go
package main

import (
	"encoding/json"
	"net/http"
	"time"
)

type Product struct {
	ID    string `json:"id"`
	Name  string `json:"name"`
	Price float64 `json:"price"`
}

func main() {
	mux := http.NewServeMux()
	
	// Route with method and path parameter
	mux.HandleFunc("GET /products/{id}", getProduct)
	mux.HandleFunc("POST /products", createProduct)
	mux.HandleFunc("GET /products", listProducts)
	
	// Configure timeouts
	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 5 * time.Second,
		IdleTimeout:  120 * time.Second,
	}
	
	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		panic(err)
	}
}

The pattern "GET /products/{id}" tells the multiplexer to match only GET requests to that path and extract the id segment. This is clean and explicit. The multiplexer won't match POST requests to the same path—they'll 404. If you need to handle multiple methods on one path, register them separately.

Internally, Go uses a trie-based router now. Performance is solid for typical API workloads. You won't run into routing bottlenecks unless your API has thousands of unique route patterns, which is rare.

Reading path values with PathValue

Once you've declared a route with a parameter like {id}, extracting it is straightforward. The request object has a PathValue method that retrieves the captured value by name.

go
func getProduct(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	
	// Fetch from database, service, cache, etc.
	product := fetchProductByID(id)
	
	if product == nil {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(product)
}

func fetchProductByID(id string) *Product {
	// Mock implementation
	if id == "123" {
		return &Product{ID: "123", Name: "Laptop", Price: 999.99}
	}
	return nil
}

A few things to note: PathValue returns a string. If you need to parse it as an integer or UUID, do that yourself—Go intentionally keeps this explicit. If the parameter doesn't exist in your route definition, PathValue returns an empty string, so validate accordingly.

In a real API, you'd likely pass this ID to a database query, cache lookup, or service layer. The routing layer should stay thin.

JSON responses with proper content-type headers

Sending JSON back to clients requires two things: the correct Content-Type header and proper encoding. The json.Encoder is idiomatic and efficient—it writes directly to the response writer without building the entire JSON in memory first.

go
func listProducts(w http.ResponseWriter, r *http.Request) {
	products := []Product{
		{ID: "1", Name: "Mouse", Price: 29.99},
		{ID: "2", Name: "Keyboard", Price: 79.99},
		{ID: "3", Name: "Monitor", Price: 299.99},
	}
	
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	if err := json.NewEncoder(w).Encode(products); err != nil {
		// Log this error; headers already sent
		return
	}
}

func createProduct(w http.ResponseWriter, r *http.Request) {
	var p Product
	
	// Decode the incoming JSON
	if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
		http.Error(w, "invalid request body", http.StatusBadRequest)
		return
	}
	
	// Validate, save to database, etc.
	p.ID = generateID()
	
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(p)
}

Setting the header before writing the body matters—once you call Write or Encode, the status code is committed and headers can't change. Always set headers first, then status, then body.

The charset=utf-8 in the Content-Type is optional in HTTP/1.1 but good practice. Clients will assume UTF-8 regardless, but being explicit doesn't hurt.

Server timeouts—the production requirement

This is where many junior engineers stumble. A simple http.ListenAndServe(":8080", mux) has no timeouts configured. If a client connects and never sends data, or sends data slowly, or your handler hangs, that connection will linger indefinitely. In production, this leads to connection exhaustion, memory leaks, and cascading failures.

Always configure an http.Server with explicit timeouts:

go
server := &http.Server{
	Addr:         ":8080",
	Handler:      mux,
	ReadTimeout:  5 * time.Second,      // Max time to read the entire request
	WriteTimeout: 5 * time.Second,      // Max time to write the response
	IdleTimeout:  120 * time.Second,    // Max time to keep idle connections
}

if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
	panic(err)
}

ReadTimeout includes the time to read headers and body. If your API expects large payloads or handles slow clients, increase this. WriteTimeout applies to the response—if your handler takes 10 seconds to generate JSON, set this to at least 11 seconds. IdleTimeout controls how long HTTP/1.1 keep-alive connections stay open between requests.

These aren't one-size-fits-all values. For a typical REST API with quick handlers, 5 seconds for read and write is reasonable. Long-polling or streaming endpoints need different settings. Monitor your metrics and adjust.

Common mistakes

Forgetting to set Content-Type before encoding JSON. Some HTTP clients will fail to parse the response if the header is missing or wrong. Always set it explicitly.

Extracting path values without validation. PathValue returns a string. If you expect a UUID, parse and validate it. Don't pass raw strings directly to SQL queries or file paths.

Writing the status code after the body starts. Once you call Encode, you can't change the status code. Structure your handler to set status and headers before writing the body.

Deploying without timeouts. Test your API under load. Open many connections and send requests slowly. Without timeouts, your server will run out of memory or file descriptors. This is a common source of production outages.

Assuming framework knowledge transfers directly. If you're used to frameworks like Gin, remember that the standard library doesn't have middleware chains, context patterns, or automatic error handling. You'll write more boilerplate, but you'll also understand exactly what's happening.

Key takeaways

  • Go 1.22+ gives you method-aware routing with path parameters built into net/http. Use mux.HandleFunc("GET /products/{id}", handler) instead of reaching for a framework.
  • Extract path values with r.PathValue("id") and validate them immediately. Don't assume the format is correct.
  • Send JSON responses using json.NewEncoder(w) after setting the Content-Type header and (if needed) a custom status code.
  • Always configure ReadTimeout, WriteTimeout, and IdleTimeout on your http.Server. Timeouts prevent resource exhaustion and are non-negotiable in production.
  • The standard library is sufficient for many APIs. Frameworks add value through middleware, validation helpers, and developer ergonomics—not through fundamental capabilities.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles