Building Web Services

Reviewed & published by Brayan K

By the end of this lesson you'll be able to write a real HTTP server in Go with net/http — handle routes, read query and path parameters, return JSON, and set status codes and headers — using nothing but the standard library.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ Your First Server: Handlers & ListenAndServe

A Go web server is built from two pieces. A handler is a function that takes a http.ResponseWriter (you write the reply into it) and a *http.Request (everything about the incoming call). You map a URL path to a handler with http.HandleFunc, then call http.ListenAndServe(":8080", nil) to start listening. Read this worked example first, then run it.

package main

// net/http is the standard library's web server — no framework needed.
import (
    "fmt"
    "net/http"
)

// A handler is just a function with THIS exact signature.
//   w  -> you WRITE the response into this
//   r  -> the incoming request (URL, headers, body, method)
func helloHandler(w http.ResponseWriter, r *http.Request) {
    // Whatever you write to w becomes the response body.
    fmt.Fprintln(w, "Hello from Go!")   // Fprintln writes text to w
}

func main() {
    // Map the URL path "/hello" to your handler function.
    http.HandleFunc("/hello", helloHandler)

    fmt.Println("Listening on http://localhost:8080")
    // ListenAndServe starts the server and BLOCKS forever, handling
    // each request as it arrives. nil = use the default router.
    http.ListenAndServe(":8080", nil)
}

With the server running, open a second terminal and send it a request. curl is a command-line HTTP client — think of it as a browser you can script. Here's the request and the exact response your server returns:

2️⃣ Reading Query & Path Parameters

Most routes need input from the caller. A query parameter comes after the ? in the URL — read it with r.URL.Query().Get("name"), which returns "" if it's missing. A path parameter is part of the path itself; since Go 1.22 you capture it with a {placeholder} in the pattern and read it with r.PathValue("id").

package main

import (
    "fmt"
    "net/http"
)

// Read a value from the query string: /greet?name=Sam
func greetHandler(w http.ResponseWriter, r *http.Request) {
    // r.URL.Query().Get returns "" if the key is missing.
    name := r.URL.Query().Get("name")
    if name == "" {
        name = "stranger"   // a sensible default
    }
    fmt.Fprintf(w, "Hello, %s!\n", name)   // Fprintf = formatted write
}

// Go 1.22+ lets the router capture {placeholders} from the path.
func userHandler(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")   // pulls "42" out of /users/42
    fmt.Fprintf(w, "You asked for user #%s\n", id)
}

func main() {
    http.HandleFunc("/greet", greetHandler)
    // The "{id}" in the pattern becomes r.PathValue("id").
    http.HandleFunc("/users/{id}", userHandler)

    fmt.Println("Listening on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}

Your turn. The program below answers /square?n=5, but two pieces are missing. Fill in each ___ using the // 👉 hints, then run it and send the request in the comment.

package main

import (
    "fmt"
    "net/http"
)

// 🎯 YOUR TURN — fill in the two ___ blanks so /square?n=5 works.
func squareHandler(w http.ResponseWriter, r *http.Request) {
    // 1) Read the "n" query parameter as text
    nText := r.URL.Query().___("n")   // 👉 the method that reads one key

    fmt.Fprintf(w, "You sent n=%s\n", nText)

    // 2) Set the response content type to plain text
    w.Header().Set("Content-Type", ___)   // 👉 "text/plain"
}

func main() {
    http.HandleFunc("/square", squareHandler)
    fmt.Println("Listening on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}

// ✅ Expected response to:  curl "http://localhost:8080/square?n=5"
//    You sent n=5

3️⃣ Returning JSON, Status Codes & Headers

Real APIs speak JSON. Define a struct with struct tags like to control the JSON keys, then write it with json.NewEncoder(w).Encode(value). Two rules matter: set Content-Type to application/json before writing the body, and always check the error that Encode returns so a failure becomes a clean 500 instead of a half-written reply.

package main

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

// Struct tags (the `json:"..."` part) set the JSON key names.
// Without them, Go would use the Go field names (Name, Price).
type Product struct {
    Name  string  `json:"name"`
    Price float64 `json:"price"`
}

func productHandler(w http.ResponseWriter, r *http.Request) {
    p := Product{Name: "Go Mug", Price: 12.50}

    // 1) Tell the client the body is JSON. Do this BEFORE writing.
    w.Header().Set("Content-Type", "application/json")

    // 2) Encode the struct straight to the response and check the error.
    if err := json.NewEncoder(w).Encode(p); err != nil {
        // If encoding fails, return a 500 so the client knows.
        http.Error(w, "could not encode JSON", http.StatusInternalServerError)
        return
    }
}

func main() {
    http.HandleFunc("/product", productHandler)
    fmt.Println("Listening on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}
# -i prints the response HEADERS as well as the body,
# so you can see the status line and Content-Type.
curl -i http://localhost:8080/product

Now finish a JSON handler yourself. Three pieces are blanked out — the content type, the encoder package, and the route registration. Fill them in:

package main

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

type Book struct {
    Title  string `json:"title"`
    Author string `json:"author"`
}

// 🎯 YOUR TURN — fill in the three ___ blanks.
func bookHandler(w http.ResponseWriter, r *http.Request) {
    b := Book{Title: "The Go Programming Language", Author: "Donovan & Kernighan"}

    // 1) Tell the client the body is JSON
    w.Header().Set("Content-Type", ___)   // 👉 "application/json"

    // 2) Write b to the response as JSON
    ___.NewEncoder(w).Encode(b)           // 👉 the json package

    _ = r   // (we don't read the request here)
}

func main() {
    // 3) Map the path "/book" to bookHandler
    http.HandleFunc("/book", ___)         // 👉 the handler function's name

    fmt.Println("Listening on http://localhost:8080")
    http.ListenAndServe(":8080", nil)
}

// ✅ Expected response to:  curl http://localhost:8080/book
//    {"title":"The Go Programming Language","author":"Donovan & Kernighan"}

4️⃣ Routing with http.ServeMux

Passing nil to ListenAndServe uses a hidden global router. For anything real, create your own with http.NewServeMux() — it keeps every route in one place and is easy to test. Since Go 1.22 you can put the method in the pattern, like "GET /api/ping", so a handler only runs for the method you intend. Set explicit status codes with w.WriteHeader(http.StatusCreated) — also before the body.

package main

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

type Message struct {
    Text string `json:"text"`
}

func main() {
    // A ServeMux is a router. Building your own (instead of using the
    // nil default) keeps routes explicit and easy to test.
    mux := http.NewServeMux()

    // Method + path patterns (Go 1.22+): only GET reaches this handler.
    mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(Message{Text: "pong"})
    })

    // Anything that doesn't match returns 405 automatically for known
    // paths; here we send 201 Created to show explicit status codes.
    mux.HandleFunc("POST /api/messages", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)   // 201 — set the code BEFORE the body
        json.NewEncoder(w).Encode(Message{Text: "saved"})
    })

    fmt.Println("Listening on http://localhost:8080")
    // Pass the mux instead of nil to use YOUR router.
    http.ListenAndServe(":8080", mux)
}
# GET the ping route.
curl http://localhost:8080/api/ping

# POST a new message and show the status code with -i.
curl -i -X POST http://localhost:8080/api/messages

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — net/http

TaskGo Syntax
Register a routehttp.HandleFunc("/path", fn)
Handler signaturefunc(w http.ResponseWriter, r *http.Request)
Start the serverhttp.ListenAndServe(":8080", mux)
Write textfmt.Fprintf(w, "Hi %s", name)
Query paramr.URL.Query().Get("name")
Path paramr.PathValue("id")
Set a headerw.Header().Set("Content-Type", "application/json")
Set status codew.WriteHeader(http.StatusCreated)
Write JSONjson.NewEncoder(w).Encode(data)
New routermux := http.NewServeMux()

Mini-Challenge: A Tiny Status API

No blanks this time — just a brief and an outline. Build a one-route JSON API from scratch: define the struct, register the handler, set the header, encode (and handle the error), and start the server. Run it and check your response against the comment.

package main

import (
    "encoding/json"   // you'll need this
    "fmt"
    "net/http"
)

func main() {
    // 🎯 MINI-CHALLENGE: a tiny "status" JSON API
    //
    // 1. Define a struct "Status" with two fields:
    //      Service string  -> json key "service"
    //      OK      bool     -> json key "ok"
    // 2. Register a handler on the path "/status" that:
    //      - sets Content-Type to "application/json"
    //      - encodes Status{Service: "go-api", OK: true} to the response
    //      - checks and handles the Encode error
    // 3. Start the server on :8080 with http.ListenAndServe.
    //
    // ✅ Expected response to:  curl http://localhost:8080/status
    //    {"service":"go-api","ok":true}

    fmt.Println("Listening on http://localhost:8080")
    _ = json.Marshal   // keep the import while you scaffold; remove when done

    // your code here
}

🎉 Course Complete — You Built Web Services in Go!

Practice quiz

What is the correct Go HTTP handler signature?

  • func(r *http.Request) http.Response
  • func(req, res)
  • func(w http.ResponseWriter, r *http.Request)
  • func(ctx context.Context)

Answer: func(w http.ResponseWriter, r *http.Request). You write the reply into w; r holds the incoming request.

Which call maps a URL path to a handler function?

  • http.HandleFunc("/path", fn)
  • http.Route("/path", fn)
  • http.Map("/path", fn)
  • http.Get("/path", fn)

Answer: http.HandleFunc("/path", fn). HandleFunc registers a path-to-handler mapping.

Why is http.ListenAndServe usually the last line of main?

  • It returns immediately
  • It only sets up routes
  • It runs in a goroutine automatically
  • It blocks, running the server loop until the process stops

Answer: It blocks, running the server loop until the process stops. ListenAndServe blocks while serving requests.

How do you read a query parameter from /greet?name=Sam?

  • r.PathValue("name")
  • r.URL.Query().Get("name")
  • r.Param("name")
  • r.Query.name

Answer: r.URL.Query().Get("name"). Query().Get returns "" if the key is missing.

Since Go 1.22, how do you read a {id} path parameter?

  • r.PathValue("id")
  • r.URL.Query().Get("id")
  • r.Path("id")
  • r.Vars["id"]

Answer: r.PathValue("id"). Patterns like /users/{id} expose r.PathValue("id").

When must you set the Content-Type header?

  • After encoding the body
  • Anytime before the handler returns
  • Before the first write to the ResponseWriter
  • It is set automatically and cannot change

Answer: Before the first write to the ResponseWriter. Headers are sent on the first write; later changes are ignored.

Which writes a struct to the response as JSON?

  • w.Write(json(value))
  • json.NewEncoder(w).Encode(value)
  • fmt.Fprint(w, value)
  • json.Print(w, value)

Answer: json.NewEncoder(w).Encode(value). NewEncoder(w).Encode streams JSON straight to the response.

What do struct tags like `json:"name"` control?

  • Validation rules
  • Database columns
  • The field's Go type
  • The JSON key names for each field

Answer: The JSON key names for each field. Without tags, Go uses the exported Go field names as keys.

How do you send a 201 Created status before the body?

  • w.Status(201)
  • w.WriteHeader(http.StatusCreated)
  • return 201
  • w.Header().Set("Status", "201")

Answer: w.WriteHeader(http.StatusCreated). Call WriteHeader before writing the body; afterwards it is locked in.

Why prefer http.NewServeMux() over passing nil to ListenAndServe?

  • It is faster at runtime
  • nil is deprecated
  • It keeps routes explicit and is easy to test
  • It enables HTTPS automatically

Answer: It keeps routes explicit and is easy to test. A custom mux avoids the hidden global router and centralizes routing.

Continue this course

Frequently asked questions

Do I need a framework like Gin or Echo to build a web server in Go?

No. Go's standard library net/http is a production-grade web server on its own, and many high-traffic services run on nothing else. Frameworks add conveniences (route groups, parameter binding, middleware helpers), but learn net/http first — every framework is built on the same http.Handler interface, so the skills transfer directly.

What is the difference between a query parameter and a path parameter?

A query parameter comes after the ? in a URL, like /greet?name=Sam, and you read it with r.URL.Query().Get("name"). A path parameter is part of the path itself, like /users/42, captured by a pattern such as "/users/{id}" and read with r.PathValue("id"). Use path params to identify a resource and query params to filter, sort, or page over it.

Why must I set Content-Type before writing the response body?

Go sends the status line and headers the moment you first write to the ResponseWriter (including the first json.Encode). Any header you set after that first write is ignored because it has already been sent over the wire. So always call w.Header().Set(...) and w.WriteHeader(...) before you write any body.

What does http.ListenAndServe blocking mean?

ListenAndServe runs the server loop and does not return while the server is running — it blocks the line it is on, handling requests in the background until the process stops or an error occurs. That's why it's the last line of main(). It returns a non-nil error only if it fails to start or is shut down, which you should log.

Why does my route return 404 even though the handler exists?

The pattern you registered does not match the URL you requested. Common causes: a trailing slash mismatch ("/users" vs "/users/"), a method that doesn't match a Go 1.22 "GET /path" pattern, or passing nil to ListenAndServe when your routes are on a custom mux. Print the request path in a catch-all "/" handler to see exactly what arrived.

Related lessons