How to Follow or Disable Redirects in Go

Web
Disable HTTP/2 redirects and other behaviors in Go by setting GODEBUG environment variables or using //go:debug directives.

The redirect trap

You send a request to example.com/login. The server replies with a 302 status and a Location header pointing to example.com/dashboard. A browser follows that link automatically. You see the dashboard. You never see the 302.

Go's net/http package does the same thing. The default client follows redirects until it hits a final 200 OK or runs out of hops. This is usually what you want. Sometimes it isn't.

Maybe you're building a crawler that needs to record every hop. Maybe you're debugging a misconfigured server that loops forever. Maybe you need to inspect the 302 response body before deciding what to do.

Go gives you a hook to intercept every redirect decision. You control the flow.

The default client follows redirects. Your custom client decides.

How redirects work under the hood

The http.Client struct holds the configuration for making requests. One field, CheckRedirect, is a function type.

CheckRedirect takes the request about to be made and the previous responses. It returns an error.

If the function returns nil, the client follows the redirect.

If the function returns an error, the client stops. The last response becomes the result.

There is a special sentinel error: http.ErrUseLastResponse. If you return this, the client stops and returns the previous response without error. If you return any other error, the client stops and returns that error.

This distinction is subtle but important. Returning http.ErrUseLastResponse means "stop redirecting, but the request succeeded with the response we have." Returning fmt.Errorf("blocked") means "stop redirecting, and the request failed."

Convention aside: http.DefaultClient is a shared singleton. Never modify http.DefaultClient.CheckRedirect in a library or a long-running server. Changes affect every goroutine using the default client. Create a new http.Client for custom behavior.

Return nil to follow. Return http.ErrUseLastResponse to stop and keep the response. Return any other error to stop and fail.

Minimal example: disable all redirects

Here's the simplest way to stop following redirects. You create a client, set CheckRedirect to always return http.ErrUseLastResponse, and make the request.

package main

import (
	"fmt"
	"net/http"
)

func main() {
	// Create a client that never follows redirects.
	// CheckRedirect is called before the redirect request is made.
	client := &http.Client{
		CheckRedirect: func(req *http.Request, via []*http.Response) error {
			// Return the sentinel error to stop redirecting.
			// The client returns the last response (the 302) as success.
			return http.ErrUseLastResponse
		},
	}

	// Make a request. If the server redirects, we get the redirect response.
	resp, err := client.Get("https://httpbin.org/redirect-to?url=https://example.com")
	if err != nil {
		fmt.Println("Request failed:", err)
		return
	}
	// Close the body to release resources.
	defer resp.Body.Close()

	// Status code will be 302, not 200.
	fmt.Println("Status:", resp.StatusCode)
	fmt.Println("Location:", resp.Header.Get("Location"))
}

The client sends the GET request. The server replies with 302. The client sees the 302 and the Location header. Before making the second request, it calls CheckRedirect. Your function returns http.ErrUseLastResponse. The client stops. It returns the 302 response. err is nil. resp.StatusCode is 302.

Convention aside: defer resp.Body.Close() is standard. Even if you don't read the body, closing it releases the underlying connection back to the pool.

http.ErrUseLastResponse is the key. Without it, returning an error fails the request.

Realistic example: smart redirect logic

Disabling all redirects is rare. Usually, you want to follow some and stop others. Or you want to limit the depth. Or you want to ensure you stay on the same domain.

Here's a client that limits redirects to three hops and refuses to leave the original domain.

package main

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

// NewSafeClient creates a client with strict redirect rules.
func NewSafeClient(baseDomain string) *http.Client {
	return &http.Client{
		CheckRedirect: func(req *http.Request, via []*http.Response) error {
			// Count the number of redirects so far.
			// len(via) is the number of responses received.
			if len(via) >= 3 {
				// Stop after 3 redirects.
				// Return a custom error to indicate the limit was hit.
				return fmt.Errorf("too many redirects: %d", len(via))
			}

			// Check if the next URL is on the same domain.
			// req.URL.Host is the target of the redirect.
			if !strings.HasSuffix(req.URL.Host, baseDomain) {
				// Stop if leaving the domain.
				// Return http.ErrUseLastResponse to keep the last response.
				return http.ErrUseLastResponse
			}

			// Return nil to allow the redirect.
			return nil
		},
	}
}

func main() {
	client := NewSafeClient("example.com")

	resp, err := client.Get("https://example.com/start")
	if err != nil {
		// Handle the error.
		// If err is http.ErrUseLastResponse, resp is valid.
		// If err is the custom error, resp might be nil or the last response.
		fmt.Println("Error:", err)
		return
	}
	defer resp.Body.Close()

	fmt.Println("Final status:", resp.StatusCode)
}

via is a slice of responses. len(via) tells you how many redirects happened. If len(via) is 0, this is the first redirect decision. If it's 3, three redirects have occurred.

The function checks len(via) >= 3. If true, it returns a custom error. The client stops and returns that error.

The function checks req.URL.Host. If it doesn't match, it returns http.ErrUseLastResponse. The client stops and returns the response that triggered the cross-domain redirect.

Convention aside: Functions that take a context should respect cancellation. If you pass a context to http.NewRequestWithContext, the client will cancel if the context expires. CheckRedirect runs synchronously. If your check is slow, it blocks the request. Keep CheckRedirect fast.

CheckRedirect is a filter. Return nil to pass, return an error to block.

Understanding the CheckRedirect signature

The type of CheckRedirect is func(req *http.Request, via []*http.Response) error.

req is the request that would be made next. It points to the URL in the Location header. The method might have changed. The headers might have been adjusted.

via is the history. It contains all responses received so far. The last element is the response that triggered this redirect.

If you are deciding on the first redirect, len(via) is 1. via[0] is the response to your original request.

This structure lets you make decisions based on the full chain. You can check if you've seen a URL before to detect loops. You can check the status codes of previous responses.

Convention aside: Receiver naming doesn't apply here, but function naming does. CheckRedirect is a field, not a method you define. The function you provide is anonymous or a helper. Name helpers clearly: checkRedirectLimit, enforceDomain.

The via slice is not just for counting. You can inspect headers of previous responses. This is an ah-ha moment for many developers: you have the full audit trail of the redirect chain available in memory.

Handling method changes and status codes

HTTP status codes dictate how redirects behave.

301 Moved Permanently and 302 Found often cause clients to change POST to GET.

303 See Other explicitly tells the client to use GET.

307 Temporary Redirect and 308 Permanent Redirect tell the client to keep the original method.

Go's client implements these rules. If you send a POST and get a 302, the next request in the chain will be a GET. You can see this change in req.Method inside CheckRedirect.

If you need to preserve a POST through a 302, you can't rely on the status code. You would need to manually follow the redirect yourself, or return http.ErrUseLastResponse and make a new request with the correct method.

Go follows the spec. 307 and 308 preserve methods. 301 and 302 usually switch to GET.

Cookies and state

The http.Client has a Jar field for cookies.

When redirects happen, cookies are sent and received automatically based on the domain.

If you stop a redirect, the cookies from the redirect response are still available in the response headers.

If you use http.ErrUseLastResponse, the client does not make the next request, so cookies for the next domain are not sent.

If you need to manually follow a redirect to control cookies, you must handle the jar yourself.

Convention aside: http.DefaultClient has a default jar. A new &http.Client{} has a nil jar, meaning cookies are not stored across requests. If you need cookies, set Jar: http.DefaultCookieJar().

Cookies follow the domain. A nil jar means no cookie persistence.

Pitfalls and compiler errors

Infinite loops are the biggest risk. If a server redirects A to B and B to A, the client loops until it hits the internal limit. Go's default limit is 10 redirects. If you hit it, the client returns http.ErrTooManyRedirects.

If you set CheckRedirect to nil, the client uses the default behavior, which includes the limit check.

If you set CheckRedirect to a function, you take over the logic. The default limit is not applied automatically. You must implement the limit yourself, or the loop continues until the context times out.

Compiler error: If you try to assign a non-function to CheckRedirect, the compiler rejects it with cannot use ... as value of type func(...) error.

Compiler error: If you forget to return a value in CheckRedirect, the compiler complains with missing return at end of function.

Runtime behavior: If you access req.URL without checking for nil, you might panic. req is never nil in CheckRedirect, but req.URL is always set. The risk is lower here, but always trust the types.

Convention aside: if err != nil { return err } is verbose by design. In CheckRedirect, you often need to distinguish between "stop and succeed" and "stop and fail". Using http.ErrUseLastResponse for success and a custom error for failure makes the intent clear.

Implement your own limit if you override CheckRedirect. The default safety net disappears.

Decision matrix

Use http.Get when you want the default behavior: follow redirects up to 10 hops, handle method changes, and return the final response.

Use http.DefaultClient when you need a quick request in a script or test and don't care about redirect customization.

Use a custom http.Client with CheckRedirect set to nil when you want default redirect logic but need to configure timeouts or transport settings.

Use a custom http.Client with a CheckRedirect function when you need to inspect redirect responses, limit hops, enforce domain boundaries, or stop on specific status codes.

Use http.ErrUseLastResponse inside CheckRedirect when you want to stop redirecting but treat the redirect response as a valid result.

Use a custom error inside CheckRedirect when you want to stop redirecting and signal a failure to the caller.

http.Get is a convenience wrapper. Custom clients give you control.

Where to go next