HTTP Clients (net/http)
Reviewed & published by Brayan K
Go's net/http package lets you call web APIs from your program: send GET and POST requests, set headers and timeouts, read the response body, and decode JSON straight into your structs — all from 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
- Make a simple request with http.Get
- Read and always close the response body
- Build a request with http.NewRequest and set headers
- Use an http.Client with a Timeout
- Check the status code and content type
- Decode a JSON response into a struct
1️⃣ The simplest request: http.Get
http.Get(url) performs a GET and returns a *http.Response and an error. Check the error, defer closing the body, then read it. We target an httptest.NewServer so the whole thing runs offline with a real, predictable response.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
// httptest.NewServer spins up a REAL HTTP server on a random local port
// so this example runs anywhere with no network and no fixed port.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello from the test server!")
}))
defer srv.Close()
// http.Get is the simplest client call: one GET, returns a *Response.
resp, err := http.Get(srv.URL)
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close() // ALWAYS close the body to free the connection
fmt.Println("status:", resp.Status)
body, _ := io.ReadAll(resp.Body) // read the whole body into bytes
fmt.Println("body:", string(body))
}2️⃣ A real client: request, headers, timeout
For anything beyond a toy call, build the request with http.NewRequest so you can choose the method and set headers, and send it through your own &http.Client{Timeout: ...}. A timeout is non-negotiable in production — the default client has none and will happily wait forever on a dead server.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Echo a request header back so we can see it was sent.
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "method=%s token=%s", r.Method, r.Header.Get("Authorization"))
}))
defer srv.Close()
// Build the request yourself when you need to set method, headers, etc.
req, err := http.NewRequest(http.MethodGet, srv.URL, nil)
if err != nil {
fmt.Println("build failed:", err)
return
}
req.Header.Set("Authorization", "Bearer secret123")
// A custom client lets you set a Timeout — ALWAYS do this in real code;
// the default http.Get client has no timeout and can hang forever.
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
fmt.Println("status code:", resp.StatusCode)
fmt.Println("content-type:", resp.Header.Get("Content-Type"))
body, _ := io.ReadAll(resp.Body)
fmt.Println("body:", string(body))
}3️⃣ Decoding a JSON response
Most APIs speak JSON. Define a struct with json:"..." tags that match the keys, then decode straight from the response stream with json.NewDecoder(resp.Body).Decode(&v) — no need to read the whole body into memory first.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
// User matches the JSON the server returns. Field tags map Go names to
// the lowercase JSON keys.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Admin bool `json:"admin"`
}
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":7,"name":"Ada","admin":true}`)
}))
defer srv.Close()
resp, err := http.Get(srv.URL)
if err != nil {
fmt.Println("request failed:", err)
return
}
defer resp.Body.Close()
// Decode straight from the response body stream into our struct —
// no need to read it all into memory first.
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
fmt.Println("decode failed:", err)
return
}
fmt.Printf("user #%d is %s (admin=%v)\n", u.ID, u.Name, u.Admin)
}🎯 Your Turn
Make a GET request and tidy up after it. Fill in the two blanks marked ___, then run it.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "pong")
}))
defer srv.Close()
// 🎯 YOUR TURN — fill in the blanks marked ___
// 1) Send a GET to the test server's URL
resp, err := ___ // 👉 http.Get(srv.URL)
if err != nil {
fmt.Println(err)
return
}
// 2) Make sure the body is closed when main returns
defer ___ // 👉 resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.StatusCode, string(body))
// ✅ Expected output:
// 200 pong
}Common Errors (and the fix)
❌ Forgetting resp.Body.Close() — leaks connections under load.
✅ defer resp.Body.Close() immediately after the error check.
❌ Using http.Get with no timeout in production — a hung server blocks forever.
✅ Build a &http.Client{Timeout: ...} or pass a context with a deadline.
❌ Assuming err == nil means success — a 404/500 still returns err == nil.
✅ Also check resp.StatusCode (e.g. reject anything outside 200-299).
❌ Touching resp before the error check — on error resp is nil and you panic.
✅ if err != nil { return } first, always.
🧠 Quick Recall
server returns 500; is err != nil?
No. The round trip succeeded, so err is nil; the failure shows up only in resp.StatusCode == 500. Always check both.
Zero — meaning no timeout. That's why http.Get can hang forever. Set your own Timeout or use a context deadline.
🎯 Mini-Challenge: Fetch & Decode Weather
GET the JSON from the test server, decode it into the Weather struct, and print Oslo is 14.5C.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
type Weather struct {
City string `json:"city"`
TempC float64 `json:"temp_c"`
}
func main() {
// 🎯 MINI-CHALLENGE: fetch and decode JSON
// The test server returns weather JSON. GET it, decode into Weather,
// then print: <city> is <temp>C
//
// ✅ Expected output:
// Oslo is 14.5C
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"city":"Oslo","temp_c":14.5}`)
}))
defer srv.Close()
// your code here: http.Get, defer Close, json.NewDecoder(...).Decode(&w)
_ = json.NewDecoder
}🎉 Lesson Complete!
- ✅ http.Get is the quick path; always defer resp.Body.Close()
- ✅ Build a request with http.NewRequest to set the method and headers
- ✅ Use your own &http.Client{Timeout: ...} — the default has none
- ✅ A 404/500 is not a transport error: check resp.StatusCode too
- ✅ Decode JSON straight from the stream with json.NewDecoder(resp.Body).Decode
Practice quiz
What does http.Get(url) return?
- a string body
- just an error
- a *http.Response and an error
- a *http.Request
Answer: a *http.Response and an error. http.Get performs a GET and returns a *http.Response plus an error to check first.
Why must you call resp.Body.Close()?
- the body is an open network stream that leaks connections if not closed
- to send the request
- to parse JSON
- it is optional and pointless
Answer: the body is an open network stream that leaks connections if not closed. Not closing the body leaks the underlying connection and file descriptors under load.
When should you check err before touching resp?
- never
- only for POST
- only in tests
- always — on error resp is nil and resp.Body would panic
Answer: always — on error resp is nil and resp.Body would panic. On an error resp is nil, so accessing resp.Body.Close() would panic; check err first.
What is the timeout on http.DefaultClient (used by http.Get)?
- 30 seconds
- zero — no timeout
- 5 seconds
- 1 minute
Answer: zero — no timeout. DefaultClient has no timeout, so http.Get can hang forever on a dead server.
How do you set a timeout for real-world requests?
- create &http.Client{Timeout: ...} and call client.Do(req)
- pass it to http.Get
- it is automatic
- use resp.Timeout
Answer: create &http.Client{Timeout: ...} and call client.Do(req). Build your own http.Client with a Timeout (or use a context deadline) and call Do.
Which function builds a request so you can set the method and headers?
- http.Get
- http.Handle
- http.NewRequest
- http.Client
Answer: http.NewRequest. http.NewRequest (or NewRequestWithContext) lets you choose method and set req.Header.
How do you add an Authorization header to a request?
- resp.Header.Set(...)
- req.Header.Set("Authorization", "Bearer ...")
- client.Auth(...)
- http.SetHeader(...)
Answer: req.Header.Set("Authorization", "Bearer ..."). Set fields on req.Header before sending; Set replaces any existing value.
Does an HTTP 404 or 500 status make client.Do return a non-nil error?
- yes, always
- only 500 does
- only 404 does
- no — err is nil; check resp.StatusCode yourself
Answer: no — err is nil; check resp.StatusCode yourself. err is non-nil only for transport problems; a 404/500 is a successful round trip.
What is the memory-efficient way to read a JSON response?
- io.ReadAll then json.Unmarshal
- json.NewDecoder(resp.Body).Decode(&v)
- fmt.Sscanf
- resp.JSON()
Answer: json.NewDecoder(resp.Body).Decode(&v). Decoding straight from the stream avoids buffering the whole payload in memory.
What is httptest.NewServer used for in these examples?
- mocking the client
- compiling the code
- a real local server on a random port so requests run offline
- logging requests
Answer: a real local server on a random port so requests run offline. It starts a real HTTP server on a random localhost port with a deterministic response.
Continue this course
- Previous: Worker Pools & Pipelines
- Next: Building a REST API — Method-based routing, JSON encoding/decoding, status codes, and path values
Frequently asked questions
Why must I always close resp.Body?
The response body is an open network stream. If you don't call resp.Body.Close(), the underlying TCP connection can't be returned to the pool or freed, and under load you'll leak connections and file descriptors. The idiom is to check the error first, then immediately write defer resp.Body.Close() before reading.
Why not just use http.Get for everything?
http.Get is convenient but it uses http.DefaultClient, which has NO timeout — a slow or hung server can block your goroutine forever. For anything real, create your own &http.Client{Timeout: ...} (or pass a context with a deadline) and call client.Do(req). You also need a custom request when you must set the method, headers, or a request body.
How do I send headers like Authorization or Content-Type?
Build the request with http.NewRequest (or http.NewRequestWithContext), then set fields on req.Header: req.Header.Set("Authorization", "Bearer ..."). Set is the usual choice because it replaces any existing value; use Add when a header legitimately appears multiple times. Then send it with client.Do(req).
Should I read the whole body or decode it as a stream?
For JSON, decode straight from the stream with json.NewDecoder(resp.Body).Decode(&v) — it avoids buffering the entire payload in memory. Use io.ReadAll when you genuinely need the raw bytes (logging, a checksum, or a non-JSON format). Either way, close the body afterwards.
Does a 404 or 500 status return an error from client.Do?
No. err is non-nil only for transport-level problems — DNS failure, refused connection, timeout. An HTTP 404 or 500 is a perfectly successful round trip as far as the client is concerned, so you must check resp.StatusCode yourself (for example, treat anything outside 200-299 as a failure) in addition to checking err.
What is httptest.NewServer and why use it here?
httptest.NewServer starts a real HTTP server on a random localhost port backed by a handler you provide, and gives you its URL. It lets these examples — and your own tests — make genuine HTTP requests with no external network, no fixed port to clash on, and a deterministic response. Call srv.Close() (usually via defer) to shut it down.