DEV Community

Cover image for You Don't Need Gin: Building a Go API with Just the Standard Library
Gabriel Anhaia
Gabriel Anhaia

Posted on

You Don't Need Gin: Building a Go API with Just the Standard Library


You don't need Gin. You don't need Echo. You don't need Fiber. Go's standard library, since 1.22, has everything you need to build a production-ready REST API. I'll prove it in under 200 lines.

Open any Go API tutorial and the first step is always "install this framework." That's a shame, because Go ships with one of the best HTTP toolkits of any language, and most developers never really learn it. After Go 1.22 landed enhanced routing in net/http, the last real argument for reaching for a framework evaporated. Let's build a full CRUD API from scratch, with middleware, error handling, and tests. Zero external dependencies.

What Changed in Go 1.22

Before 1.22, http.ServeMux was embarrassingly basic. You could register paths like /books/, but you couldn't bind HTTP methods or extract path parameters. That meant every handler started with a switch r.Method block, and extracting an ID from /books/42 was manual string splitting -- strings.TrimPrefix(r.URL.Path, "/books/"). Ugly, error-prone, and the exact thing that sent people running to Gin or chi.

The community built dozens of routers to fill this gap. gorilla/mux, httprouter, chi -- all good libraries, but all solving a problem that the standard library should have handled. The Go team agreed, and Go 1.22 (released February 2024) finally shipped an enhanced ServeMux that supports patterns like:

mux.HandleFunc("GET /books/{id}", getBookHandler)
mux.HandleFunc("POST /books", createBookHandler)
Enter fullscreen mode Exit fullscreen mode

Method-based routing. Path parameters via r.PathValue("id"). Built into the standard library. No third-party router needed.

The patterns also support wildcards (/files/{path...} catches everything after /files/), host-based routing, and precedence rules that do the right thing when patterns overlap. But for a REST API, the method + path parameter combo is what matters most.

That's all the context you need. Let's build something.

Project Setup

mkdir bookshelf-api && cd bookshelf-api
go mod init bookshelf-api
Enter fullscreen mode Exit fullscreen mode

We'll keep everything in a single main.go file. No internal/, no pkg/, no cmd/. This is about the HTTP layer, not project architecture. One file, one focus.

Define the Domain

A bookshelf API needs books. Nothing fancy:

package main

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

type Book struct {
    ID        string    `json:"id"`
    Title     string    `json:"title"`
    Author    string    `json:"author"`
    Year      int       `json:"year"`
    CreatedAt time.Time `json:"created_at"`
}
Enter fullscreen mode Exit fullscreen mode

And a thread-safe in-memory store. We're not here to talk about databases:

var (
    books   = make(map[string]Book)
    booksMu sync.RWMutex
    nextID  int
)
Enter fullscreen mode Exit fullscreen mode

We use sync.RWMutex because reads are more frequent than writes, and a read lock won't block other readers. Small detail, but it matters under load.

Error Handling

Before writing handlers, let's solve a problem most tutorials ignore. Go's built-in http.Error() returns plain text. If your API speaks JSON, a plain text error response is a bug. Every client parsing your responses has to handle two different content types. Don't do that.

A small helper fixes it:

func jsonError(w http.ResponseWriter, message string, code int) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(map[string]string{"error": message})
}
Enter fullscreen mode Exit fullscreen mode

Now every error response is JSON. Consistent. Parseable. Professional. We'll use this everywhere instead of http.Error().

One thing to watch out for: you must call w.WriteHeader() after setting headers and before writing the body. If you write bytes to w before calling WriteHeader, Go implicitly sends a 200. The order matters.

Handlers

Five handlers, one for each CRUD operation plus a list endpoint. Each one is a plain http.HandlerFunc -- no magic, no interface to satisfy beyond the standard signature.

List All Books

func handleListBooks(w http.ResponseWriter, r *http.Request) {
    booksMu.RLock()
    defer booksMu.RUnlock()

    result := make([]Book, 0, len(books))
    for _, b := range books {
        result = append(result, b)
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(result)
}
Enter fullscreen mode Exit fullscreen mode

Grab a read lock, collect books into a slice, encode as JSON. The make([]Book, 0, len(books)) ensures we return [] instead of null when there are no books. That matters for frontend clients.

Get a Single Book

func handleGetBook(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    booksMu.RLock()
    book, ok := books[id]
    booksMu.RUnlock()

    if !ok {
        jsonError(w, "book not found", http.StatusNotFound)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(book)
}
Enter fullscreen mode Exit fullscreen mode

r.PathValue("id") is the Go 1.22 addition. No regex, no Gin's c.Param("id"), no manual parsing. Just ask the request for the value.

Create a Book

func handleCreateBook(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title  string `json:"title"`
        Author string `json:"author"`
        Year   int    `json:"year"`
    }

    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        jsonError(w, "invalid JSON body", http.StatusBadRequest)
        return
    }

    if input.Title == "" || input.Author == "" {
        jsonError(w, "title and author are required", http.StatusBadRequest)
        return
    }

    booksMu.Lock()
    nextID++
    id := fmt.Sprintf("%d", nextID)
    book := Book{
        ID:        id,
        Title:     input.Title,
        Author:    input.Author,
        Year:      input.Year,
        CreatedAt: time.Now().UTC(),
    }
    books[id] = book
    booksMu.Unlock()

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(book)
}
Enter fullscreen mode Exit fullscreen mode

Notice the anonymous struct for the input. We don't reuse the Book type for deserialization because the client shouldn't set the ID or CreatedAt -- that's server logic. Separate input types prevent that class of bug.

Also notice the validation. It's basic, but it's there. In a real API you'd want something more structured, but the principle is clear: validate before you mutate state.

Don't forget to add "fmt" to your imports -- we need it for fmt.Sprintf.

Update a Book

func handleUpdateBook(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    var input struct {
        Title  string `json:"title"`
        Author string `json:"author"`
        Year   int    `json:"year"`
    }

    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        jsonError(w, "invalid JSON body", http.StatusBadRequest)
        return
    }

    booksMu.Lock()
    book, ok := books[id]
    if !ok {
        booksMu.Unlock()
        jsonError(w, "book not found", http.StatusNotFound)
        return
    }

    if input.Title != "" {
        book.Title = input.Title
    }
    if input.Author != "" {
        book.Author = input.Author
    }
    if input.Year != 0 {
        book.Year = input.Year
    }
    books[id] = book
    booksMu.Unlock()

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(book)
}
Enter fullscreen mode Exit fullscreen mode

This is a partial update -- only non-zero fields get overwritten. That's a design choice. Some APIs prefer full replacement on PUT. Either way, the Go mechanics are the same.

One subtle point: we unlock before writing the response. The JSON encoding doesn't need the lock, and holding it longer than necessary hurts concurrency.

Delete a Book

func handleDeleteBook(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")

    booksMu.Lock()
    _, ok := books[id]
    if !ok {
        booksMu.Unlock()
        jsonError(w, "book not found", http.StatusNotFound)
        return
    }
    delete(books, id)
    booksMu.Unlock()

    w.WriteHeader(http.StatusNoContent)
}
Enter fullscreen mode Exit fullscreen mode

204 No Content. No response body. Clean.

Middleware

This is where frameworks usually "justify" themselves. Gin gives you gin.Logger(), Echo gives you middleware.CORS(). The implication is that middleware is hard without a framework. It's not.

The pattern is simple: a middleware is a function that takes an http.Handler and returns an http.Handler. The returned handler does some work before and/or after calling the original handler's ServeHTTP method. That's it. No interface to implement, no struct to embed, no registration system to learn.

Logging Middleware

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
    })
}
Enter fullscreen mode Exit fullscreen mode

Every request gets logged with the method, path, and duration. Seven lines. In a real application, you'd probably also capture the response status code -- that requires wrapping the ResponseWriter, which is a few more lines but the same pattern. The point is: the foundation is this simple.

CORS Middleware

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusOK)
            return
        }

        next.ServeHTTP(w, r)
    })
}
Enter fullscreen mode Exit fullscreen mode

Handles preflight requests and sets the appropriate headers. In production you'd tighten Allow-Origin to specific domains, but the pattern is identical.

Chaining Middleware

No special chaining library needed. Just nest the calls:

func chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}
Enter fullscreen mode Exit fullscreen mode

We iterate in reverse so the first middleware in the list is the outermost wrapper -- it runs first on the way in, last on the way out. Intuitive ordering. Usage looks like this:

handler := chain(mux, loggingMiddleware, corsMiddleware)
Enter fullscreen mode Exit fullscreen mode

Logging runs first (outermost), CORS runs second (inner). You can add as many as you want -- rate limiting, auth, request ID injection -- just append to the list. The signature func(http.Handler) http.Handler is the universal middleware interface in Go. Any middleware that follows this convention is composable with any other. That's a property you get for free from the stdlib's design, and it's one that frameworks sometimes break by introducing their own context types.

For production patterns like graceful shutdown, auth middleware, and database integration, I cover all of that in my book.

Wiring It Up

Here's where everything comes together. The main() function registers routes and starts the server:

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /books", handleListBooks)
    mux.HandleFunc("GET /books/{id}", handleGetBook)
    mux.HandleFunc("POST /books", handleCreateBook)
    mux.HandleFunc("PUT /books/{id}", handleUpdateBook)
    mux.HandleFunc("DELETE /books/{id}", handleDeleteBook)

    handler := chain(mux, loggingMiddleware, corsMiddleware)

    log.Println("Server starting on :8080")
    if err := http.ListenAndServe(":8080", handler); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Look at those route registrations. GET /books/{id}. Clean, readable, obvious. No .Group(), no router objects, no framework DSL. Just the Go standard library.

Run it:

go run main.go
Enter fullscreen mode Exit fullscreen mode

Test it:

# Create a book
curl -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":"The Go Programming Language","author":"Donovan & Kernighan","year":2015}'

# List all books
curl http://localhost:8080/books

# Get a single book
curl http://localhost:8080/books/1

# Update it
curl -X PUT http://localhost:8080/books/1 \
  -H "Content-Type: application/json" \
  -d '{"year":2016}'

# Delete it
curl -X DELETE http://localhost:8080/books/1
Enter fullscreen mode Exit fullscreen mode

That's a working CRUD API. No dependencies. No framework. Every response is properly typed JSON, every error is structured, and the whole thing compiles in under a second.

Testing

One of the best things about building on net/http is the httptest package. It lets you create mock requests and capture responses without starting a real server. No ports to manage, no cleanup to worry about, no race conditions with other tests fighting over the same address.

This is where the stdlib approach really pays off. Because your handlers are plain functions with the standard (http.ResponseWriter, *http.Request) signature, you can test them in complete isolation. There's no framework runtime to boot, no dependency injection container to configure, no test helpers from a third-party package that might break on the next major version.

Let's test the create and get flow. Put this in main_test.go:

package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

func setupRouter() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /books", handleListBooks)
    mux.HandleFunc("GET /books/{id}", handleGetBook)
    mux.HandleFunc("POST /books", handleCreateBook)
    mux.HandleFunc("PUT /books/{id}", handleUpdateBook)
    mux.HandleFunc("DELETE /books/{id}", handleDeleteBook)
    return mux
}

func resetStore() {
    booksMu.Lock()
    books = make(map[string]Book)
    nextID = 0
    booksMu.Unlock()
}

func TestCreateBook(t *testing.T) {
    resetStore()
    router := setupRouter()

    body := `{"title":"Effective Go","author":"Go Team","year":2023}`
    req := httptest.NewRequest(http.MethodPost, "/books", bytes.NewBufferString(body))
    req.Header.Set("Content-Type", "application/json")
    w := httptest.NewRecorder()

    router.ServeHTTP(w, req)

    if w.Code != http.StatusCreated {
        t.Fatalf("expected status 201, got %d", w.Code)
    }

    var book Book
    if err := json.NewDecoder(w.Body).Decode(&book); err != nil {
        t.Fatalf("failed to decode response: %v", err)
    }

    if book.Title != "Effective Go" {
        t.Errorf("expected title 'Effective Go', got '%s'", book.Title)
    }
    if book.ID == "" {
        t.Error("expected non-empty ID")
    }
}

func TestGetBook(t *testing.T) {
    resetStore()
    router := setupRouter()

    // First, create a book
    body := `{"title":"Learning Go","author":"Jon Bodner","year":2021}`
    createReq := httptest.NewRequest(http.MethodPost, "/books", bytes.NewBufferString(body))
    createReq.Header.Set("Content-Type", "application/json")
    createW := httptest.NewRecorder()
    router.ServeHTTP(createW, createReq)

    var created Book
    json.NewDecoder(createW.Body).Decode(&created)

    // Now, fetch it
    getReq := httptest.NewRequest(http.MethodGet, "/books/"+created.ID, nil)
    getW := httptest.NewRecorder()
    router.ServeHTTP(getW, getReq)

    if getW.Code != http.StatusOK {
        t.Fatalf("expected status 200, got %d", getW.Code)
    }

    var fetched Book
    json.NewDecoder(getW.Body).Decode(&fetched)

    if fetched.Title != "Learning Go" {
        t.Errorf("expected title 'Learning Go', got '%s'", fetched.Title)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the tests:

go test -v
Enter fullscreen mode Exit fullscreen mode

No test framework. No mocking library. httptest.NewRequest creates a fake request, httptest.NewRecorder captures the response, and you call ServeHTTP directly on the router. You're testing the full handler chain -- routing, serialization, status codes, everything -- without ever opening a socket.

Notice the resetStore() function. Since we're using package-level state, each test resets the store to avoid interference. In a real application, you'd inject the store as a dependency, but for this example the reset function keeps things honest.

You can also use httptest.NewServer if you need to test actual HTTP connections -- it spins up a real server on a random port and gives you its URL. Useful for testing middleware that inspects headers, or for integration tests that go through the full TCP stack. But for most handler tests, NewRecorder is faster and simpler.

The Complete File

For reference, here are the imports you need at the top of main.go:

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "sync"
    "time"
)
Enter fullscreen mode Exit fullscreen mode

Every type and function used in this post comes from these six packages. All part of the Go standard library. No go get, no vendor/ directory, no dependency graph to audit.

When You Actually Need a Framework

I'm not saying frameworks are useless. If you need WebSocket support, built-in request binding with struct tags, or a large ecosystem of maintained middleware, Gin or Echo might save you time. If you're building a high-throughput proxy, Fiber's fasthttp backend has real performance benefits.

But for most REST APIs? The stdlib is enough. It's fast, it's stable, it's well-documented, and it has zero supply chain risk. You don't need to track breaking changes in someone else's router. You don't need to worry about a maintainer abandoning the project. Your code compiles against the language itself.

Start with the standard library. Reach for a framework when you hit a real limitation, not a hypothetical one. And when that day comes, you'll understand exactly what the framework is doing for you -- because you'll have built it yourself first.


Want to go deeper?

I wrote a book that covers everything in this series -- and a lot more: error handling patterns, testing strategies, production deployment, and the stuff you only learn after shipping Go to production.

Available in:

Top comments (0)