How to Make an HTTP GET Request in Go

Web
Use the standard `net/http` package's `http.Get()` function for simple requests or `http.Client` with `http.NewRequest()` when you need to customize headers, timeouts, or handle errors more granularly.

The network is not local

You are building a service that needs to pull configuration from a remote server. Or maybe you are writing a CLI tool that checks for updates. The task looks simple on paper: send a GET request to a URL and read the response. In many ecosystems, this means importing a third-party library, configuring a session object, and wrestling with async callbacks or promise chains. Go handles it differently. The standard library ships with everything you need, and it forces you to treat network connections as explicit resources rather than invisible utilities.

An HTTP GET request in Go is a structured conversation between two programs. Your program opens a socket, sends a formatted message, waits for a reply, and then closes the connection. The net/http package abstracts the socket details but keeps the lifecycle visible. You create a request, you send it through a client, you receive a response, and you explicitly clean up. There is no automatic garbage collection for open network streams. The language trusts you to manage the connection pool and the response body. This design choice eliminates silent failures and makes resource usage predictable.

The convenience function hides the setup steps. The manual approach exposes them. Both use the same underlying machinery. Pick the level of control your problem requires.

The convenience shortcut

Here is the simplest way to fetch a URL. The standard library provides a one-liner that builds the request and sends it immediately.

package main

import (
	"fmt"
	"io"
	"net/http"
)

func main() {
	// Convenience function creates the request and sends it immediately
	resp, err := http.Get("https://api.github.com")
	if err != nil {
		panic(err)
	}
	// Defer ensures the stream closes even if reading fails later
	defer resp.Body.Close()

	// HTTP 200 means success. Other codes are not panics.
	if resp.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("unexpected status: %d", resp.StatusCode))
	}

	// Read the entire stream into memory for small payloads
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		panic(err)
	}

	fmt.Println(string(body))
}

When you run this code, http.Get builds a Request struct behind the scenes. It sets the method to GET, attaches the URL, and passes it to the package-level default client. That default client manages a connection pool, reusing TCP connections to the same host to avoid the overhead of repeated handshakes. The response object you get back contains headers, a status code, and a Body field. That Body field is an io.ReadCloser. It is a stream, not a buffer. The data arrives in chunks over the network. Calling io.ReadAll pulls those chunks until the server closes the stream.

If you skip the defer resp.Body.Close() line, the connection stays open in the client's pool. After enough requests, the pool fills up, new requests block, and your program hangs. The compiler will not stop you from omitting the close call. The runtime will just quietly exhaust your resources. Always close the body. Always defer it.

Building a production request

Production code rarely uses the convenience function. You need timeouts, custom headers, and structured error handling. Here is how a robust GET request looks in a real service.

package main

import (
	"fmt"
	"io"
	"net/http"
	"time"
)

func fetchConfig() ([]byte, error) {
	// Dedicated client enforces a hard deadline on the entire round trip
	client := &http.Client{
		Timeout: 5 * time.Second,
	}

	// NewRequest builds the request without sending it yet
	req, err := http.NewRequest("GET", "https://api.example.com/config", nil)
	if err != nil {
		return nil, fmt.Errorf("building request: %w", err)
	}

	// Attach identifying headers for server logging and rate limiting
	req.Header.Set("User-Agent", "ConfigFetcher/1.0")

	// Send the request and wait for the response
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("request failed: %w", err)
	}
	// Always close the body to return the connection to the pool
	defer resp.Body.Close()

	// Treat non-200 responses as logical errors, not network errors
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("server returned status %d", resp.StatusCode)
	}

	// Stream the response body into a byte slice
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("reading body: %w", err)
	}

	return data, nil
}

The http.Client struct gives you explicit control. The Timeout field covers DNS resolution, TCP connection, TLS handshake, request transmission, and response reading. If any step exceeds five seconds, the client cancels the operation and returns an error. The http.NewRequest function separates construction from execution. This gap lets you attach headers, set query parameters, or inject a context before the network call begins.

Notice the error handling pattern. The community accepts the if err != nil { return err } boilerplate because it makes the unhappy path visible. Wrapping errors with %w preserves the original error chain, which lets callers inspect the root cause later. The compiler rejects type mismatches with messages like cannot use body (type []byte) as string value in argument, but it will never warn you about a 404 response. You must check the status code yourself.

What happens under the hood

Network I/O in Go relies on goroutines and the operating system's event loop. When client.Do runs, it spawns a goroutine that blocks until the response arrives. That goroutine sits on the client's internal queue. The http.Transport type manages the actual socket lifecycle. It keeps idle connections alive for a default of ninety seconds. It rotates them out when they sit idle too long. It handles TLS session resumption so repeated calls to the same host skip the cryptographic handshake.

The response body is a pipe. The kernel receives TCP segments, pushes them into a buffer, and your goroutine reads from that buffer. io.ReadAll allocates a byte slice, grows it as needed, and copies the data. For small payloads, this is fine. For multi-megabyte downloads, you should stream the data directly to a file or process it in chunks. Calling io.ReadAll on a large response will spike your memory usage and trigger the garbage collector. The runtime will not panic, but your latency will jump. Stream when you can. Buffer when you must.

Context propagation is the other hidden mechanism. If you use http.NewRequestWithContext, the client attaches the context to the request. The transport monitors the context for cancellation. If a parent goroutine cancels the context, the transport closes the underlying connection and returns an error to the blocked goroutine. This prevents abandoned requests from holding onto sockets. Context is plumbing. Run it through every long-lived call site.

Pitfalls and silent failures

Network code introduces failure modes that local code does not. The most common mistake is treating HTTP status codes as success indicators. A 404 or 500 response still returns a valid Response object with a nil error. The err variable only captures transport failures like DNS resolution errors or connection timeouts. If you skip the status code check, your program will happily parse an HTML error page as JSON and crash later. The compiler rejects undefined variables with undefined: pkg, but it will never warn you about a logical failure. You must validate the response yourself.

Another frequent issue is client reuse. Creating a new http.Client inside a tight loop or a hot request handler forces the program to open fresh TCP connections for every call. The operating system eventually runs out of ephemeral ports, and the kernel drops packets. The runtime panics with dial tcp: too many open files or bind: address already in use. The convention is to create one http.Client at program startup and pass it around. If you need to share it across goroutines, remember that http.Client is safe for concurrent use. Do not wrap it in a mutex. Do not create it per-request.

Timeouts are equally important. Without a Timeout field on the client, a request can hang indefinitely if the server stops responding. The goroutine making the call blocks forever. If that goroutine was spawned by a web handler, the handler never returns, and the HTTP server eventually runs out of worker threads. The worst goroutine bug is the one that never logs. Setting a timeout guarantees that the call fails fast and returns control to your error handling logic.

Discarding values requires intention. If you call a function that returns multiple values but only need the first, use the underscore to drop the rest. result, _ := someFunc() says you considered the second return value and chose to ignore it. Use it sparingly with errors. Swallowing an error with _ hides bugs that will surface in production. The compiler will not stop you, but your future self will thank you for keeping the error visible.

When to reach for what

Use http.Get when you are writing a quick script, a one-off CLI tool, or a test helper where connection pooling and timeouts do not matter. Use http.Client with http.NewRequest when you need to set custom headers, enforce a timeout, or reuse the client across multiple calls. Use http.NewRequestWithContext when you need to cancel a long-running request from another goroutine or respect a parent deadline. Use a dedicated HTTP client struct in your service layer when you want to mock the network layer for unit tests. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next