Skip to main content
    Courses/Go/Building Web Services

    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.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.

    Worked example: the smallest Go server
    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)
    }
    Run 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:

    The request → and the response it 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/hello
    Output
    Hello from Go!
    Run this in a second terminal while the server from above is running. The Output panel is the server's reply.

    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").

    Worked example: query and path params
    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)
    }
    Run go run main.go, then send the two requests below.
    Two requests → two responses
    # 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/42
    Output
    Hello, Sam!
    You asked for user #42
    Wrap the query URL in quotes so your shell doesn't treat ? 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.

    🎯 YOUR TURN: read a query parameter
    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
    Output
    You sent n=5
    Fill in the two ___ 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.

    Worked example: a JSON endpoint
    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)
    }
    Run go run main.go, then request it below. Use -i to see the status line and headers.
    Request with -i → status, headers, and JSON body
    # -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
    Output
    HTTP/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}
    The 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:

    🎯 YOUR TURN: complete the JSON handler
    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"}
    Output
    {"title":"The Go Programming Language","author":"Donovan & Kernighan"}
    Fill in the three ___ 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.

    Worked example: a ServeMux with method routes
    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)
    }
    Run go run main.go, then send the GET and POST requests below.
    GET and POST → JSON with explicit status codes
    # 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
    Output
    {"text":"pong"}
    
    HTTP/1.1 201 Created
    Content-Type: application/json
    
    {"text":"saved"}
    The POST returns 201 Created because the handler called w.WriteHeader(http.StatusCreated) before writing.

    Common 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: `json:"created_at"` gives you snake_case keys without renaming Go fields.

    📋 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()

    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.

    🎯 MINI-CHALLENGE: build a /status JSON endpoint
    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
    }
    Output
    {"service":"go-api","ok":true}
    Write the struct, handler, and server yourself, run it, then 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 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.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service