Set the Proxy field on an http.Transport to http.ProxyURL(parsed). Give that Transport to an http.Client, and every request from that client goes through the proxy. The proxy belongs to the Transport, not to a request. The smallest unit you can proxy is a client, not a call. SOCKS5 goes in the same field: current Go takes a socks5:// URL there, with no extra package.
That last sentence is the one most guides get wrong, so everything below was measured rather than repeated. The runs used Go 1.24.2 on 16 September 2026, against stub proxies on this machine, and the program is linked at the end.
One disambiguation first, because the words collide. GOPROXY and proxy.golang.org are the Go module proxy. That is where the go command downloads modules from, described in the Go Modules Reference. This page is about the other kind, routing your own program's HTTP requests through a proxy server.
Go's standard library has proxy support built in and it is genuinely clean, which is rare. The catches are elsewhere: the proxy lives on the http.Transport rather than on a request, a custom Transport silently drops the environment handling you got for free, and the environment is read once per process. This guide covers the standard-library way, SOCKS5 without an extra package, authentication, what your proxy variables really match, rotating a pool with Colly, and the verification step that proves the proxy is carrying your traffic.
We run a proxy network and write a fair amount of Go against it, so the mistakes below are ones we have made ourselves: a scraper that quietly went direct because a custom Transport forgot its Proxy field, a SOCKS setup that would not compile until the dialer was asserted to the right interface. Every example is a complete, runnable program. Where one needs a live proxy, pull a fresh one from our free proxy API, which returns real, recently checked endpoints without a key.
How do you use a proxy with Go?
Build an http.Transport with its Proxy field set to http.ProxyURL(parsed), attach that Transport to an http.Client, and every request through that client goes via the proxy. For environment-driven proxying use http.ProxyFromEnvironment. For SOCKS5, pass a socks5:// URL to the same Proxy field. The proxy is a property of the client's Transport, not of any single request.
http.Client
your requests
Transport.Proxy
ProxyURL
Proxy
adds auth, exits
Target
sees the exit IP
What Go's proxy support does not do
Four limits are worth knowing before you build on any of this.
There is no per-request proxy field. Proxy sits on the Transport and is a function called once per request, so you can branch inside it on the *http.Request. You cannot attach a proxy to a request itself, which means switching exits usually means switching clients.
SOCKS is only in the Proxy field for HTTP work. Transport.Proxy takes a socks5:// URL, which covers requests made by an http.Client. A connection that is not HTTP still needs a dialer, and that is what golang.org/x/net/proxy is for.
A Transport you build yourself ignores the environment. http.DefaultTransport sets ProxyFromEnvironment for you and a Transport you construct does not, which is the trap two sections below. We checked both: the default carries a Proxy function, and &http.Transport{} carries none.
The environment is read once per process. Setting HTTP_PROXY with os.Setenv after your program has already made a request changes nothing at all, which the measured section covers.
Nothing tests an exit before using it. Colly's round-robin switcher hands out the next entry in order rather than checking it, so a dead proxy in the list fails that request instead of being skipped. Verification is your job, and the last section shows the check.
What does the Transport.Proxy field do?
http.Transport has a Proxy field with the signature func(*http.Request) (*url.URL, error). It is called once per request to decide which proxy, if any, to use. The standard library ships two functions that fit that shape: http.ProxyURL, which always returns the same fixed proxy, and http.ProxyFromEnvironment, which reads the environment. For a single known proxy, http.ProxyURL is all you need:
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"time"
)
func main() {
proxyURL, err := url.Parse("http://user:pass@203.0.113.7:8080")
if err != nil {
panic(err)
}
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}
resp, err := client.Get("https://httpbin.org/ip")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
Two things are quietly doing the right thing here. For an https:// target, the Transport opens a CONNECT tunnel through the proxy and performs TLS end to end with the origin, so a plain HTTP proxy carries your encrypted traffic without seeing inside it. And the user:pass in the proxy URL is not decorative: net/http reads the userinfo and sets the Proxy-Authorization header for you, so authenticated HTTP proxies need no extra code.
Why does HTTP_PROXY stop working on a custom Transport?
http.DefaultTransport, the Transport a plain http.Get uses, already has Proxy: http.ProxyFromEnvironment set. That is why an ordinary Go program respects HTTP_PROXY, HTTPS_PROXY and NO_PROXY with no code at all. Per the net/http documentation, ProxyFromEnvironment matches the request scheme to the right variable, reads either a full URL or a bare host[:port] (assuming http when the scheme is missing), and returns no proxy for localhost.
The trap is what happens the moment you build your own Transport, which you eventually will, for a custom timeout or TLS config. A Transport you construct has an empty Proxy field, so it ignores the environment completely. If you want the environment respected on a custom Transport, say so explicitly:
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, // restore env handling on a custom Transport
// ... your other settings ...
}
This one line is the fix for the surprisingly common "my HTTP_PROXY stopped working after I customized the client" report.
What do my proxy variables actually match?
We asked http.ProxyFromEnvironment directly, once per environment, in a fresh process each time. No request was sent anywhere. Each column is the answer for one address:
| Environment | http://example.com | https://example.com | http://api.example.com | http://127.0.0.1:8080 |
|---|---|---|---|---|
HTTP_PROXY set | proxy | direct | proxy | direct |
http_proxy set, lower case | proxy | direct | proxy | direct |
HTTPS_PROXY set | direct | proxy | direct | direct |
| both set | proxy | proxy | proxy | direct |
HTTP_PROXY + NO_PROXY=example.com | direct | direct | direct | direct |
HTTP_PROXY + NO_PROXY=.example.com | proxy | direct | direct | direct |
HTTP_PROXY + NO_PROXY=* | direct | direct | direct | direct |
Four rules fall out of that table, and three of them surprise people.
HTTP_PROXY has nothing to do with an https:// address. The variable must match the scheme of the request. Setting one and testing the other is the most common false alarm.
A bare domain in NO_PROXY covers its subdomains, and a leading dot does not cover the domain. The documentation of the httpproxy package says it in one line: foo.com matches foo.com and bar.foo.com, while .y.com matches x.y.com but not y.com. The same page documents an IP prefix and CIDR notation as values, and a single asterisk to turn proxying off.
Loopback is never proxied. A target on localhost or 127.0.0.1 goes direct whatever you set, which is why your proxy looks broken when you test it against a local server.
Lower case wins. When both HTTP_PROXY and http_proxy are set, the documentation says the lower-case value is the one used.
And one rule about time rather than matching:
The environment is read once per process. In our run, a program that asked before the variable was set and again after setting it got the same answer both times: no proxy. A program that set the variable first got the proxy. So os.Setenv("HTTP_PROXY", ...) in the middle of a program does nothing for the requests that follow. Pass the proxy to http.ProxyURL if you need to decide at runtime.
Does Go support SOCKS5 proxies?
Yes, and the answer changed. The documentation of Transport.Proxy in Go 1.24.2 says it plainly:
The proxy type is determined by the URL scheme. "http", "https", "socks5", and "socks5h" are supported. If the scheme is empty, "http" is assumed. "socks5" is treated the same as "socks5h".
So a SOCKS5 proxy needs no extra package and no dialer. It is the same field:
proxyURL, _ := url.Parse("socks5://user:pass@203.0.113.7:1080")
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 15 * time.Second,
}
We put that through a stub SOCKS5 proxy on 16 September 2026 and watched what the client sent. Three things are worth knowing:
- With a proxy that wants no login, the client offered the no-authentication method and the request went through.
- With
user:passin the URL, it offered the user name and password method as well and completed the exchange, so credentials work here too. - Asked for a target named by host name, it sent the name itself to the proxy rather than an address. The lookup happens at the proxy, which is the behaviour curl calls
socks5h, and it is why the documentation says the two schemes are the same thing. We cover why remote lookups matter in what is a SOCKS5 proxy.
The advice to leave net/http for SOCKS was once correct: issue 18508, opened on 4 January 2017, is about proxy environment variables not understanding socks5:// at all. It carries the Go1.9Early milestone and is closed, and guides written before that are still handing out the old workaround.
When you still want x/net/proxy
For anything that is not an http.Client, the field above cannot help you, because there is no Transport. A raw TCP connection, a database driver, an SSH client: those need a dialer, and golang.org/x/net/proxy is the package for it. proxy.SOCKS5(network, address, *Auth, forward) returns a Dialer. To wire it into an http.Transport you assert that dialer to proxy.ContextDialer and hand its DialContext to the Transport:
package main
import (
"fmt"
"io"
"net/http"
"time"
"golang.org/x/net/proxy"
)
func main() {
auth := &proxy.Auth{User: "user", Password: "pass"}
dialer, err := proxy.SOCKS5("tcp", "203.0.113.7:1080", auth, proxy.Direct)
if err != nil {
panic(err)
}
// The SOCKS5 dialer implements ContextDialer; assert to use DialContext.
ctxDialer, ok := dialer.(proxy.ContextDialer)
if !ok {
panic("SOCKS5 dialer is not a ContextDialer")
}
transport := &http.Transport{DialContext: ctxDialer.DialContext}
client := &http.Client{Transport: transport, Timeout: 15 * time.Second}
resp, err := client.Get("https://httpbin.org/ip")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
If you would rather parse a URL than pass arguments, proxy.FromURL("socks5://user:pass@203.0.113.7:1080", proxy.Direct) does the same thing from a *url.URL. That package is not in the standard library, so it is a module you install, and we did not run it for this page: the code above follows its documentation, while everything we measured used the standard library alone.
How do I rotate proxies in Colly?
For scraping, most Go code uses Colly, and Colly has proxy rotation built in. proxy.RoundRobinProxySwitcher takes a list of proxy URLs and returns a function you attach with SetProxyFunc; it hands out a different exit on every request. Per its documentation, it understands http, https and socks5 schemes, and treats a missing scheme as http:
package main
import (
"fmt"
"github.com/gocolly/colly/v2"
"github.com/gocolly/colly/v2/proxy"
)
func main() {
c := colly.NewCollector()
rp, err := proxy.RoundRobinProxySwitcher(
"http://203.0.113.7:8080",
"http://203.0.113.24:3128",
"socks5://198.51.100.14:1080",
)
if err != nil {
panic(err)
}
c.SetProxyFunc(rp)
c.OnResponse(func(r *colly.Response) {
fmt.Println(string(r.Body)) // prints the exit IP that httpbin saw
})
c.Visit("https://httpbin.org/ip")
c.Visit("https://httpbin.org/ip") // a different exit than the first visit
}
For a single fixed proxy, c.SetProxy("http://203.0.113.7:8080") is the shortcut; it wraps a one-entry switcher internally. You do not have to hardcode the list either. Our free proxy API returns a fresh pool you can pull at startup and splice straight into RoundRobinProxySwitcher. For production, a rotating gateway that gives you one endpoint and a fresh residential IP per request removes list management entirely, but rotating a real list first is the best way to understand what that gateway is doing for you.
How do I check that the proxy is really being used?
Never assume the proxy is working. Confirm the target sees the proxy's address and not yours before you trust an exit with real traffic. The clean check compares a direct request against a proxied one:
func exitIP(client *http.Client) string {
resp, err := client.Get("https://httpbin.org/ip")
if err != nil {
return "error: " + err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body)
}
// direct := &http.Client{Timeout: 10 * time.Second}
// proxied := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
// fmt.Println("real: ", exitIP(direct))
// fmt.Println("proxy:", exitIP(proxied))

If the two responses show the same IP, the request never went through the proxy. It is also worth checking https://httpbin.org/headers through the proxy, because a transparent proxy can forward your real address in X-Forwarded-For even while origin looks changed. Our proxy checker runs that whole battery, exit IP, anonymity grade, real geolocation, and latency, in one paste if you would rather not script it.
The two errors a broken proxy gives you
They mean different things, and the wording tells you which:
Get "https://example.com": Forbidden
Get "http://example.com": proxyconnect tcp: dial tcp 203.0.113.7:8080: ...
The first came from a proxy that answered and then refused to open the tunnel: the address is right, the proxy is alive, and it declined this request. The second came from an address with nothing listening: Go never got a proxy at all, and everything after proxyconnect tcp is the dial failure underneath. The exact tail of that second line is the operating system speaking, so it reads differently on Windows, Linux and macOS.
A third case looks like neither. If the request succeeds and the target still sees your own address, the proxy was never used: check that the Transport has a Proxy field at all, and that the variable matches the scheme of the request.
Where to go from here
Two habits keep Go proxy code alive unattended: set http.Client.Timeout so a silent proxy cannot hang a goroutine forever, and verify every exit before you trust it. For a live pool to test against, the free proxy list re-verifies its entries every few minutes, and the free API hands them to you with no key.
From here, proxies for web scraping covers choosing the right proxy type and the request hygiene no HTTP client can add for you, the cURL guide is the fastest way to sanity-check a proxy from the shell before you wire it into code, and if you hit an auth wall, how to fix 407 Proxy Authentication Required is the focused fix. When a project graduates to production, get IPs nobody else is burning on our paid pools. For teams that would rather order and rotate plans from a build step, the REST API docs walk through authentication, quoting, ordering and delivery.
Sources
- net/http package documentation:
Transport.Proxy,http.ProxyURL, andhttp.ProxyFromEnvironment, including theHTTP_PROXY/HTTPS_PROXY/NO_PROXYand localhost rules. The schemes quoted above are fromsrc/net/http/transport.goin Go 1.24.2. - golang.org/x/net/http/httpproxy: the package behind
ProxyFromEnvironment, and the only place theNO_PROXYmatching rules are written out. - golang/go issue 18508, 4 January 2017: proxy environment variables did not understand
socks5://, which is why older guides route aroundnet/http. - Our own measurements, 16 September 2026, Go 1.24.2: nineteen cases against stub HTTP and SOCKS5 proxies on this machine, covering the request shapes, the credential headers, the environment table above and the two error texts. Every server in the run was local, so nothing was sent to anyone else.
- golang.org/x/net/proxy:
SOCKS5,FromURL, theAuthstruct, and theDialer/ContextDialerinterfaces used withTransport.DialContext. - Colly proxy package:
RoundRobinProxySwitcherand thehttp/https/socks5schemes it accepts. - Go Modules Reference: the module proxy, which is what
GOPROXYandproxy.golang.orgrefer to.


