~ / guides / Go Web Scraping: The Complete Guide

Go Web Scraping: The Complete Guide

MR
Marcus Reed
Founder & lead tester · about the author
the short version
  • Colly is the default Go scraping framework: a callback-based crawler that fetches and parses with CSS selectors in about 20 lines. Install with go get github.com/gocolly/colly/v2.
  • Colly is built on goquery, so every OnHTML callback gives you a jQuery-style API (ChildText, ChildAttr, ForEach) over the matched element.
  • Rate limiting, parallelism, and per-domain delays are one LimitRule struct. Async crawls need colly.Async() plus c.Wait().
  • Parsing is the easy 20 lines. Staying unblocked across thousands of requests is the hard job, and that is what a scraper API handles.

I reach for Go when a scrape has to run fast and concurrent: tens of thousands of pages, a static binary I can drop on any box, and goroutines instead of a thread-pool framework. This guide is the map for that path. Which library does what, a full Colly walkthrough you can compile, how to parse and paginate, how to set rate limits and parallelism, and how to keep a crawler alive once a site pushes back. The code uses the documented API for each library at its current 2026 version, pointed at books.toscrape.com, a sandbox built for scraping practice.

Is Go good for web scraping?

Yes, Go is a strong choice for web scraping, and it pulls ahead when the job is large and concurrent. The standard library ships a production HTTP client, goroutines make thousands of parallel fetches cheap, and go build produces one static binary with no interpreter to install on the target host. For parsing and crawling, the ecosystem centers on one mature framework, Colly, so you rarely assemble pieces by hand.

Three things make Go pull its weight here:

Python has more scraping tutorials and a deeper bench of one-off scripts, which I cover in the Python guide. Go wins when throughput, low memory, and easy deployment matter more than the size of the tutorial pool.

What are the main Go web scraping libraries?

Four tools cover the whole field, and the right one depends on whether you need a full crawler and whether the page runs JavaScript. Static HTML with crawling needs Colly. Parsing alone needs goquery. JavaScript-rendered pages need a browser, which is chromedp. Raw API calls need only the standard library.

LibraryWhat it doesBest forRuns JavaScript
CollyCallback-based crawler: requests, link-following, parsing, rate limitsThe default for any real crawlNo
goqueryjQuery-style HTML parsing with CSS selectorsParsing HTML you already fetchedNo
chromedpDrives headless Chrome over the DevTools ProtocolJavaScript-rendered pages, clicking, loginsYes
net/http + encoding/jsonThe standard library’s HTTP client and JSON decoderHitting JSON APIs directlyNo

A few notes from using all four:

How do you install Colly?

One go get inside a module, and the import path carries the major version. Colly’s current line is v2, so the module path ends in /v2. Initialize a module first if you have not:

go mod init bookscraper
go get github.com/gocolly/colly/v2

The matching import is the full v2 path. Dropping the /v2 pulls the old v1 line, which is a common first mistake:

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

That is the whole install. go get resolves goquery and the other transitive dependencies into your go.mod automatically, so there is nothing else to add. Any Go toolchain with modules enabled (Go 1.16+ defaults to modules) is enough.

How do you scrape a website with Go and Colly?

The loop is three steps: create a collector, register an OnHTML callback for the element you want, then call Visit. Colly fetches the page, runs your callback once per matching element, and hands you a *colly.HTMLElement to read from.

This program fetches the first catalogue page and prints the title and price for every book on it:

package main

import (
	"fmt"
	"log"

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

func main() {
	c := colly.NewCollector(
		colly.UserAgent("Mozilla/5.0 (compatible; BookScraper/1.0)"),
	)

	// Runs once per matching element on the page.
	c.OnHTML("article.product_pod", func(e *colly.HTMLElement) {
		title := e.ChildAttr("h3 a", "title")
		price := e.ChildText(".price_color")
		fmt.Printf("%-8s %s\n", price, title)
	})

	c.OnRequest(func(r *colly.Request) {
		fmt.Println("Visiting", r.URL)
	})

	c.OnError(func(r *colly.Response, err error) {
		log.Printf("error on %s: %v", r.Request.URL, err)
	})

	if err := c.Visit("https://books.toscrape.com/catalogue/page-1.html"); err != nil {
		log.Fatal(err)
	}
}

Each part of that maps to one job:

Run it from the module directory:

go run .

This is the shape I would ship for a static page. The selectors are the only part that changes per site, so the pattern transfers directly to any target whose data is in the initial HTML.

How do you parse HTML with goquery selectors in Colly?

Read fields off the *colly.HTMLElement Colly hands each callback, because that element wraps a goquery selection. Colly is built on goquery, so inside OnHTML you have jQuery-style helpers without importing goquery yourself. The element methods cover almost everything.

MethodReturnsUse for
e.TextstringAll text inside the matched element
e.Attr("href")stringOne attribute of the matched element
e.ChildText(".price")stringStripped text of the first matching descendant
e.ChildTexts(".tag")[]stringText of every matching descendant
e.ChildAttr("a", "href")stringAttribute of the first matching descendant
e.ChildAttrs("a", "href")[]stringAttribute of every matching descendant
e.ForEach(".item", fn)iteratesLooping over repeated children

When you need the raw goquery selection (for example to read a sibling or a parent), it is on e.DOM. For structured output, marshal each record into a struct as you go:

type Book struct {
	Title string
	Price string
	Stock string
}

func main() {
	var books []Book

	c := colly.NewCollector()

	c.OnHTML("article.product_pod", func(e *colly.HTMLElement) {
		books = append(books, Book{
			Title: e.ChildAttr("h3 a", "title"),
			Price: e.ChildText(".price_color"),
			Stock: e.ChildText(".availability"),
		})
	})

	// Fires once after all OnHTML callbacks for a page finish.
	c.OnScraped(func(r *colly.Response) {
		fmt.Printf("collected %d books from %s\n", len(books), r.Request.URL)
	})

	c.Visit("https://books.toscrape.com/catalogue/page-1.html")
}

Two things worth noting:

Call e.Request.Visit on the next-page link from inside an OnHTML callback. Colly tracks visited URLs and queues new ones, so paginated crawling is one extra callback that fires when it finds the “next” anchor and follows it. Colly resolves the relative href against the current page for you.

package main

import (
	"fmt"
	"log"

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

func main() {
	pages := 0

	c := colly.NewCollector(
		colly.AllowedDomains("books.toscrape.com"),
	)

	// Count each listing page as it loads.
	c.OnHTML("body", func(e *colly.HTMLElement) {
		pages++
	})

	// Follow the next-page link until it disappears.
	c.OnHTML("li.next a", func(e *colly.HTMLElement) {
		next := e.Attr("href")
		fmt.Println("following", next)
		e.Request.Visit(e.Request.AbsoluteURL(next))
	})

	c.OnError(func(r *colly.Response, err error) {
		log.Printf("error on %s: %v", r.Request.URL, err)
	})

	c.Visit("https://books.toscrape.com/catalogue/page-1.html")

	fmt.Println("pages crawled:", pages)
}

Two details make this robust on a real target:

Against the full books.toscrape.com catalogue this walks all 50 listing pages. The same “match the next anchor, resolve it, visit it” pattern handles any next-button pagination.

How do you set rate limits and concurrency in Colly?

Use one colly.LimitRule, and turn on colly.Async() when you want parallel fetches. A synchronous collector already paces itself to one request at a time, so a LimitRule matters most once you go async, where it caps how many requests run together and how long Colly waits between them.

The LimitRule fields that do the work:

FieldTypeEffect
DomainGlobstringWhich domains the rule applies to (* for all)
DomainRegexpstringSame, as a regex instead of a glob
ParallelismintMax concurrent requests to matching domains
Delaytime.DurationFixed wait before each request
RandomDelaytime.DurationExtra random wait of 0 to this value

A polite async crawl looks like this. Async() makes Visit non-blocking, so c.Wait() at the end holds the program open until the queue drains:

package main

import (
	"fmt"
	"log"
	"time"

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

func main() {
	c := colly.NewCollector(
		colly.AllowedDomains("books.toscrape.com"),
		colly.Async(true),
	)

	// At most 2 requests at once, 1s + up to 1s random between them.
	c.Limit(&colly.LimitRule{
		DomainGlob:  "*books.toscrape.com*",
		Parallelism: 2,
		Delay:       1 * time.Second,
		RandomDelay: 1 * time.Second,
	})

	c.OnHTML("article.product_pod", func(e *colly.HTMLElement) {
		log.Println(e.ChildText(".price_color"), e.ChildAttr("h3 a", "title"))
	})

	for i := 1; i <= 50; i++ {
		c.Visit(fmt.Sprintf("https://books.toscrape.com/catalogue/page-%d.html", i))
	}

	c.Wait() // block until every queued request finishes
}

The two pieces that trip people up:

How do you scrape JavaScript-heavy sites in Go?

Drive a real browser with chromedp, because Colly cannot run JavaScript. When a page loads its data with JavaScript after the initial response, that content is absent from the HTML Colly downloads, so your OnHTML callbacks never fire. chromedp controls headless Chrome over the DevTools Protocol, so it renders the page exactly as a user sees it, and then you can parse the result.

go get github.com/chromedp/chromedp

You navigate, wait for the rendered content, pull the outer HTML, then hand it to goquery so your parsing code looks the same as before:

package main

import (
	"context"
	"log"
	"strings"
	"time"

	"github.com/PuerkitoBio/goquery"
	"github.com/chromedp/chromedp"
)

func main() {
	ctx, cancel := chromedp.NewContext(context.Background())
	defer cancel()

	ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
	defer cancel()

	var html string
	err := chromedp.Run(ctx,
		chromedp.Navigate("https://example.com"),
		chromedp.WaitVisible("body", chromedp.ByQuery),
		chromedp.OuterHTML("html", &html, chromedp.ByQuery),
	)
	if err != nil {
		log.Fatal(err)
	}

	doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
	if err != nil {
		log.Fatal(err)
	}

	doc.Find(".product").Each(func(i int, s *goquery.Selection) {
		log.Println(s.Text())
	})
}

What matters here:

The quick test for which path you need: fetch a page with Colly and check whether a value you can see in the browser shows up. When it is missing, the page is JavaScript-rendered and you move to chromedp or a rendering scraper API.

How do you avoid getting blocked when scraping in Go?

Look like a normal browser, slow down, and spread requests across IP addresses. A site flags a scraper on three signals: requests that arrive too fast, headers that look automated, and too much traffic from one IP. Each has a countermeasure that works whether you are on Colly or chromedp.

Block signalWhat triggers itFix
Bad headersColly’s default User-Agent, no other headersSet a real User-Agent via colly.UserAgent(...) and add headers in OnRequest
Request rateDozens of requests per second from one clientUse a LimitRule with Delay and RandomDelay; honor 429 responses
Single IPThousands of requests from one addressRotate IPs or proxies with c.SetProxyFunc, or use a scraper API
No JavaScriptHTTP-only fetches that never run scriptsRender with chromedp when the site checks

These measures carry a small project a long way. The trouble starts at scale: rotating a large IP pool, solving CAPTCHAs, and patching per-site quirks becomes its own engineering job, and it pulls focus from the data you actually want. That is where a scraper API like ChocoData fits the category, with a universal endpoint plus 453 dedicated endpoints that return the page so your Go code keeps working unchanged while the blocking is handled upstream. You point c.Visit at the API URL instead of the target, parse the response with the same OnHTML callbacks, and skip the proxy and CAPTCHA plumbing. I cover that full tradeoff, and the legal side, in the web scraping guide and in how to scrape without getting blocked.

For the scraping itself, the stack in this guide will carry you a long way. Take the async pagination crawler, point it at a site you care about, adjust the selectors until the fields come out clean, and add the anti-block measures as the target pushes back.

FAQ

Is Go good for web scraping?

Yes, especially for high-concurrency crawls. Goroutines let one process fetch thousands of pages with low memory, the static binary deploys anywhere with no runtime, and Colly's callback model keeps crawler code short. Python has more tutorials, but Go is faster per core and easier to ship.

Can Colly scrape JavaScript-rendered pages?

No. Colly fetches HTML over HTTP and does not run page scripts, so content injected by JavaScript is absent from what it parses. For those pages drive a real browser with chromedp (the Go CDP library) or send the URL through a scraper API that renders before returning HTML.

What is the difference between Colly and goquery?

goquery parses an HTML document and queries it with CSS selectors, like jQuery. Colly is the crawler around it: it makes the requests, follows links, handles cookies and rate limits, and hands each matched element to your callback. Colly uses goquery internally, so you rarely import goquery directly.

MR
Marcus Reed
I've built and run web scrapers for the better part of a decade. On this site I put scraper APIs and scraping tools through real jobs against real targets, then write up what actually holds up.