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
- • Register routes with http.HandleFunc and write a handler with the (w http.ResponseWriter, r *http.Request) signature
- • Start the server with http.ListenAndServe and understand why it blocks
- • Read query parameters with r.URL.Query().Get and path parameters with r.PathValue
- • Return JSON with encoding/json and json.NewEncoder(w).Encode(...)
- • Set status codes and headers with w.WriteHeader and w.Header().Set
- • Build an explicit router with http.ServeMux and method-based patterns
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=53️⃣ 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/productNow 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/messagesCommon Errors (and the fix)
- Forgetting Content-Type for JSON: if you call Encode without w.Header().Set("Content-Type", "application/json"), Go guesses text/plain and 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 Encode error: json.NewEncoder(w).Encode(v) returns an error. 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 passing nil to ListenAndServe when 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-Type and call WriteHeader first; the first write to w locks them in.
- 💡 Prefer an explicit ServeMux over the nil default — it's testable and keeps routing in one obvious place.
- 💡 Use struct tags for clean JSON: 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() |
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!
- ✅ A handler is func(w http.ResponseWriter, r *http.Request) — you write the reply into w
- ✅ http.HandleFunc maps a path to a handler; http.ListenAndServe starts the (blocking) server
- ✅ Read input with r.URL.Query().Get(...) (query) and r.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().Set and w.WriteHeader
- ✅ Use http.ServeMux with method patterns like "GET /api/ping" for clean routing
- 🎓 That's the Go course! You've gone from package main to 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.
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
- Previous: Checkpoint: Building Services
- Next: Testing in Go — Write unit tests, table-driven tests, and benchmarks with the testing package
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.