Go http client
last modified October 30, 2026
In this article we show how to create HTTP requests with net/http
in Go. We look at the response status and headers, HEAD requests, custom
clients, form posts, redirects, cookies, timeouts and connection reuse.
An HTTP client sends a request to a server and receives a response. The
net/http package provides both, and its Client type
adds configuration that the package-level helpers do not expose.
All examples in this article were compiled and run with Go 1.27.0. The responses come from a small server that is part of the article, so they can be reproduced without network access.
The demo server
The server has an HTML page at /, echoes headers at
/headers, returns 404 at /status/404, redirects
/redirect to /, sets and reads a cookie at
/cookie, answers after two seconds at /slow, and
echoes form data at /post.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-Server", "demo")
w.Write([]byte("<!DOCTYPE html>\n<html lang=\"en\">\n<head><title>Demo page</title></head>\n<body><p>Hello from the demo server.</p></body>\n</html>\n"))
})
http.HandleFunc("/headers", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"user-agent": r.UserAgent(),
"x-token": r.Header.Get("X-Token"),
})
})
http.HandleFunc("/status/404", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
})
http.HandleFunc("/redirect", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
})
http.HandleFunc("/cookie", func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session")
if err != nil {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "abc123", Path: "/"})
fmt.Fprintln(w, "cookie set")
return
}
fmt.Fprintln(w, "cookie:", c.Value)
})
http.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
fmt.Fprintln(w, "slow response")
})
http.HandleFunc("/post", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(r.PostForm)
})
fmt.Println("Listening on 127.0.0.1:8080")
log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
Every endpoint returns a short, fixed response, which keeps the client outputs stable. The server is started before the client programs are run.
$ go run server.go Listening on 127.0.0.1:8080
Status, protocol and headers
A response carries the status line, the protocol version, the headers and the
body. resp.Status holds the text of the status,
resp.Proto the protocol, and resp.Header is a map of
header names to value slices.
package main
import (
"fmt"
"io"
"log"
"net/http"
)
func main() {
resp, err := http.Get("http://127.0.0.1:8080/")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println("status: ", resp.Status)
fmt.Println("proto: ", resp.Proto)
fmt.Println("content-type:", resp.Header.Get("Content-Type"))
fmt.Println("server: ", resp.Header.Get("X-Server"))
fmt.Println("bytes: ", len(body))
}
Header.Get returns the first value of a header and an empty string
when it is missing. The body has 127 bytes, which is the length of the HTML page
produced by the server.
$ go run main.go status: 200 OK proto: HTTP/1.1 content-type: text/html; charset=utf-8 server: demo bytes: 127
HEAD request
A HEAD request asks for the headers without the body. It is useful for checking
whether a resource exists and how large it is before downloading it.
http.Head is the shortcut for this method.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
resp, err := http.Head("http://127.0.0.1:8080/")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
fmt.Println("status: ", resp.Status)
fmt.Println("content-type: ", resp.Header.Get("Content-Type"))
fmt.Println("content-length:", resp.ContentLength)
}
resp.ContentLength is the parsed value of the
Content-Length header. A HEAD response has no body, so it does not
need to be read, but it still has to be closed.
$ go run main.go status: 200 OK content-type: text/html; charset=utf-8 content-length: 127
A configured client
The package-level http.Get uses http.DefaultClient,
which has no timeout. A dedicated http.Client sets a timeout, and
http.NewRequest allows headers to be added before the request is
sent with Do.
package main
import (
"fmt"
"io"
"log"
"net/http"
"time"
)
func main() {
client := &http.Client{Timeout: 3 * time.Second}
req, err := http.NewRequest("GET", "http://127.0.0.1:8080/headers", nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("User-Agent", "Go client example/1.0")
req.Header.Set("X-Token", "secret-token")
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Request.UserAgent())
fmt.Print(string(body))
}
The client can be reused for many requests and is safe for concurrent use.
resp.Request points to the request that produced the response, so
the user agent that was actually sent can be read back.
$ go run main.go
Go client example/1.0
{"user-agent":"Go client example/1.0","x-token":"secret-token"}
POST form data
http.PostForm sends a url.Values as an
application/x-www-form-urlencoded body. It is equivalent to a
browser form submission with the default encoding.
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/url"
)
func main() {
data := url.Values{
"name": {"John Doe"},
"occupation": {"gardener"},
}
resp, err := http.PostForm("http://127.0.0.1:8080/post", data)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Status)
fmt.Print(string(body))
}
The server parses the body with ParseForm and returns the values as
JSON. Every value is an array because a form key may appear more than once.
$ go run main.go
200 OK
{"name":["John Doe"],"occupation":["gardener"]}
Redirect policy
By default a client follows up to ten redirects. The
CheckRedirect function decides whether the next request is made;
returning http.ErrUseLastResponse stops following and returns the
redirect response itself.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
resp, err := http.Get("http://127.0.0.1:8080/redirect")
if err != nil {
log.Fatal(err)
}
resp.Body.Close()
fmt.Println("followed:", resp.Status, resp.Request.URL.Path)
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err = client.Get("http://127.0.0.1:8080/redirect")
if err != nil {
log.Fatal(err)
}
resp.Body.Close()
fmt.Println("stopped: ", resp.Status, resp.Header.Get("Location"))
}
The default client ends at / with status 200. The custom client
returns the 302 response, and Location holds the target of the
redirect as sent by the server.
$ go run main.go followed: 200 OK / stopped: 302 Found /
Cookies and a cookie jar
A client does not store cookies by itself. The cookiejar package
provides a jar that records Set-Cookie headers and sends the
matching cookies with later requests.
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
)
func main() {
jar, err := cookiejar.New(nil)
if err != nil {
log.Fatal(err)
}
client := &http.Client{Jar: jar}
for i := 1; i <= 2; i++ {
resp, err := client.Get("http://127.0.0.1:8080/cookie")
if err != nil {
log.Fatal(err)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
log.Fatal(err)
}
fmt.Printf("request %d: %s", i, body)
}
}
The first request has no cookie, so the server sets one. The jar stores it and adds it to the second request, which the server answers with the value read from the cookie.
$ go run main.go request 1: cookie set request 2: cookie: abc123
Timeouts with a context
http.NewRequestWithContext attaches a context to one request. When
the context expires, the request is cancelled and Do returns an
error that wraps the context error.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "http://127.0.0.1:8080/slow", nil)
if err != nil {
fmt.Println("request:", err)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
}
The server sleeps for two seconds, so the 300 ms deadline expires first. A
context is more flexible than Client.Timeout because it can also be
cancelled from a signal handler or a parent request.
$ go run main.go request failed: Get "http://127.0.0.1:8080/slow": context deadline exceeded
Connection errors
A transport error is returned by Get before any response exists.
The error is a *url.Error that contains the operation, the URL and
the underlying network error.
package main
import (
"fmt"
"net/http"
)
func main() {
_, err := http.Get("http://127.0.0.1:9/")
fmt.Println("error:", err)
}
Port 9 is the discard service and is normally closed, so the connection is refused. The message names the address and the reason, which is usually enough to tell a missing server from a DNS or TLS problem.
$ go run main.go error: Get "http://127.0.0.1:9/": dial tcp 127.0.0.1:9: connect: connection refused
Connection reuse
The default transport keeps idle connections and reuses them for later requests
to the same host. httptrace reports whether a request got a new
connection or a pooled one.
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/http/httptrace"
)
func main() {
client := &http.Client{}
for i := 1; i <= 2; i++ {
req, err := http.NewRequest("GET", "http://127.0.0.1:8080/", nil)
if err != nil {
log.Fatal(err)
}
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
fmt.Printf("request %d: reused=%t\n", i, info.Reused)
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
The body of the first response is read to the end and closed, which returns the connection to the pool. The second request then reuses it instead of opening a new TCP connection. A body that is not drained prevents reuse.
$ go run main.go request 1: reused=false request 2: reused=true
Source
Go net/http package - reference
Go net/http/httptrace package - reference
In this article we have used the Go HTTP client: status and headers, HEAD requests, a configured client, form posts, redirects, cookies, timeouts, connection errors and connection reuse.
Author
List all Go tutorials.