In the previous tutorial, you learned advanced error handling patterns. Now it is time to build something real — an HTTP server.

Go has a powerful HTTP server in the standard library. No framework needed. The net/http package gives you everything for building web servers and REST APIs. Many production Go services use only the standard library.

Your First HTTP Server

package main

import (
    "fmt"
    "net/http"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, World!")
}

func main() {
    http.HandleFunc("/hello", helloHandler)

    fmt.Println("Server starting on :8080")
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        fmt.Println("Server error:", err)
    }
}

Run it:

go run main.go

Test it:

curl http://localhost:8080/hello
# Hello, World!

That is it. Three lines of setup. The handler function takes two parameters:

  • http.ResponseWriter — write the response
  • *http.Request — read the request (method, headers, body, URL)

HTTP Methods and Status Codes

Check the HTTP method to handle different request types:

package main

import (
    "fmt"
    "net/http"
)

func userHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        fmt.Fprintf(w, "Getting users")
    case http.MethodPost:
        fmt.Fprintf(w, "Creating a user")
    case http.MethodPut:
        fmt.Fprintf(w, "Updating a user")
    case http.MethodDelete:
        fmt.Fprintf(w, "Deleting a user")
    default:
        w.WriteHeader(http.StatusMethodNotAllowed)
        fmt.Fprintf(w, "Method not allowed")
    }
}

func main() {
    http.HandleFunc("/users", userHandler)
    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", nil)
}

Use constants for HTTP methods and status codes:

http.MethodGet    // "GET"
http.MethodPost   // "POST"
http.MethodPut    // "PUT"
http.MethodDelete // "DELETE"

http.StatusOK                  // 200
http.StatusCreated             // 201
http.StatusBadRequest          // 400
http.StatusNotFound            // 404
http.StatusInternalServerError // 500

Go 1.22+ Routing Patterns

Go 1.22 added built-in routing patterns with HTTP method matching and path parameters. No external router needed:

package main

import (
    "fmt"
    "net/http"
)

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

    // Method + path pattern (Go 1.22+)
    mux.HandleFunc("GET /users", listUsers)
    mux.HandleFunc("POST /users", createUser)
    mux.HandleFunc("GET /users/{id}", getUser)
    mux.HandleFunc("PUT /users/{id}", updateUser)
    mux.HandleFunc("DELETE /users/{id}", deleteUser)

    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", mux)
}

func listUsers(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "List all users")
}

func createUser(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Create a user")
}

func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // Get path parameter (Go 1.22+)
    fmt.Fprintf(w, "Get user: %s", id)
}

func updateUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "Update user: %s", id)
}

func deleteUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "Delete user: %s", id)
}

Test it:

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

curl http://localhost:8080/users/42
# Get user: 42

curl -X DELETE http://localhost:8080/users/42
# Delete user: 42

The {id} syntax creates a path parameter. Read it with r.PathValue("id"). The method prefix (GET, POST, etc.) restricts the route to that method.

Query Parameters

Read query parameters from the URL:

package main

import (
    "fmt"
    "net/http"
    "strconv"
)

func searchHandler(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query()

    keyword := query.Get("q")
    page := query.Get("page")
    limit := query.Get("limit")

    // Set defaults
    if page == "" {
        page = "1"
    }
    if limit == "" {
        limit = "10"
    }

    pageNum, _ := strconv.Atoi(page)
    limitNum, _ := strconv.Atoi(limit)

    fmt.Fprintf(w, "Search: q=%s, page=%d, limit=%d", keyword, pageNum, limitNum)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /search", searchHandler)

    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", mux)
}

Test it:

curl "http://localhost:8080/search?q=golang&page=2&limit=20"
# Search: q=golang, page=2, limit=20

JSON Responses

Most APIs return JSON. Use encoding/json to convert Go structs to JSON:

package main

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

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type ErrorResponse struct {
    Error string `json:"error"`
}

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func listUsersHandler(w http.ResponseWriter, r *http.Request) {
    users := []User{
        {ID: 1, Name: "Alex", Email: "alex@example.com"},
        {ID: 2, Name: "Sam", Email: "sam@example.com"},
        {ID: 3, Name: "Jordan", Email: "jordan@example.com"},
    }

    writeJSON(w, http.StatusOK, users)
}

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

    // Simulated lookup
    if id == "1" {
        user := User{ID: 1, Name: "Alex", Email: "alex@example.com"}
        writeJSON(w, http.StatusOK, user)
        return
    }

    writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "user not found"})
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /users", listUsersHandler)
    mux.HandleFunc("GET /users/{id}", getUserHandler)

    http.ListenAndServe(":8080", mux)
}

Test it:

curl http://localhost:8080/users
# [{"id":1,"name":"Alex","email":"alex@example.com"},{"id":2,"name":"Sam","email":"sam@example.com"},{"id":3,"name":"Jordan","email":"jordan@example.com"}]

curl http://localhost:8080/users/99
# {"error":"user not found"}

Struct tags control JSON field names:

  • `json:"name"` — field appears as “name” in JSON
  • `json:"name,omitempty"` — field is omitted if empty
  • `json:"-"` — field is never included in JSON

Reading JSON Request Body

Use json.NewDecoder to parse JSON from the request body:

package main

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

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest

    // Decode JSON body
    err := json.NewDecoder(r.Body).Decode(&req)
    if err != nil {
        writeJSON(w, http.StatusBadRequest, map[string]string{
            "error": "invalid JSON: " + err.Error(),
        })
        return
    }

    // Validate
    if req.Name == "" || req.Email == "" {
        writeJSON(w, http.StatusBadRequest, map[string]string{
            "error": "name and email are required",
        })
        return
    }

    // Create user (simulated)
    user := User{
        ID:    1,
        Name:  req.Name,
        Email: req.Email,
    }

    fmt.Printf("Created user: %+v\n", user)
    writeJSON(w, http.StatusCreated, user)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("POST /users", createUserHandler)

    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", mux)
}

Test it:

curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Alex","email":"alex@example.com"}'
# {"id":1,"name":"Alex","email":"alex@example.com"}

curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":""}'
# {"error":"name and email are required"}

Static File Serving

Serve static files (HTML, CSS, images) from a directory:

package main

import (
    "fmt"
    "net/http"
)

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

    // Serve files from the ./static directory
    fs := http.FileServer(http.Dir("./static"))
    mux.Handle("/static/", http.StripPrefix("/static/", fs))

    // API routes
    mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, `{"status":"ok"}`)
    })

    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", mux)
}

Files in ./static/ are served at /static/. For example, ./static/style.css is available at http://localhost:8080/static/style.css.

A Complete REST API Example

Here is a complete in-memory CRUD API for notes:

package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "strconv"
    "sync"
    "time"
)

type Note struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    CreatedAt time.Time `json:"created_at"`
}

type CreateNoteRequest struct {
    Title   string `json:"title"`
    Content string `json:"content"`
}

type NoteStore struct {
    mu     sync.RWMutex
    notes  map[int]Note
    nextID int
}

func NewNoteStore() *NoteStore {
    return &NoteStore{
        notes:  make(map[int]Note),
        nextID: 1,
    }
}

func (s *NoteStore) Create(title, content string) Note {
    s.mu.Lock()
    defer s.mu.Unlock()

    note := Note{
        ID:        s.nextID,
        Title:     title,
        Content:   content,
        CreatedAt: time.Now(),
    }
    s.notes[s.nextID] = note
    s.nextID++
    return note
}

func (s *NoteStore) GetAll() []Note {
    s.mu.RLock()
    defer s.mu.RUnlock()

    notes := make([]Note, 0, len(s.notes))
    for _, note := range s.notes {
        notes = append(notes, note)
    }
    return notes
}

func (s *NoteStore) GetByID(id int) (Note, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    note, ok := s.notes[id]
    return note, ok
}

func (s *NoteStore) Delete(id int) bool {
    s.mu.Lock()
    defer s.mu.Unlock()

    _, ok := s.notes[id]
    if ok {
        delete(s.notes, id)
    }
    return ok
}

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func main() {
    fmt.Println("=== GO-15: net/http REST API ===")
    fmt.Println()

    store := NewNoteStore()

    // Add some sample data
    store.Create("Learn Go", "Start with the basics and build up to concurrency.")
    store.Create("Build an API", "Use net/http to create REST endpoints.")

    mux := http.NewServeMux()

    // List all notes
    mux.HandleFunc("GET /api/notes", func(w http.ResponseWriter, r *http.Request) {
        notes := store.GetAll()
        writeJSON(w, http.StatusOK, notes)
    })

    // Get a note by ID
    mux.HandleFunc("GET /api/notes/{id}", func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(r.PathValue("id"))
        if err != nil {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
            return
        }

        note, ok := store.GetByID(id)
        if !ok {
            writeJSON(w, http.StatusNotFound, map[string]string{"error": "note not found"})
            return
        }

        writeJSON(w, http.StatusOK, note)
    })

    // Create a note
    mux.HandleFunc("POST /api/notes", func(w http.ResponseWriter, r *http.Request) {
        var req CreateNoteRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
            return
        }

        if req.Title == "" {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
            return
        }

        note := store.Create(req.Title, req.Content)
        writeJSON(w, http.StatusCreated, note)
    })

    // Delete a note
    mux.HandleFunc("DELETE /api/notes/{id}", func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(r.PathValue("id"))
        if err != nil {
            writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
            return
        }

        if !store.Delete(id) {
            writeJSON(w, http.StatusNotFound, map[string]string{"error": "note not found"})
            return
        }

        writeJSON(w, http.StatusOK, map[string]string{"message": "note deleted"})
    })

    // Health check
    mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
        writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
    })

    fmt.Println("Server starting on :8080")
    fmt.Println("Try: curl http://localhost:8080/api/notes")
    err := http.ListenAndServe(":8080", mux)
    if err != nil {
        fmt.Println("Server error:", err)
    }
}

Test it:

# List notes
curl http://localhost:8080/api/notes

# Get note by ID
curl http://localhost:8080/api/notes/1

# Create a note
curl -X POST http://localhost:8080/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"New Note","content":"Some content"}'

# Delete a note
curl -X DELETE http://localhost:8080/api/notes/1

# Health check
curl http://localhost:8080/api/health

This API is thread-safe thanks to sync.RWMutex in the store. Multiple goroutines (HTTP requests) can read notes at the same time, but writes are serialized.

Common Mistakes

1. Forgetting to set Content-Type for JSON responses.

// Bad — browser may not parse as JSON
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)

// Good — always set Content-Type
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)

2. Writing headers after WriteHeader or Write.

// Bad — header is already sent
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json") // Too late!

// Good — set headers BEFORE WriteHeader
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)

3. Not closing the request body.

// Bad — body is not closed
json.NewDecoder(r.Body).Decode(&req)

// Good — close the body when done
defer r.Body.Close()
json.NewDecoder(r.Body).Decode(&req)

In HTTP handlers, the server closes the body automatically, so this is less critical. But it is good practice, especially in HTTP clients.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-15 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #16: Building REST APIs with Gin, you will learn:

  • What Gin is and why it is the most popular Go web framework
  • Gin vs Echo vs Fiber vs Chi
  • Route groups, request binding, and middleware
  • Building a complete REST API with Gin

This is part 15 of the Go Tutorial series. Follow along to learn Go from scratch.