Lesson 8 • Advanced • Final lesson
Building Web Services 🐹
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.
What You'll Learn in This Lesson
- • Register routes with
http.HandleFuncand write a handler with the(w http.ResponseWriter, r *http.Request)signature - • Start the server with
http.ListenAndServeand understand why it blocks - • Read query parameters with
r.URL.Query().Getand path parameters withr.PathValue - • Return JSON with
encoding/jsonandjson.NewEncoder(w).Encode(...) - • Set status codes and headers with
w.WriteHeaderandw.Header().Set - • Build an explicit router with
http.ServeMuxand method-based patterns
if err != nil pattern throughout. Every server below is real Go: run it with go run main.go after installing Go from go.dev, then send requests with the curl commands shown — the "Output" panel is exactly what comes back.http.HandleFunc is the menu that decides which kitchen station (handler) prepares which dish (path). The handler plates the food by writing to w, and the response is the meal carried back to the table. ListenAndServe is opening the doors — the restaurant stays open, taking orders, until you close up.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)
}go run main.go locally — the server starts and waits. It keeps running until you press Ctrl+C.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:
# In another terminal, send a request to your running server.
# curl is a command-line HTTP client — it acts like a browser.
curl http://localhost:8080/helloHello from Go!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)
}go run main.go, then send the two requests below.# A query parameter lives after the "?" in the URL.
curl "http://localhost:8080/greet?name=Sam"
# A path parameter is part of the path itself.
curl http://localhost:8080/users/42Hello, Sam!
You asked for user #42? and & specially.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=5You sent n=5___ blanks, run the server, then curl "http://localhost:8080/square?n=5" and check the response matches the Output panel.3️⃣ Returning JSON, Status Codes & Headers
Real APIs speak JSON. Define a struct with struct tags like `json:"name"` 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)
}go run main.go, then request it below. Use -i to see the status line and headers.# -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/productHTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 15 Jun 2026 10:00:00 GMT
Content-Length: 31
{"name":"Go Mug","price":12.5}200 OK line and Content-Type: application/json header are set by the handler; the JSON is the encoded struct.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"}{"title":"The Go Programming Language","author":"Donovan & Kernighan"}___ blanks, run the server, then curl http://localhost:8080/book and confirm the JSON matches the Output panel.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)
}go run main.go, then send the GET and POST requests below.# 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{"text":"pong"}
HTTP/1.1 201 Created
Content-Type: application/json
{"text":"saved"}201 Created because the handler called w.WriteHeader(http.StatusCreated) before writing.Common Errors (and the fix)
- Forgetting
Content-Typefor JSON: if you callEncodewithoutw.Header().Set("Content-Type", "application/json"), Go guessestext/plainand browsers won't parse it as JSON. Set the header before the first write — afterwards it's ignored because the headers have already been sent. - Not handling the
Encodeerror:json.NewEncoder(w).Encode(v)returns anerror. Ignoring it can leave a truncated, broken body with a misleading 200. Wrap it:if err := ...Encode(v); err != nil { http.Error(w, "...", 500); return }. - Route not matching (404): a trailing-slash mismatch (
"/users"vs"/users/"), a method that doesn't fit a"GET /path"pattern, or passingniltoListenAndServewhen your routes live on a custom mux. Register the exact pattern and pass your mux as the second argument.
Pro Tips
- 💡 Always check
ListenAndServe's error:log.Fatal(http.ListenAndServe(":8080", mux))surfaces "address already in use" instead of exiting silently. - 💡 Headers and status before body: set
Content-Typeand callWriteHeaderfirst; the first write towlocks them in. - 💡 Prefer an explicit
ServeMuxover thenildefault — it's testable and keeps routing in one obvious place. - 💡 Use struct tags for clean JSON:
`json:"created_at"`gives you snake_case keys without renaming Go fields.
📋 Quick Reference — net/http
| Task | Go Syntax |
|---|---|
| Register a route | http.HandleFunc("/path", fn) |
| Handler signature | func(w http.ResponseWriter, r *http.Request) |
| Start the server | http.ListenAndServe(":8080", mux) |
| Write text | fmt.Fprintf(w, "Hi %s", name) |
| Query param | r.URL.Query().Get("name") |
| Path param | r.PathValue("id") |
| Set a header | w.Header().Set("Content-Type", "application/json") |
| Set status code | w.WriteHeader(http.StatusCreated) |
| Write JSON | json.NewEncoder(w).Encode(data) |
| New router | mux := http.NewServeMux() |
Frequently Asked Questions
Q: 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.
Q: 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.
Q: 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.
Q: 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.
Q: 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.
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
}{"service":"go-api","ok":true}curl http://localhost:8080/status to check the JSON matches the Output panel.🎉 Course Complete — You Built Web Services in Go!
- ✅ A handler is
func(w http.ResponseWriter, r *http.Request)— you write the reply intow - ✅
http.HandleFuncmaps a path to a handler;http.ListenAndServestarts the (blocking) server - ✅ Read input with
r.URL.Query().Get(...)(query) andr.PathValue(...)(path) - ✅ Return JSON with
encoding/json+json.NewEncoder(w).Encode(...)— and check the error - ✅ Set headers and status before the body with
w.Header().Setandw.WriteHeader - ✅ Use
http.ServeMuxwith method patterns like"GET /api/ping"for clean routing - 🎓 That's the Go course! You've gone from
package mainto variables, functions, structs, interfaces, concurrency, errors, and now real web services. - 👉 Where next: deepen your skills with Testing in Go to write unit and table-driven tests for the very handlers you just built. Then ship something real — try a small REST API backed by a database, and explore frameworks like Chi or Gin once you want route groups and middleware helpers.
Sign up for free to track which lessons you've completed and get learning reminders.