๐ŸŽ‰ Premium Proxies ยท 3-Day Free TrialClaim Now โ†’
Guides

Web Scraping With Go and Colly: Proxies and Rotation

Web scraping with Go and Colly, end to end: build a collector, extract with OnHTML, follow links to crawl, rotate proxies, and control concurrency safely.

S SparkProxy 2 19 min read
Share
Web Scraping With Go and Colly: Proxies and Rotation

Web scraping with Go and Colly turns a pile of net/http boilerplate into a few callbacks and a Visit call. Colly runs the crawl queue, parses HTML, follows links, and throttles requests, so you write extraction logic instead of plumbing. This guide builds a real Colly scraper from an empty collector up to a concurrent, proxy-rotating crawler, and it flags the framework quirks that cost people an afternoon: the callback that never fires, the LimitRule that silently does nothing, and the async collector that exits before a single page loads.

Why Colly for Go Web Scraping

Colly (github.com/gocolly/colly/v2) is the most widely used scraping framework in Go. You register callbacks that fire on HTML elements, and Colly manages the request queue, connection pool, cookie jar, and parsing through goquery underneath. It gives you crawling and extraction without hand-rolling a worker pool.

There is one limitation to understand before you write a line of code: Colly fetches raw HTML over HTTP and does not run JavaScript. If a page renders its content client-side, OnHTML sees the empty shell the server sent, not the DOM a browser would build. That single fact decides most of your architecture. Static and server-rendered pages are Colly's home turf; heavily client-rendered or bot-defended pages need a headless browser or a rendering API instead.

ApproachBest forTrade-off
CollyCrawling and parsing server-rendered HTML across a siteNo JavaScript execution
Raw `net/http`High-volume JSON APIs, full control over the clientYou build the crawl loop yourself
SparkProxy Scraping APIJavaScript-heavy or anti-bot targetsPer-request cost, no local pool

If you want the lower-level proxy plumbing that sits under Colly, the Go net/http proxy guide covers transports and dialers in detail. For the build-versus-buy call on the hard targets, see scraping API versus self-managed proxies. New to the concept entirely? Start with what web scraping is. This guide stays focused on Colly.


Install Colly and Build Your First Collector

Colly needs Go 1.20 or newer. Add the v2 module to your project:

go get github.com/gocolly/colly/v2

The whole framework revolves around one type, the Collector. You create one with colly.NewCollector, attach callbacks, then call Visit. Here is a complete scraper that prints every H2 on a page:

package main

import (
	"fmt"
	"log"

	"github.com/gocolly/colly/v2"
)

func main() {
	c := colly.NewCollector(
		colly.UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"),
	)

	c.OnHTML("h2", func(e *colly.HTMLElement) {
		fmt.Println("heading:", e.Text)
	})

	if err := c.Visit("https://www.sparkproxy.io/blog"); err != nil {
		log.Fatal(err)
	}
}

colly.NewCollector takes functional options. The most useful ones early on are colly.UserAgent(string) so you do not announce yourself as Go's default agent, colly.AllowedDomains(...) to keep a crawl on the sites you meant, and colly.MaxDepth(n) to cap how deep link following goes. You can also set fields directly, for example c.UserAgent = "...", but the options are cleaner.

One rule that trips up newcomers: register every callback before you call Visit. Colly runs callbacks as the response is parsed, so a callback added after the visit has already returned never fires.


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

The Colly Callback Lifecycle

Colly is event driven. Each request passes through a fixed sequence of callbacks, and knowing the order tells you where to put each piece of logic.

CallbackFires whenTypical use
`OnRequest`Just before a request is sentSet headers, log the URL, abort with `r.Abort()`
`OnError`The request failed or returned a transport errorRetry, log, count failures
`OnResponse`A response body arrivedInspect status, save raw bytes, handle non-HTML
`OnHTML`The parser matches your CSS selectorExtract data, queue links to follow
`OnXML`The parser matches an XPath (HTML or XML)Extract from sitemaps and feeds
`OnScraped`All `OnHTML` handlers for the response finishedFlush a record, tidy up

This scraper wires up the full chain so you can watch the flow in your logs:

c := colly.NewCollector()

c.OnRequest(func(r *colly.Request) {
	r.Headers.Set("Accept-Language", "en-US,en;q=0.9")
	fmt.Println("visiting", r.URL)
})

c.OnResponse(func(r *colly.Response) {
	fmt.Printf("got %d bytes, status %d\n", len(r.Body), r.StatusCode)
})

c.OnHTML("title", func(e *colly.HTMLElement) {
	fmt.Println("title:", e.Text)
})

c.OnError(func(r *colly.Response, err error) {
	fmt.Printf("request to %s failed: %v\n", r.Request.URL, err)
})

c.OnScraped(func(r *colly.Response) {
	fmt.Println("done with", r.Request.URL)
})

c.Visit("https://www.sparkproxy.io")

If your OnHTML callback never runs, work down this short list before anything else. Check that your selector actually matches the served markup. Check that the content is present in the raw HTML and not injected by JavaScript, because Colly will not run that script. Confirm the request did not fail (put a print in OnError). Those three cover almost every "my callback does nothing" report.


Extract Structured Data with OnHTML

Real scraping means pulling fields out of a repeating structure and turning them into records. Inside an OnHTML handler, the *colly.HTMLElement gives you the matched node, and helper methods read its children by selector.

type Product struct {
	Name  string
	Price string
	URL   string
}

func main() {
	c := colly.NewCollector(colly.AllowedDomains("sparkproxy.io"))

	var products []Product

	// Fire once per product card, then read fields from inside it.
	c.OnHTML("div.product-card", func(e *colly.HTMLElement) {
		p := Product{
			Name:  e.ChildText("h3.product-title"),
			Price: e.ChildText("span.price"),
			URL:   e.Request.AbsoluteURL(e.ChildAttr("a", "href")),
		}
		products = append(products, p)
	})

	c.Visit("https://www.sparkproxy.io/products")

	fmt.Printf("scraped %d products\n", len(products))
}

The methods you will reach for constantly:

  • e.Text and e.Attr("href") read the matched element itself.
  • e.ChildText(selector) and e.ChildAttr(selector, attr) read a descendant.
  • e.ForEach(selector, func(i int, el *colly.HTMLElement)) loops over repeated children when one card holds a list.
  • e.Request.AbsoluteURL(link) resolves a relative href against the current page, so you never store a broken /path with no host.
  • e.DOM drops you into the underlying *goquery.Selection when you need selector logic Colly's helpers do not cover.

Use ForEach when a single matched element contains a variable number of sub-items, such as a table of specs or a list of tags:

c.OnHTML("table.specs", func(e *colly.HTMLElement) {
	specs := map[string]string{}
	e.ForEach("tr", func(_ int, row *colly.HTMLElement) {
		key := row.ChildText("td:first-child")
		val := row.ChildText("td:last-child")
		if key != "" {
			specs[key] = val
		}
	})
	fmt.Println(specs)
})

Add Proxies to a Colly Collector

Crawl one site hard enough from one IP and it rate-limits you, serves a CAPTCHA, or bans the address. Routing through proxies spreads the traffic. For a single static proxy, SetProxy is all you need:

c := colly.NewCollector()

if err := c.SetProxy("http://user:pass@proxy-1.sparkproxy.io:10000"); err != nil {
	log.Fatal(err)
}

c.OnHTML("h1", func(e *colly.HTMLElement) {
	fmt.Println(e.Text)
})

c.Visit("https://www.sparkproxy.io/ip")

Credentials go in the URL as user:pass@host:port and Colly passes them to the proxy for you. If the password contains characters like @, :, or /, percent-encode them first, otherwise URL parsing splits in the wrong place and you get a 407 Proxy Authentication Required. Colly also honors the standard HTTP_PROXY and HTTPS_PROXY environment variables when you set no proxy in code, which is handy for a quick test but too blunt for rotation. For how the credential handshake works under the hood, see how proxy authentication works.

A single proxy still funnels everything through one IP. For anything at scale, rotate.


Rotate Proxies with RoundRobinProxySwitcher

Colly ships a proxy switcher in github.com/gocolly/colly/v2/proxy. RoundRobinProxySwitcher takes a list of proxy URLs and cycles a different one on every request. It accepts http, https, and socks5 schemes in the same call.

package main

import (
	"log"

	"github.com/gocolly/colly/v2"
	collyproxy "github.com/gocolly/colly/v2/proxy"
)

func newCollector() *colly.Collector {
	c := colly.NewCollector(colly.Async(true))

	rp, err := collyproxy.RoundRobinProxySwitcher(
		"http://user:pass@proxy-1.sparkproxy.io:10000",
		"http://user:pass@proxy-2.sparkproxy.io:10000",
		"socks5://user:pass@proxy-3.sparkproxy.io:1080",
	)
	if err != nil {
		log.Fatal(err)
	}
	c.SetProxyFunc(rp)
	return c
}

RoundRobinProxySwitcher is safe to use with colly.Async(true) because it increments its index with an atomic counter internally, so concurrent goroutines never hand out the same proxy by racing. That detail matters the moment you write your own switcher.

SetProxyFunc accepts any colly.ProxyFunc, which is just func(http.Request) (url.URL, error). Round-robin is often not what you want. If some IPs are faster or you want random selection to look less mechanical, supply your own function, and make it concurrency-safe yourself:

import (
	"math/rand"
	"net/http"
	"net/url"
	"sync"
)

// randomSwitcher picks a random proxy per request, safe across goroutines.
func randomSwitcher(raw []string) colly.ProxyFunc {
	parsed := make([]*url.URL, len(raw))
	for i, p := range raw {
		u, err := url.Parse(p)
		if err != nil {
			panic(err)
		}
		parsed[i] = u
	}
	var mu sync.Mutex
	return func(_ *http.Request) (*url.URL, error) {
		mu.Lock()
		defer mu.Unlock()
		return parsed[rand.Intn(len(parsed))], nil
	}
}

Parse the URLs once when you build the function, not on every call, and guard the shared state (math/rand's global source is not safe for concurrent use without a lock). The four rotation strategies, round-robin, random, weighted, and sticky, are covered generally in what proxy rotation is and how it works; with Colly you plug any of them in through this one function.


Control Concurrency with Async and LimitRule

By default a Colly collector is synchronous: each Visit blocks until the response is handled. To fetch pages in parallel, create the collector with colly.Async(true), then cap the parallelism with a LimitRule and wait for the queue to drain with c.Wait().

import (
	"time"

	"github.com/gocolly/colly/v2"
)

func crawl(c *colly.Collector, urls []string) {
	c.Limit(&colly.LimitRule{
		DomainGlob:  "*",             // must match the target host or the rule is ignored
		Parallelism: 4,               // at most 4 in-flight requests per matching domain
		RandomDelay: 2 * time.Second, // random 0 to 2s gap between requests
	})

	for _, u := range urls {
		c.Visit(u)
	}
	c.Wait() // async Visit returns immediately; without Wait, main exits first
}

Two mistakes account for nearly every broken Colly concurrency setup:

Forgetting c.Wait(). With Async(true), Visit queues the request and returns at once. If main reaches its end before the goroutines finish, the program exits and you scrape nothing. Always call Wait after queuing.

A DomainGlob that does not match. The limit only applies to requests whose host matches the glob. Set DomainGlob: "example.com" while scraping www.example.com and the rule is silently skipped, so your "throttled" crawl runs wide open. Use "*" unless you deliberately want per-domain rules.

`LimitRule` fieldMeaning
`DomainGlob`Glob the request host must match for the rule to apply (`*` matches all)
`DomainRegexp`Alternative to `DomainGlob` using a regular expression
`Parallelism`Maximum simultaneous requests to matching domains (needs `Async(true)`)
`Delay`Fixed wait before each request to a matching domain
`RandomDelay`Extra random wait from 0 up to this value, added on top of `Delay`

Match the parallelism to your proxy pool, not to what Go can spawn. Pushing 100 goroutines through 3 proxies just piles requests onto three IPs and gets all three flagged. A starting point of 2 to 4 requests per domain per proxy is sane, then raise it while watching your block rate. For the wider set of signals that get a request flagged, see how to avoid getting your proxy blocked.


Cache, Retry, and Handle Errors

During development, refetching the same pages on every run wastes time and burns proxy budget. Set colly.CacheDir and Colly stores each response on disk, serving repeats from cache:

c := colly.NewCollector(colly.CacheDir("./colly_cache"))

Delete the folder to force fresh fetches. Cached responses are read from disk and do not touch a proxy, which is the behavior you want while iterating on selectors.

Colly does not retry failed requests on its own. Handle failures in OnError, and bound the retries so a permanently dead target does not loop forever. Store the attempt count in the request's Context:

c.OnError(func(r *colly.Response, err error) {
	const maxRetries = 3

	attempts := 0
	if v, ok := r.Request.Ctx.GetAny("attempts").(int); ok {
		attempts = v
	}

	if attempts >= maxRetries {
		log.Printf("giving up on %s after %d attempts: %v", r.Request.URL, attempts, err)
		return
	}

	r.Request.Ctx.Put("attempts", attempts+1)
	backoff := time.Duration(1<<attempts) * time.Second // 1s, 2s, 4s
	time.Sleep(backoff)
	r.Request.Retry() // re-submits the same request; the switcher gives it a new proxy
}) 

r.Request.Retry() re-queues the identical request, and because your proxy switcher rotates per request, the retry lands on the next IP automatically. Exponential backoff (double the wait each attempt) keeps you from hammering a rate limit while it cools off.

Here are the Colly-specific failures worth memorizing:

SymptomCauseFix
`OnHTML` never firesSelector wrong, or content is JavaScript-renderedVerify the selector against raw HTML; render the page if it needs JS
Program exits, nothing scraped`Async(true)` without `c.Wait()`Call `c.Wait()` after queuing visits
Throttle ignored, requests flood out`LimitRule` `DomainGlob` does not match the hostUse `DomainGlob: "*"` or the exact host
`Forbidden domain` on `Visit`URL host is outside `AllowedDomains`Add the host, or drop the option
Same page never revisitedColly dedupes visited URLsAdd `colly.AllowURLRevisit()` if intentional
`407 Proxy Authentication Required`Bad or unencoded proxy credentialsPercent-encode special characters in the password

Hand Hard Targets to the SparkProxy Scraping API

Colly is excellent until a target renders its data with JavaScript or fights back with fingerprinting and anti-bot walls. Colly cannot run the JavaScript, and defeating a serious bot manager from scratch is a project of its own. When you hit that wall, route those specific pages through the SparkProxy Scraping API. It renders JavaScript in a real browser, rotates the exit IP server-side on every request, and returns the finished HTML, so there is no pool, no retry loop, and no headless browser for you to maintain.

The base endpoint is https://scrape.sparkproxy.io/api/v1 and authentication is the X-API-Key header. Call it from Go with a plain http.Client:

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"
)

func main() {
	endpoint, _ := url.Parse("https://scrape.sparkproxy.io/api/v1")

	q := endpoint.Query()
	q.Set("url", "https://example.com/products")
	q.Set("render_js", "true")     // run a real browser for JS-heavy pages
	q.Set("premium_proxy", "true") // route through residential IPs for tough targets
	q.Set("country_code", "us")    // geo-target the exit IP (ISO 3166-1 alpha-2)
	endpoint.RawQuery = q.Encode()

	req, _ := http.NewRequest(http.MethodGet, endpoint.String(), nil)
	req.Header.Set("X-API-Key", os.Getenv("SPARKPROXY_API_KEY"))

	client := &http.Client{Timeout: 90 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	fmt.Printf("status=%d bytes=%d\n", resp.StatusCode, len(body))
}

render_js runs the headless browser, premium_proxy upgrades to residential exit IPs, and country_code sets the exit geography. Because a fresh IP is assigned per call, the rotation and retry logic from earlier is handled for you. Use a longer client timeout, around 90 seconds, since a rendered page takes longer than a raw fetch. The API can also return parsed output directly: set format to md for clean Markdown, or pass extract_rules with your CSS selectors to get structured JSON back, which skips the goquery step entirely for the pages you send it.

A pragmatic split keeps costs down: let Colly crawl and parse the static, server-rendered pages where per-request cost matters, and forward only the JavaScript-heavy or heavily defended pages to the API. The two work well side by side.


Frequently asked questions

FAQ

Colly is an open-source web scraping and crawling framework for Go, imported as github.com/gocolly/colly/v2. You create a Collector, register callbacks like OnHTML that fire on elements matching CSS selectors, and call Visit to fetch pages. Colly manages the request queue, connection pooling, cookies, and HTML parsing, so it is used for crawling sites, extracting structured data, and building scrapers without writing the low-level HTTP loop yourself.

Use proxy.RoundRobinProxySwitcher from github.com/gocolly/colly/v2/proxy, pass it your list of http, https, or socks5 proxy URLs, and hand the result to collector.SetProxyFunc. It cycles a different proxy on every request and is safe under Async(true) because it uses an atomic counter. For random or weighted selection, write your own colly.ProxyFunc with the same signature and make it concurrency-safe with a lock.

The three usual causes are a CSS selector that does not match the served markup, content that is rendered by JavaScript (Colly only sees the raw HTML and does not run scripts), or a request that failed before parsing. Check the raw HTML for your selector, add a print inside OnError, and confirm the callback is registered before you call Visit, since callbacks added afterward never run.

Because colly.Async(true) makes Visit return immediately after queuing the request instead of blocking until it completes. If your main function ends before those goroutines finish, the program exits and nothing is scraped. Call c.Wait() after you queue all your visits so the program blocks until the crawl drains.

Yes. Create the collector with colly.Async(true) for parallel requests, then call c.Limit with a colly.LimitRule to cap Parallelism and add Delay or RandomDelay between requests. The common trap is the DomainGlob field: it must match the target host, so use "*" to apply the rule to every domain, or the limit is silently ignored and requests flood out.

Use it when the target renders its content with JavaScript, which Colly cannot execute, or when anti-bot defenses block your rotating proxies faster than you can maintain them. The SparkProxy Scraping API renders pages in a real browser and rotates exit IPs server-side, so you send a URL and get finished HTML back. A cost-effective pattern is to crawl static pages with Colly and forward only the hard pages to the API.


Limited-time ยท 50% off

Get 50% off your first purchase

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Offer ends soon โ€” claim it before it's gone

Claim Discount

About the Author

This guide was written by the SparkProxy Technical Team. SparkProxy operates datacenter and residential proxy networks and a managed Scraping API used by engineering teams for web scraping, market research, and large-scale automation. We build and run the rotation, retry, and geo-targeting infrastructure described here, and we publish these guides from hands-on work with the same Colly collectors, proxy switchers, and concurrency limits our customers ship to production. For product details and the full parameter list, see the SparkProxy Scraping API docs.

Keep reading

Related articles