Right, let’s talk about building requests properly. You might have seen the http.Get and http.Post functions. They’re fine for a five-line script you’ll run once and forget. But for anything that lives in the real world—where you need deadlines, custom headers, or to not look like a complete script kiddie—you need the grown-up tool: http.NewRequestWithContext.

This function is your precision instrument. It gives you complete control over the HTTP request before you fire it off into the ether. The WithContext part is non-negotiable; it’s how you add timeouts and cancellation, which are the difference between a robust application and a flaky mess that grinds to a halt waiting for a server that went on a permanent vacation.

The Basic Anatomy of a Custom Request

Here’s the skeleton. You’ll see it everywhere, so get used to it.

ctx := context.Background()
req, err := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/json", nil)
if err != nil {
    // Don't just log this and continue; if building the request failed,
    // you have a fundamental problem (like a invalid URL) and cannot proceed.
    log.Fatalf("Failed to create request: %v", err)
}

// You now have an *http.Request but it hasn't been sent yet.
// This is your chance to modify it.
req.Header.Add("Accept", "application/json")

// Now, and only now, do you use the client to actually DO the thing.
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
    log.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close() // This is not a suggestion. Defer it immediately.

// ... process the response body later

The key players:

  1. context.Context: Your control knob for cancellation and timeouts.
  2. method: A string like "GET", "POST", "PUT". It’s case-sensitive, but the HTTP spec says it should be uppercase. Some servers might be pedantic, so just use uppercase.
  3. url: A string. It must be absolute. If you try to pass a relative path like /api/v1/users, it will fail spectacularly. This is a common “I’m new to this” mistake.
  4. body: An io.Reader for the request body. For GET and HEAD requests, this is nil. For others, you’ll often use a strings.Reader, bytes.Reader, or a bytes.Buffer.

Why Context is Your Best Friend

I said it was non-negotiable. Here’s why. Using the vanilla http.NewRequest (without context) is like building a missile with no off-switch. You can launch it, but you can’t call it back.

// This is how you build a responsible application.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // Always cancel the context to free resources, even if you timeout.

req, err := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/delay/10", nil)
if err != nil {
    log.Fatal(err)
}

// This request will try to hit an endpoint that waits 10 seconds.
// But our context will cancel it after 5.
resp, err := http.DefaultClient.Do(req)
if err != nil {
    // This error will be a context.DeadlineExceeded error.
    log.Printf("Request failed (probably timed out): %v", err)
    return
}
defer resp.Body.Close()

Without the context, that same request would stupidly wait for a full 10 seconds, clogging up your goroutines and bringing your service to its knees. The context allows the underlying http.Client to break the TCP connection and abandon the request the moment the timeout is hit. It’s your single most important tool for reliability.

Adding a Request Body (The Right Way)

For POST, PUT, and PATCH, you need a body. The crucial part is setting the Content-Type header yourself. The http package will not do it for you, which is a surprisingly common pitfall. You’ll send application/x-www-form-urlencoded data with a text/plain header and wonder why the server is ignoring you.

// Example 1: JSON Body
jsonData, _ := json.Marshal(map[string]string{"name": "Gopher"})
ctx := context.Background()

req, err := http.NewRequestWithContext(ctx, "POST", "https://httpbin.org/post", bytes.NewReader(jsonData))
if err != nil {
    log.Fatal(err)
}
// YOU must set the Content-Type header. This is not optional.
req.Header.Set("Content-Type", "application/json")

// Example 2: Form Data
formData := url.Values{}
formData.Set("username", "gopher")
formData.Add("interests", "coding")
formData.Add("interests", "cheese")

req2, err := http.NewRequestWithContext(ctx, "POST", "https://httpbin.org/post", strings.NewReader(formData.Encode()))
if err != nil {
    log.Fatal(err)
}
// And again, you MUST tell the server what you're sending.
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")

Setting Headers and Dealing with Quirks

Use the req.Header.Set() method for most headers. It sets a header, replacing any existing values. Use req.Header.Add() if you need to send multiple values for a single header key (like Accept-Encoding).

Now, for the weird bit. The Go authors decided that the Host header is special. Because of the HTTP spec’s weird relationship with the Host header, you must set it via req.Host and not req.Header.Set("Host", "..."). If you set it via the header map, it will be silently ignored. Yes, it’s a bizarre and inconsistent choice. I don’t make the rules, I just complain about them.

req.Header.Set("Authorization", "Bearer my-secret-token") // Good.
req.Header.Set("Accept", "application/json")              // Good.
req.Header.Add("Accept-Encoding", "gzip")                 // Also good.
req.Header.Add("Accept-Encoding", "deflate")              // Now the header is "gzip, deflate"

// BAD: Will not work as expected.
req.Header.Set("Host", "my-custom-domain.example.com")
// GOOD: The correct, if odd, way to do it.
req.Host = "my-custom-domain.example.com"

The final piece of advice: always, always check the error from NewRequestWithContext. The only thing that typically causes it to fail is a malformed URL. If you’re building URLs dynamically with fmt.Sprintf or string concatenation (which you are), you’re one stray % or unescaped space away from a runtime panic. Check the error. Your future self will thank you when debugging at 2 AM.