Go HTTP Clients
Overview
Go's standard library ships a production-ready HTTP client in net/http — no third-party dependency needed. This note covers the full lifecycle: making requests, working with JSON, understanding URLs and headers, all HTTP methods, and error handling.
1 — Why HTTP?
HTTP (HyperText Transfer Protocol) is the request/response protocol that powers the web. A client sends a request; a server sends a response.
Client Server
| |
| GET /users HTTP/1.1 |
| Host: api.example.com |
|------------------------------>|
| |
| HTTP/1.1 200 OK |
| Content-Type: application/json
| [{"id":1,"name":"Alice"}] |
|<------------------------------|
Status code families:
| Range | Meaning | Common codes |
|---|---|---|
| 2xx | Success | 200 OK, 201 Created, 204 No Content |
| 3xx | Redirect | 301 Moved Permanently, 304 Not Modified |
| 4xx | Client error | 400 Bad Request, 401 Unauthorized, 404 Not Found |
| 5xx | Server error | 500 Internal Server Error, 503 Service Unavailable |
Minimal GET request in Go:
import "net/http"
resp, err := http.Get("https://api.example.com/users")
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
fmt.Println(resp.StatusCode) // 200
Always
defer resp.Body.Close()— the response body is a network stream; not closing it leaks the connection.
2 — JSON
JSON (JavaScript Object Notation) is the standard data format for REST APIs. Go's encoding/json package handles encoding (Go → JSON) and decoding (JSON → Go).
Decoding — json.Unmarshal / json.NewDecoder
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// From []byte
data := []byte(`{"id":1,"name":"Alice","email":"alice@example.com"}`)
var u User
if err := json.Unmarshal(data, &u); err != nil {
log.Fatal(err)
}
fmt.Println(u.Name) // Alice
// From a stream (response body) — preferred for HTTP
resp, _ := http.Get("https://api.example.com/users/1")
defer resp.Body.Close()
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
log.Fatal(err)
}
Encoding — json.Marshal / json.NewEncoder
u := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
// To []byte
data, err := json.Marshal(u)
fmt.Println(string(data))
// {"id":1,"name":"Alice","email":"alice@example.com"}
// Pretty-print
pretty, _ := json.MarshalIndent(u, "", " ")
fmt.Println(string(pretty))
Struct Tags
Struct tags control how fields map to JSON keys:
type Article struct {
Title string `json:"title"` // rename
Body string `json:"body,omitempty"` // omit if zero value
Internal string `json:"-"` // always omit
CreatedAt string `json:"created_at"` // snake_case key
}
Unknown structure — map[string]any
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
fmt.Println(result["name"])
3 — DNS
DNS (Domain Name System) maps human-readable hostnames (api.example.com) to IP addresses (93.184.216.34). The OS handles DNS resolution automatically when you make HTTP requests — you rarely need to call it directly.
Browser/Go client
|
| "api.example.com"?
↓
DNS Resolver
|
| → Root nameserver → TLD nameserver → Authoritative nameserver
|
↓ 93.184.216.34
|
→ TCP connection to 93.184.216.34:443
Looking up DNS in Go:
import "net"
addrs, err := net.LookupHost("api.example.com")
if err != nil {
log.Fatal(err)
}
fmt.Println(addrs) // [93.184.216.34]
// Reverse lookup
names, _ := net.LookupAddr("93.184.216.34")
fmt.Println(names)
4 — URIs
A URI (Uniform Resource Identifier) identifies a resource. A URL is a URI that also specifies how to reach it (scheme + location).
https://api.example.com:8080/v1/users?page=2&limit=10#results
│ │ │ │ │ │
scheme host port path query fragment
Parsing URLs with net/url:
import "net/url"
u, err := url.Parse("https://api.example.com/v1/users?page=2&limit=10")
if err != nil {
log.Fatal(err)
}
fmt.Println(u.Scheme) // https
fmt.Println(u.Host) // api.example.com
fmt.Println(u.Path) // /v1/users
fmt.Println(u.RawQuery) // page=2&limit=10
// Access individual query params
params := u.Query()
fmt.Println(params.Get("page")) // 2
Building URLs programmatically:
base, _ := url.Parse("https://api.example.com/v1/users")
params := url.Values{}
params.Set("page", "2")
params.Set("limit", "10")
base.RawQuery = params.Encode()
fmt.Println(base.String())
// https://api.example.com/v1/users?limit=10&page=2
Use
url.Valuesto build query strings — it handles URL encoding (spaces →%20, etc.) correctly.
5 — Headers
Headers are key-value metadata attached to every HTTP request and response. Common uses: authentication, content negotiation, caching.
Reading Response Headers
resp, _ := http.Get("https://api.example.com/users")
defer resp.Body.Close()
fmt.Println(resp.Header.Get("Content-Type")) // application/json
fmt.Println(resp.Header.Get("X-Rate-Limit"))
Setting Request Headers
Use http.NewRequest to build a request with custom headers:
req, err := http.NewRequest("GET", "https://api.example.com/users", nil)
if err != nil {
log.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer eyJhbGci...")
client := &http.Client{}
resp, err := client.Do(req)
defer resp.Body.Close()
Common Headers
| Header | Direction | Purpose |
|---|---|---|
Content-Type | Request / Response | Format of the body (application/json) |
Accept | Request | Formats the client can handle |
Authorization | Request | Credentials (Bearer <token>, Basic <base64>) |
User-Agent | Request | Identifies the client |
X-API-Key | Request | API key authentication |
Cache-Control | Both | Caching directives |
Location | Response | Redirect target (3xx responses) |
Authorization Schemes
// Bearer token (OAuth / JWT)
req.Header.Set("Authorization", "Bearer "+token)
// API key (custom header)
req.Header.Set("X-API-Key", apiKey)
// Basic auth (username:password base64-encoded)
req.SetBasicAuth("username", "password")
6 — HTTP Methods
HTTP methods define the intent of the request. REST APIs use them semantically:
| Method | Semantic | Body? | Idempotent? |
|---|---|---|---|
GET | Read a resource | No | Yes |
POST | Create a resource | Yes | No |
PUT | Replace a resource | Yes | Yes |
PATCH | Partially update | Yes | No |
DELETE | Remove a resource | No | Yes |
GET
resp, err := http.Get("https://api.example.com/users/1")
defer resp.Body.Close()
var u User
json.NewDecoder(resp.Body).Decode(&u)
POST
newUser := User{Name: "Alice", Email: "alice@example.com"}
body, _ := json.Marshal(newUser)
resp, err := http.Post(
"https://api.example.com/users",
"application/json",
bytes.NewBuffer(body),
)
defer resp.Body.Close()
fmt.Println(resp.StatusCode) // 201
PUT
updated := User{Name: "Alice Smith", Email: "alice@example.com"}
body, _ := json.Marshal(updated)
req, _ := http.NewRequest("PUT", "https://api.example.com/users/1", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
DELETE
req, _ := http.NewRequest("DELETE", "https://api.example.com/users/1", nil)
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
fmt.Println(resp.StatusCode) // 204
7 — Paths & Query Parameters
Path parameters identify a specific resource. Query parameters filter, sort, or paginate results.
/users/42 ← path param: user with ID 42
/users?role=admin ← query param: filter users by role
/posts?page=2&limit=20&sort=desc
Building paths safely
import "fmt"
userID := 42
url := fmt.Sprintf("https://api.example.com/users/%d/posts", userID)
Building query params safely
import "net/url"
base := "https://api.example.com/posts"
params := url.Values{}
params.Set("page", "2")
params.Set("limit", "20")
params.Set("sort", "desc")
params.Set("tag", "go & generics") // special chars handled automatically
fullURL := base + "?" + params.Encode()
// https://api.example.com/posts?limit=20&page=2&sort=desc&tag=go+%26+generics
Never build query strings by hand with string concatenation — use
url.Values.Encode()to handle special characters correctly.
8 — HTTPS & TLS
HTTPS = HTTP + TLS (Transport Layer Security). TLS encrypts the connection so data can't be read or tampered with in transit.
Client Server
| |
| ClientHello (TLS handshake) |
|---------------------------------->|
| ServerHello + Certificate |
|<----------------------------------|
| Verify cert (CA signature) |
| Key exchange |
|---------------------------------->|
| Encrypted HTTP traffic |
|<=================================>|
Go's http.Client handles TLS automatically — connecting to an https:// URL negotiates TLS, verifies the certificate, and encrypts all traffic. Nothing extra required.
Custom TLS config (advanced — e.g. client certs, skip verify in dev):
import "crypto/tls"
// ⚠️ Skip verification — development only, never production
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
9 — Error Handling
There are two distinct levels of errors with HTTP in Go:
Level 1 — Transport error: The request never reached the server (DNS failure, connection refused, timeout). http.Get / client.Do returns a non-nil error.
Level 2 — HTTP error: The server responded, but with a 4xx or 5xx status. The error return is nil — you must check resp.StatusCode.
func getUser(id int) (*User, error) {
url := fmt.Sprintf("https://api.example.com/users/%d", id)
resp, err := http.Get(url)
if err != nil {
// Level 1 — transport error
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Level 2 — HTTP error
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var u User
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
return nil, fmt.Errorf("decoding response: %w", err)
}
return &u, nil
}
Client Timeouts
By default http.DefaultClient has no timeout — a slow server will hang forever. Always set a timeout:
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
10 — cURL
curl is a command-line tool for making HTTP requests — useful for testing APIs manually. jq formats JSON output.
Common curl flags
| Flag | Purpose |
|---|---|
-X METHOD | Set HTTP method (-X POST) |
-H "Key: Value" | Add a header |
-d '{"key":"val"}' | Request body |
-i | Include response headers in output |
-s | Silent (no progress bar) |
| jq . | Pipe to jq for pretty JSON |
curl ↔ Go equivalents
GET
curl -s https://api.example.com/users/1 | jq .
resp, _ := http.Get("https://api.example.com/users/1")
json.NewDecoder(resp.Body).Decode(&u)
GET with auth header
curl -s -H "Authorization: Bearer TOKEN" https://api.example.com/profile | jq .
req, _ := http.NewRequest("GET", "https://api.example.com/profile", nil)
req.Header.Set("Authorization", "Bearer TOKEN")
client.Do(req)
POST with JSON body
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com"}' \
https://api.example.com/users | jq .
body, _ := json.Marshal(map[string]string{"name": "Alice", "email": "alice@example.com"})
http.Post("https://api.example.com/users", "application/json", bytes.NewBuffer(body))
DELETE
curl -s -X DELETE https://api.example.com/users/1 -i
req, _ := http.NewRequest("DELETE", "https://api.example.com/users/1", nil)
client.Do(req)
Putting It Together — Reusable HTTP Client
A common pattern: wrap http.Client in a struct with the base URL and auth token pre-configured.
type Client struct {
httpClient *http.Client
baseURL string
apiKey string
}
func NewClient(baseURL, apiKey string) *Client {
return &Client{
httpClient: &http.Client{Timeout: 10 * time.Second},
baseURL: baseURL,
apiKey: apiKey,
}
}
func (c *Client) do(method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequest(method, c.baseURL+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "ApiKey "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
return c.httpClient.Do(req)
}
func (c *Client) GetUser(id int) (*User, error) {
resp, err := c.do("GET", fmt.Sprintf("/users/%d", id), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var u User
return &u, json.NewDecoder(resp.Body).Decode(&u)
}
Gotchas
| Gotcha | Detail |
|---|---|
Not closing resp.Body | Leaks the underlying TCP connection — always defer resp.Body.Close() |
Trusting err == nil for success | http.Get returns err=nil even for 404/500 — always check resp.StatusCode |
No timeout on http.DefaultClient | Hangs forever on slow servers — always use a custom &http.Client{Timeout: ...} |
| Building query strings manually | Special characters break the URL — use url.Values.Encode() |
| Reading body twice | resp.Body is a one-shot stream — read it once; use io.TeeReader if you need it twice |
Ignoring json.Decode errors | Silent bad data — always check the decode error |
http.Post shorthand | Can't set custom headers — use http.NewRequest + client.Do for anything beyond a basic POST |