ZetCode

Go HTTP server

last modified October 30, 2026

In this article we show how to create HTTP servers in Go. We start with a minimal handler and move on to handler types, status codes, path and query parameters, JSON, forms, headers, templates, middleware, and a server that shuts down gracefully.

The net/http package provides the server implementation. A handler implements ServeHTTP(http.ResponseWriter, *http.Request), and http.HandlerFunc adapts an ordinary function to that interface. Handlers registered on http.DefaultServeMux are used by http.ListenAndServe when its second argument is nil.

All examples were run with Go 1.27.0.

A minimal server

http.HandleFunc registers a function for a pattern on the default mux, and http.ListenAndServe accepts connections on the given address. The address :8080 listens on all interfaces.

main.go
package main

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

func main() {

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, there")
    })

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The handler writes one line to the response. fmt.Fprintln sets the content type to text/plain; charset=utf-8 the first time it writes, and ListenAndServe blocks until the server stops.

$ curl -s localhost:8080/
Hello, there

A handler type with state

A struct with a ServeHTTP method is a handler. The counter is shared by all requests, so it is protected by a mutex; without it, concurrent requests would race on the field.

main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "sync"
)

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) ServeHTTP(w http.ResponseWriter, r *http.Request) {

    c.mu.Lock()
    c.n++
    n := c.n
    c.mu.Unlock()

    fmt.Fprintf(w, "Counter: %d\n", n)
}

func main() {

    c := &Counter{}
    http.Handle("/count", c)

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The increment and the read of the new value happen inside the lock, so two requests cannot see the same number. The state lives in memory and is lost when the process exits.

$ curl -s localhost:8080/count
Counter: 1
$ curl -s localhost:8080/count
Counter: 2
$ curl -s localhost:8080/count
Counter: 3

Methods and status codes

A handler can branch on r.Method. WriteHeader sends a status other than 200 and must be called before the body is written; http.Error writes a status, a plain text message and a newline.

main.go
package main

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

func main() {

    http.HandleFunc("/items", func(w http.ResponseWriter, r *http.Request) {

        switch r.Method {
        case http.MethodGet:
            fmt.Fprintln(w, "list of items")
        case http.MethodPost:
            w.WriteHeader(http.StatusCreated)
            fmt.Fprintln(w, "item created")
        default:
            w.Header().Set("Allow", "GET, POST")
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        }
    })

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The Allow header lists the methods the resource supports, as the HTTP specification requires for a 405 response. A POST receives 201 Created and a PUT receives 405 Method Not Allowed.

$ curl -s localhost:8080/items
list of items
$ curl -s -X POST -w ' [%{http_code}]\n' localhost:8080/items
item created
 [201]
$ curl -s -X PUT -w ' [%{http_code}]\n' localhost:8080/items
method not allowed
 [405]
$ curl -sI -X PUT localhost:8080/items | grep -iE 'HTTP/|allow'
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST

Path values

Since Go 1.22 a pattern may contain a method and wildcards. A segment wildcard such as {name} matches one segment and is read with r.PathValue; a trailing {path...} matches the rest of the path, including slashes.

main.go
package main

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

func main() {

    mux := http.NewServeMux()

    mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello %s!\n", r.PathValue("name"))
    })

    mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "file: %s\n", r.PathValue("path"))
    })

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", mux))
}

The method in the pattern is part of the match, so a POST to /hello/John is answered with 405. A request without the name segment, such as /hello, does not match and receives 404.

$ curl -s localhost:8080/hello/John
Hello John!
$ curl -s localhost:8080/files/css/style.css
file: css/style.css
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/hello
404

Query parameters

Query parameters are available through r.URL.Query(), which returns a url.Values. Get returns the first value or an empty string, and the map can be indexed to read repeated keys.

main.go
package main

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

func main() {

    http.HandleFunc("/greet", func(w http.ResponseWriter, r *http.Request) {

        q := r.URL.Query()

        name := q.Get("name")
        if name == "" {
            name = "guest"
        }

        fmt.Fprintf(w, "Hello %s!\n", name)

        for _, tag := range q["tag"] {
            fmt.Fprintf(w, "tag: %s\n", tag)
        }
    })

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The values are URL-decoded, so John%20Doe becomes John Doe. A key that appears twice is kept as a slice, and the loop prints every tag.

$ curl -s 'localhost:8080/greet?name=John%20Doe'
Hello John Doe!
$ curl -s localhost:8080/greet
Hello guest!
$ curl -s 'localhost:8080/greet?tag=go&tag=web'
Hello guest!
tag: go
tag: web

JSON requests and responses

encoding/json decodes the request body and encodes the response. The handler checks the input, sets the status and the content type, and returns a struct with JSON tags.

main.go
package main

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

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
    Age  int    `json:"age"`
}

func main() {

    http.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {

        var u User

        if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
            http.Error(w, "invalid JSON", http.StatusBadRequest)
            return
        }

        if u.Name == "" {
            http.Error(w, "name is required", http.StatusUnprocessableEntity)
            return
        }

        u.ID = 1

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(u)
    })

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

A malformed body produces 400, and a missing name produces 422. The ID is assigned by the server; a real application would take it from a database. The response is 201 Created with the stored object.

$ curl -s -X POST -H 'Content-Type: application/json' -d '{"name":"John","age":30}' localhost:8080/users
{"id":1,"name":"John","age":30}
$ curl -s -X POST -H 'Content-Type: application/json' -w ' [%{http_code}]\n' -d 'oops' localhost:8080/users
invalid JSON
 [400]
$ curl -s -X POST -w ' [%{http_code}]\n' -d '{"age":30}' localhost:8080/users
name is required
 [422]

HTML forms

A GET request returns the form, and a POST request parses the submitted body with ParseForm. FormValue reads a field from the form or from the query string.

main.go
package main

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

const form = `<!DOCTYPE html>
<html lang="en">
<head><title>Form</title></head>
<body>
<form method="POST" action="/form">
    <input name="name" type="text">
    <input name="occupation" type="text">
    <button type="submit">Submit</button>
</form>
</body>
</html>`

func main() {

    http.HandleFunc("/form", func(w http.ResponseWriter, r *http.Request) {

        if r.Method == http.MethodGet {
            w.Header().Set("Content-Type", "text/html; charset=utf-8")
            fmt.Fprint(w, form)
            return
        }

        if err := r.ParseForm(); err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }

        name := r.FormValue("name")
        occupation := r.FormValue("occupation")

        fmt.Fprintf(w, "%s is a %s\n", name, occupation)
    })

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The form is a raw string constant, so the HTML is easy to edit. For a POST request the handler parses the URL-encoded body and echoes two fields. A production form needs CSRF protection, which this example leaves out.

$ curl -s localhost:8080/form | grep -E '<form|<input'
<form method="POST" action="/form">
    <input name="name" type="text">
    <input name="occupation" type="text">
$ curl -s -X POST -d 'name=John Doe&occupation=gardener' localhost:8080/form
John Doe is a gardener

Request and response headers

r.Header holds the request headers and w.Header() the response headers. r.UserAgent is a shortcut for the User-Agent header, and headers must be set before the status is written.

main.go
package main

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

func main() {

    http.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) {

        w.Header().Set("Content-Type", "text/plain; charset=utf-8")
        w.Header().Set("X-Server", "demo")

        fmt.Fprintln(w, r.UserAgent())
    })

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

The handler returns the user agent it received and adds a custom response header. Setting a header after the first write has no effect, because the headers are already on the wire.

$ curl -s -H 'User-Agent: my-client/1.0' localhost:8080/ua
my-client/1.0
$ curl -s -D - -o /dev/null -H 'User-Agent: my-client/1.0' localhost:8080/ua | grep -iE 'content-type|x-server'
Content-Type: text/plain; charset=utf-8
X-Server: demo

Rendering templates

The html/template package renders dynamic HTML and escapes values for the page context. The template is parsed once at startup and executed for every request with the current data.

layout.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Users</title>
</head>
<body>
    <table>
        <thead>
            <tr><th>Name</th><th>Occupation</th></tr>
        </thead>
        <tbody>
            {{- range . }}
            <tr><td>{{ .Name }}</td><td>{{ .Occupation }}</td></tr>
            {{- end }}
        </tbody>
    </table>
</body>
</html>

The range action walks the slice and sets the dot to each element, so {{ .Name }} reads a field of the current user. The minus sign in {{- range . }} trims the whitespace before the action.

main.go
package main

import (
    "fmt"
    "html/template"
    "log"
    "net/http"
)

type User struct {
    Name       string
    Occupation string
}

func main() {

    tmpl := template.Must(template.ParseFiles("layout.html"))

    http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {

        users := []User{
            {Name: "John Doe", Occupation: "gardener"},
            {Name: "Roger Roe", Occupation: "driver"},
            {Name: "Thomas Green", Occupation: "teacher"},
        }

        if err := tmpl.Execute(w, users); err != nil {
            log.Println(err)
        }
    })

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}

template.Must panics when the file is missing or invalid, which stops a broken deployment immediately. The handler writes the table for /users.

$ curl -s localhost:8080/users
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Users</title>
</head>
<body>
    <table>
        <thead>
            <tr><th>Name</th><th>Occupation</th></tr>
        </thead>
        <tbody>
            <tr><td>John Doe</td><td>gardener</td></tr>
            <tr><td>Roger Roe</td><td>driver</td></tr>
            <tr><td>Thomas Green</td><td>teacher</td></tr>
        </tbody>
    </table>
</body>
</html>

Middleware

A middleware wraps a handler and returns a new one. The logging middleware prints the method and path, and the authentication middleware rejects requests without a header. They are composed around the mux.

main.go
package main

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

func logging(next http.Handler) http.Handler {

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Printf("%s %s\n", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

func auth(next http.Handler) http.Handler {

    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

        if r.Header.Get("X-Auth") != "secret" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }

        next.ServeHTTP(w, r)
    })
}

func main() {

    mux := http.NewServeMux()

    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "home page")
    })

    mux.Handle("/admin", auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "admin area")
    })))

    handler := logging(mux)

    fmt.Println("Listening on 127.0.0.1:8080")
    log.Fatal(http.ListenAndServe("127.0.0.1:8080", handler))
}

The order matters: logging sees every request, while auth guards only /admin. A middleware that needs to buffer the response implements http.ResponseWriter itself, which is outside this example.

$ curl -s -w ' [%{http_code}]\n' localhost:8080/admin
unauthorized
 [401]
$ curl -s -H 'X-Auth: secret' localhost:8080/admin
admin area

Graceful shutdown and timeouts

http.Server exposes the timeouts that ListenAndServe leaves at zero. Shutdown stops accepting connections and waits for active requests, which is triggered here by an interrupt signal.

main.go
package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {

    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "home page")
    })

    srv := &http.Server{
        Addr:              "127.0.0.1:8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    go func() {
        fmt.Println("Listening on 127.0.0.1:8080")
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    <-ctx.Done()
    fmt.Println("shutting down")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatal(err)
    }

    fmt.Println("server stopped")
}

ReadHeaderTimeout limits the time spent reading request headers and protects against slow clients; the write timeout limits the response time. After an interrupt, connections have five seconds to finish before they are closed.

$ go run main.go
Listening on 127.0.0.1:8080
^C
shutting down
server stopped

Source

Go net/http package - reference
Go html/template package - reference

In this article we have created HTTP servers in Go: handlers with and without state, methods and status codes, path and query parameters, JSON, forms, headers, templates, middleware, timeouts and graceful shutdown.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.