Back to Notes

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:

RangeMeaningCommon codes
2xxSuccess200 OK, 201 Created, 204 No Content
3xxRedirect301 Moved Permanently, 304 Not Modified
4xxClient error400 Bad Request, 401 Unauthorized, 404 Not Found
5xxServer error500 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.Values to 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

HeaderDirectionPurpose
Content-TypeRequest / ResponseFormat of the body (application/json)
AcceptRequestFormats the client can handle
AuthorizationRequestCredentials (Bearer <token>, Basic <base64>)
User-AgentRequestIdentifies the client
X-API-KeyRequestAPI key authentication
Cache-ControlBothCaching directives
LocationResponseRedirect 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:

MethodSemanticBody?Idempotent?
GETRead a resourceNoYes
POSTCreate a resourceYesNo
PUTReplace a resourceYesYes
PATCHPartially updateYesNo
DELETERemove a resourceNoYes

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

FlagPurpose
-X METHODSet HTTP method (-X POST)
-H "Key: Value"Add a header
-d '{"key":"val"}'Request body
-iInclude response headers in output
-sSilent (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

GotchaDetail
Not closing resp.BodyLeaks the underlying TCP connection — always defer resp.Body.Close()
Trusting err == nil for successhttp.Get returns err=nil even for 404/500 — always check resp.StatusCode
No timeout on http.DefaultClientHangs forever on slow servers — always use a custom &http.Client{Timeout: ...}
Building query strings manuallySpecial characters break the URL — use url.Values.Encode()
Reading body twiceresp.Body is a one-shot stream — read it once; use io.TeeReader if you need it twice
Ignoring json.Decode errorsSilent bad data — always check the decode error
http.Post shorthandCan't set custom headers — use http.NewRequest + client.Do for anything beyond a basic POST