Right, let’s talk about making HTTP requests. This isn’t just about fetching cat pictures (though I fully support that mission); it’s about your program having a conversation with the outside world. And in Go, the net/http package gives you a few great ways to start that conversation, but you’ve got to know their quirks or you’ll be debugging at 2 AM.

The workhorses are http.Get, http.Post, and the all-powerful http.Do. They seem simple, and for quick scripts, they are. But for anything serious, you need to understand what’s happening under the hood, because “simple” in Go often means “we’ve made the 90% case easy, but hid the 100% case in plain sight.”

The Simple Get: A Trap in Disguise

Let’s start with the one-liner you’ve probably seen everywhere.

resp, err := http.Get("https://api.example.com/users")
if err != nil {
    // Handle your error. No, seriously, handle it. Don't just log and continue.
    log.Fatal(err)
}
defer resp.Body.Close() // <- This is not a suggestion.

body, err := io.ReadAll(resp.Body)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("%s", body)

Seems straightforward, right? But here’s the first “brilliant friend” insight: This code is leaking a network connection if you don’t call defer resp.Body.Close(). The response body must be closed to allow the underlying TCP connection to be reused. If you don’t, your program will slowly, or not so slowly, run out of network sockets. It’s the most common rookie mistake, and we’ve all done it.

Also, note that err coming back from http.Get is only for client-side errors making the request (like a bad URL, a DNS lookup failure, or a timeout). A 404 Not Found is not an error in this context. The request was made successfully, and the server gave a valid response. You have to check the HTTP status code yourself.

if resp.StatusCode != http.StatusOK {
    log.Printf("Request failed with status: %s", resp.Status)
    // You might still want to read the body to see the error details from the API
}

Posting Data: More Than Just Bytes

Posting is where you start sending data out into the world. http.Post is your friend for simple form data or JSON.

// Posting JSON is 90% of what you'll do.
jsonData, _ := json.Marshal(map[string]string{
    "name":  "Alice",
    "email": "alice@example.com",
})

resp, err := http.Post("https://api.example.com/users", "application/json", bytes.NewBuffer(jsonData))
if err != nil {
    log.Fatal(err)
}
defer resp.Body.Close()

// ... handle the response

The key here is the Content-Type header. If you’re sending JSON, it must be application/json. If you use text/plain or anything else, the server will likely have no idea what to do with your carefully crafted bytes and will throw a 400 error. It’s like sending a love letter written in French to someone who only speaks Mandarin; the effort is there, but the message is lost.

For form data, it’s even easier. The net/http/url package has your back.

formData := url.Values{}
formData.Add("username", "alice")
formData.Add("password", "s3cr3t") // Please, for the love of all that is holy, use HTTPS.

resp, err := http.PostForm("https://api.example.com/login", formData)
// ... you know the drill by now.

The Grand Vizier: http.Do and http.Client

Here’s the truth: http.Get and http.Post are just convenient wrappers around the real star of the show: http.Do. They use a default http.Client that is… fine. But it has no timeout set. This is, and I say this with love for the Go team, an absolutely bonkers default. A request can hang forever, freezing your goroutine until the heat death of the universe.

This is why for any production code, you should never use the default client. Create your own with sane timeouts.

// Create a client with sensible defaults. This is non-negotiable.
client := &http.Client{
    Timeout: 10 * time.Second, // This covers the entire exchange: dial, request, response body read.
}

// Now, to use it, you build a Request object.
req, err := http.NewRequest("GET", "https://api.example.com/secure", nil)
if err != nil {
    log.Fatal(err)
}

// Add headers, because the default client is a naked hermit.
req.Header.Add("Authorization", "Bearer your-token-here")
req.Header.Set("User-Agent", "MyAwesomeApp/1.0") // Always set a User-Agent. It's polite.

// Now perform the request.
resp, err := client.Do(req)
if err != nil {
    log.Fatal(err) // This will now include timeout errors!
}
defer resp.Body.Close()

// Process response...

Why go through all this trouble? Control. With your own http.Client, you control timeouts, redirect policies, cookie jars, and TLS settings. You can share a single client across your entire application to reuse connections efficiently. The default client is a shared global variable, and messing with its timeouts (which you absolutely should not do) affects every other part of your program using it.

So, to summarize: use http.Get for throwaway scripts. For everything else, create a custom http.Client and use http.NewRequest with client.Do. Your future self, who isn’t debugging a production outage caused by a missing timeout, will thank you.