Google Search API in Go: A Tested Golang Tutorial (2026)

By Serpent API Team · · 11 min read

Calling a Google search API from Golang takes about 30 lines of standard library: a net/http GET to a SERP endpoint with your key in a header, then encoding/json to decode the results. No SDK, no dependencies. Every code block in this tutorial was compiled and run against the live Serpent API on July 17, 2026, and the outputs you'll see are the real ones.

We'll go from that minimal first call to a production-shaped client: structs derived from the actual JSON, retries with exponential backoff that honor Retry-After, context timeouts, and a concurrent rank checker built on a bounded worker pool. If you're coming from Python or Node, the flow is the same one our Node.js guide and PHP guide follow — Go just makes the concurrency part pleasant.

Why an API instead of scraping from Go?

Go has excellent scraping libraries, but Google search specifically is a hostile target: results render behind JavaScript, block datacenter IPs, and change markup without notice. A SERP API hands you parsed JSON over plain HTTPS — which in Go means the standard library is genuinely all you need. And there's no official alternative to reach for: Google's Custom Search JSON API is closed to new customers with an announced January 1, 2027 end of life (Google's own docs), a story we covered in our migration guide.

Setup: a key and two URLs

Create a free Serpent API account — 10 free searches, no card — and copy your key. Everything in this tutorial uses one endpoint, with a second worth knowing about:

The useful query parameters (full list in the API docs): q (required), engine (google, bing, yahoo, ddg), country (2-letter code), num (1–100 desired results), pages (1–10), freshness, language, and format=simple if you want position/title/url only. Sanity-check your key with curl before writing any Go:

curl -H "X-API-Key: YOUR_API_KEY" \
  "https://apiserpent.com/api/search/quick?q=golang+web+scraping&country=us"

Your first search in 30 lines

The minimal program: request, decode, print. The structs here are deliberately trimmed to what this example uses — the full set comes next.

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"time"
)

type SearchResponse struct {
	Success bool   `json:"success"`
	Engine  string `json:"engine"`
	Country string `json:"country"`
	Results struct {
		Organic []OrganicResult `json:"organic"`
	} `json:"results"`
	Meta struct {
		TotalOrganic int    `json:"totalOrganic"`
		Elapsed      string `json:"elapsed"`
	} `json:"meta"`
}

type OrganicResult struct {
	Position     int    `json:"position"`
	Title        string `json:"title"`
	URL          string `json:"url"`
	Snippet      string `json:"snippet"`
	DisplayedURL string `json:"displayedUrl"`
}

func main() {
	apiKey := os.Getenv("SERPENT_API_KEY")

	params := url.Values{}
	params.Set("q", "golang web scraping")
	params.Set("country", "us")

	req, err := http.NewRequest(http.MethodGet,
		"https://apiserpent.com/api/search/quick?"+params.Encode(), nil)
	if err != nil {
		panic(err)
	}
	req.Header.Set("X-API-Key", apiKey)

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

	var sr SearchResponse
	if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
		panic(err)
	}

	fmt.Printf("engine=%s country=%s organic=%d elapsed=%s\n",
		sr.Engine, sr.Country, sr.Meta.TotalOrganic, sr.Meta.Elapsed)
	for _, r := range sr.Results.Organic {
		fmt.Printf("%2d. %s\n    %s\n", r.Position, r.Title, r.URL)
	}
}

Run with SERPENT_API_KEY=YOUR_API_KEY go run main.go. Real output from our July 17, 2026 run (Go 1.22):

engine=google country=us organic=7 elapsed=4246ms
 1. Web Scraping in Golang: Complete Guide - ZenRows
    https://www.zenrows.com/blog/web-scraping-golang
 2. Web Scraping with Go - ScrapFly Blog
    https://scrapfly.io/blog/posts/web-scraping-with-go
 3. GitHub - gocolly/colly: Elegant Scraper and Crawler Framework ...
    https://github.com/gocolly/colly
 4. Web Scraping in Golang Tutorial With Quick Start Examples
    https://www.scrapingbee.com/blog/web-scraping-go/
 5. Web Scraping With Go (Golang): Colly & goquery (2026)
    https://scrappey.com/qa/web-scraping-languages/web-scraping-with-golang
 6. GitHub - tech-engine/goscrapy: GoScrapy: Harnessing Go's ...
    https://github.com/tech-engine/goscrapy
 7. Web scraping in Golang: 2026 Step-by-step guide
    https://roundproxies.com/blog/web-scraping-golang/

Two honest details worth noticing, because your code should expect both. First, we asked for 10 results and got 7 — the response includes a meta.partialResults object explaining the engine had only 7 for this query, so never hard-code an assumption of 10. Second, an identical earlier run reported elapsed=8782ms against this run's 4.2s: live searches vary. Both behaviors are exactly why the retry-and-timeout layer below exists.

Structs that match the real response

The trap in every “Golang google search api” snippet online is structs written from imagination. Ours were derived by fetching a live response first and mirroring it. The full results object also carries SERP features — People Also Ask, related searches, a nullable featured snippet — so model them with pointers and empty slices in mind:

type Results struct {
	Organic         []OrganicResult  `json:"organic"`
	PeopleAlsoAsk   []PAA            `json:"peopleAlsoAsk"`
	RelatedSearches []RelatedSearch  `json:"relatedSearches"`
	FeaturedSnippet *FeaturedSnippet `json:"featuredSnippet"` // nullable
}

type PAA struct {
	Question string `json:"question"`
	Answer   string `json:"answer"`
}

type RelatedSearch struct {
	Query string `json:"query"`
}

type FeaturedSnippet struct {
	Text      string `json:"text"`
	SourceURL string `json:"sourceUrl"`
}

FeaturedSnippet is a pointer because the API returns null when there's no snippet — decoding null into a struct value would silently give you a zero struct, and a nil check is more honest. Features you don't need, you simply omit: encoding/json ignores unknown fields.

A production client: retries, backoff, Retry-After

Three failure classes need three behaviors: network errors and 5xx responses get retried with exponential backoff plus jitter; a 429 gets retried after exactly the wait the API specifies; any other 4xx (bad key, bad params) fails fast because retrying can't fix it. Serpent API's documented contract makes the middle case easy — 429 responses are the platform's automated protection against overload and come with a Retry-After header plus retryAfter, window and limit fields in the body, so the client never has to guess:

func (c *Client) Search(ctx context.Context, query string, opts map[string]string) (*SearchResponse, error) {
	params := url.Values{}
	params.Set("q", query)
	for k, v := range opts {
		params.Set(k, v)
	}

	var lastErr error
	for attempt := 0; attempt <= c.maxRetries; attempt++ {
		if attempt > 0 {
			select {
			case <-time.After(backoff(attempt, lastErr)):
			case <-ctx.Done():
				return nil, ctx.Err()
			}
		}

		req, err := http.NewRequestWithContext(ctx, http.MethodGet,
			"https://apiserpent.com/api/search/quick?"+params.Encode(), nil)
		if err != nil {
			return nil, err
		}
		req.Header.Set("X-API-Key", c.apiKey)

		resp, err := c.httpClient.Do(req)
		if err != nil {
			lastErr = err // network error: retryable
			continue
		}

		switch {
		case resp.StatusCode == http.StatusOK:
			var sr SearchResponse
			err := json.NewDecoder(resp.Body).Decode(&sr)
			resp.Body.Close()
			if err != nil {
				lastErr = err
				continue
			}
			return &sr, nil

		case resp.StatusCode == http.StatusTooManyRequests:
			// The API says exactly how long to wait — honor it.
			lastErr = &RateLimitError{RetryAfter: parseRetryAfter(resp)}
			resp.Body.Close()

		case resp.StatusCode >= 500:
			resp.Body.Close()
			lastErr = fmt.Errorf("server error: %s", resp.Status)

		default:
			// Other 4xx (bad key, bad params): retrying won't help.
			body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
			resp.Body.Close()
			return nil, fmt.Errorf("request failed: %s: %s",
				resp.Status, strings.TrimSpace(string(body)))
		}
	}
	return nil, fmt.Errorf("giving up after %d attempts: %w", c.maxRetries+1, lastErr)
}

func backoff(attempt int, lastErr error) time.Duration {
	var rl *RateLimitError
	if errors.As(lastErr, &rl) && rl.RetryAfter > 0 {
		return rl.RetryAfter + time.Duration(rand.Intn(1000))*time.Millisecond
	}
	base := time.Duration(1<<uint(attempt-1)) * time.Second // 1s, 2s, 4s, 8s
	return base + time.Duration(rand.Intn(500))*time.Millisecond
}

The jitter matters at scale: a fleet of workers that all retry on the same schedule re-collide forever. If you're planning sustained volume, our running millions of requests guide covers the client engineering side, and the rate-limit comparison shows what each provider actually documents.

Concurrent rank checking with a bounded worker pool

Here's where Go earns its keep. The wrong move is a goroutine per keyword — a thousand keywords would open a thousand simultaneous requests and hit your account's concurrency ceiling instantly. The right move is a fixed pool of workers pulling from a channel. Size the pool to your account's documented concurrent allocation (Serpent API's rate-limit table publishes concurrent, per-minute, per-hour and per-day allocations that scale with balance):

func checkRanks(ctx context.Context, c *Client, domain string, keywords []string, workers int) []RankResult {
	jobs := make(chan string)
	var (
		mu      sync.Mutex
		wg      sync.WaitGroup
		results = make([]RankResult, 0, len(keywords))
	)

	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for kw := range jobs {
				sr, err := c.Search(ctx, kw, map[string]string{"country": "us"})
				rr := RankResult{Keyword: kw, Err: err}
				if err == nil {
					for _, r := range sr.Results.Organic {
						if strings.Contains(r.URL, domain) {
							rr.Position = r.Position
							break
						}
					}
				}
				mu.Lock()
				results = append(results, rr)
				mu.Unlock()
			}
		}()
	}

	for _, kw := range keywords {
		jobs <- kw
	}
	close(jobs)
	wg.Wait()
	return results
}

Real output — checking where go.dev ranks for three keywords with two workers, July 17, 2026:

golang json tutorial     #4
web scraping golang      not in top 10
golang http client       #1
checked 3 keywords in 9.917s

Two workers, three live Google searches, under ten seconds end to end — and the same pool handles three thousand keywords without any code change. (The complete runnable program, all ~170 lines, is these pieces assembled: types, client, pool, and a main that reads the key from SERPENT_API_KEY. Build a real tracker on top with our rank tracking API guide.)

Depth: getting 100 results per query

Everything above used the default ~10 results. Pass num=100 (or pages=10) and one call returns up to 10 pages. The pricing detail worth knowing: Serpent API bills quick search as one flat-rate call on a $10+ balance regardless of page count — depth-100 tracking costs the same per keyword as depth-10, from $0.60 down to $0.03 per 1,000 by tier (live pricing). Most per-request providers bill each page separately, which is the “10× problem” our cost calculator models at any volume, and a real budget factor if you're comparing options from the cheapest SERP API breakdown.

FAQ

Is there an official Google search API for Go?

Not one you can build on today. Google's Custom Search JSON API is closed to new customers with an announced January 1, 2027 end of life, and it never exposed full SERP features. Our CSE migration guide covers the options.

Do I need a Go SDK or client library?

No. A SERP API is a plain HTTPS GET returning JSON — net/http, encoding/json and context cover everything, as the tested code above shows.

How fast are the responses?

Live searches take seconds: our recorded runs reported 4.2–8.8s of engine time per query, and 3 keywords with 2 workers finished in 9.9s. Throughput comes from concurrency, not per-call speed.

How many goroutines should I run?

A bounded pool at or below your account's documented concurrent allocation. Unbounded goroutine-per-keyword designs trip 429s (the platform's automated overload protection) — the rate-limits comparison has the math.

Can I get more than 10 results per call?

Yes — num=1..100 or pages=1..10. One call, up to 10 pages, one flat charge on a $10+ balance.

Ship your Go integration today

Every snippet in this guide ran against the live API before publishing. Start with 10 free searches — no card, no minimum — and the whole client is standard library.

Start Free — 10 Searches, No Card

Explore: SERP API · API Docs · Pricing

References

Related Posts