Querying the pkg.go.dev API from Go: A Typed Client Walkthrough
GET https://pkg.go.dev/v1/package/github.com/google/go-cmp/cmp returns JSON instead of HTML. If you have ever written a scraper to find out which module provides a package, or which version is the latest, you can replace it with a supported API.
The pkg.go.dev API announcement introduced the endpoints. It doesn’t show what a reusable Go client looks like, and two behaviours deserve particular care: mutable branch names and ambiguous package paths. Let’s build the client, then look at those.
The endpoints and the response shape
Everything lives under /v1 and every request is a GET. The stateless surface is straightforward to cache and version.
| Endpoint | Returns |
|---|---|
/v1/package/{path} | Info about a package |
/v1/module/{path} | Info about a module |
/v1/versions/{path} | Versions of a module |
/v1/packages/{path} | Packages in a module |
/v1/search?q={query} | Search results |
/v1/symbols/{path} | Symbols declared by a package |
/v1/imported-by/{path} | Packages importing a package |
/v1/vulns/{path} | Vulnerabilities for a module or package |
The package endpoint’s documented response:
{
"modulePath": "github.com/google/go-cmp",
"version": "v0.7.0",
"isLatest": true,
"isStandardLibrary": false,
"goos": "all",
"goarch": "all",
"path": "github.com/google/go-cmp/cmp",
"name": "cmp",
"synopsis": "Package cmp determines equality of values.",
"isRedistributable": true
}
For the other endpoints, use the interactive reference at pkg.go.dev/api or the published OpenAPI document. The docs are the contract. Don’t reverse-engineer field names from sample output you got back once.
Building the client without hand-built URLs
Package paths contain slashes that belong in the URL path. (*url.URL).JoinPath makes that intent explicit, while url.Values.Encode handles query parameter escaping. Avoid assembling either part with string concatenation:
package pkgsite
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
const defaultBaseURL = "https://pkg.go.dev/v1"
type Client struct {
BaseURL string
HTTP *http.Client
}
type PackageOptions struct {
Module string
Version string
}
func NewClient() *Client {
return &Client{
BaseURL: defaultBaseURL,
HTTP: &http.Client{Timeout: 10 * time.Second},
}
}
// Package mirrors the documented response of /v1/package/{path}.
type Package struct {
ModulePath string `json:"modulePath"`
Version string `json:"version"`
IsLatest bool `json:"isLatest"`
IsStandardLibrary bool `json:"isStandardLibrary"`
GOOS string `json:"goos"`
GOARCH string `json:"goarch"`
Path string `json:"path"`
Name string `json:"name"`
Synopsis string `json:"synopsis"`
IsRedistributable bool `json:"isRedistributable"`
}
// APIError carries the raw body so callers can inspect error details
// (such as the candidate module list on an ambiguous path).
type APIError struct {
URL string
StatusCode int
Body []byte
}
func (e *APIError) Error() string {
return fmt.Sprintf("GET %s: %d %s: %s",
e.URL, e.StatusCode, http.StatusText(e.StatusCode), e.Body)
}
func (c *Client) get(ctx context.Context, endpoint, path string, query url.Values, dst any) error {
base, err := url.Parse(c.BaseURL)
if err != nil {
return err
}
u := base.JoinPath(endpoint, path)
if query != nil {
u.RawQuery = query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return err
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Cap the read: never trust a remote body to be small.
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return &APIError{URL: u.String(), StatusCode: resp.StatusCode, Body: body}
}
return json.NewDecoder(resp.Body).Decode(dst)
}
func (c *Client) Package(ctx context.Context, path string, opts PackageOptions) (*Package, error) {
query := make(url.Values)
if opts.Module != "" {
query.Set("module", opts.Module)
}
if opts.Version != "" {
query.Set("version", opts.Version)
}
var p Package
if err := c.get(ctx, "package", path, query, &p); err != nil {
return nil, fmt.Errorf("pkgsite: package %s: %w", path, err)
}
return &p, nil
}
Two habits in there apply to any HTTP client you write. Take a context and build requests with http.NewRequestWithContext, because a package-lookup goroutine that ignores cancellation will happily outlive the request that spawned it. And return a typed error carrying the raw body instead of formatting the status into a string, because the moment a caller needs to branch on a 400 versus a 404 you’ll wish you had errors.As. The next section is exactly that moment.
Version resolution: you may not get back what you asked for
The version query parameter accepts a semantic version tag or the branch names master and main. Other branch names are not supported.
Branch names resolve server-side to a pseudo-version:
$ curl -s "https://pkg.go.dev/v1/package/github.com/google/go-cmp/cmp?version=master" | jq '{path, version}'
{
"path": "github.com/google/go-cmp/cmp",
"version": "v0.7.1-0.20260310220054-34c9473539b8"
}
For durable caching, store the result under the package path, module path and resolved version from the response. A semantic version such as v0.7.0 is immutable, while master, main, latest, and an omitted version can resolve differently later. Cache those aliases only briefly or revalidate them before mapping them to an immutable version.
Omit the parameter and the service chooses the latest tagged version. To check whether a project dependency is behind, pass the version selected by that project and inspect isLatest; omitting the parameter gives the API no information about the version your project uses.
Ambiguous package paths are an error, not a guess
This is the most interesting call the API designers made, and it will catch out anyone whose mental model comes from the website.
When go mod tidy searches for a new module to satisfy an import that no existing dependency provides, it tries candidate module-path prefixes and selects the longest candidate that contains the package. If two modules already in the build list both provide that package, the Go command instead reports an ambiguous import. The pkg.go.dev web interface uses the longest matching module path when choosing a page to display. (This flexibility is what lets a project carve out a submodule later without changing its package import path. More background in our history of dependency management in Go.)
The API refuses to guess. Ambiguous path, error response, plus the list of candidate modules and an invitation to be specific. The announcement calls this “precision over convenience,” and for a machine-readable API it’s the right trade: a wrong guess in a dependency audit is worse than a question.
So an error from Package isn’t always fatal. Because APIError keeps the raw body, you can show the candidates to the user or feed them into a disambiguation step:
const path = "example.com/a/b/c"
pkg, err := c.Package(ctx, path, pkgsite.PackageOptions{})
if err != nil {
var apiErr *pkgsite.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest {
var detail struct {
Candidates []string `json:"candidates"`
}
if json.Unmarshal(apiErr.Body, &detail) == nil && len(detail.Candidates) > 0 {
// Let the user choose in real code. This example retries the first.
pkg, err = c.Package(ctx, path, pkgsite.PackageOptions{
Module: detail.Candidates[0],
})
}
}
}
If your tool already knows the module, perhaps because it parsed a go.mod, pass it in PackageOptions on the first request and skip the ambiguity.
Fanning out lookups without hammering the service
Tools rarely want one package. A dependency auditor wants every import in a repo. Bound the concurrency with errgroup instead of spawning a goroutine per import:
func Lookup(ctx context.Context, c *pkgsite.Client, paths []string) (map[string]*pkgsite.Package, error) {
var (
mu sync.Mutex
out = make(map[string]*pkgsite.Package, len(paths))
)
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // be a good citizen
for _, p := range paths {
g.Go(func() error {
pkg, err := c.Package(ctx, p, pkgsite.PackageOptions{})
if err != nil {
return err
}
mu.Lock()
defer mu.Unlock()
out[p] = pkg
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return out, nil
}
g.SetLimit(8) caps active lookup goroutines, and errgroup.WithContext cancels the rest when one returns an error. (The loop relies on Go 1.22+ per-iteration loop variables. On older versions, shadow p inside the loop.) Concurrency is not rate limiting: the service documents a limit of 45 queries per second per IP block, so a sustained client should also pace requests and handle 429 Too Many Requests.
If one process can request the same package concurrently, as a language server might during a burst of editor events, put singleflight in front of the client. Include the package path, module and requested version in the key so only identical requests collapse into one HTTP call.
The reference client is a binary, not a library
The Go team ships pkgsite-cli:
$ go install golang.org/x/pkgsite/cmd/internal/pkgsite-cli@latest
$ pkgsite-cli package --symbols github.com/google/go-cmp/cmp
It covers pagination and formatting for search, package, module, symbol, imported-by and version queries. Look closely at the import path: cmd/internal/pkgsite-cli. Go’s internal directory rule means code outside golang.org/x/pkgsite/cmd cannot import it. Install it, run it, and read it as a worked example, but do not treat its command-line output as a stable interface.
The stable API now lives at /v1, and the Go team has committed to keeping existing integrations working. That’s enough stability to build an editor plugin, a dependency dashboard, or an MCP server on top of it. A linter could flag imports whose module has not been tagged in three years; a review bot could post the vulnerability list for every new dependency in a PR. Those were technically possible before, but maintaining a scraper made them much less appealing. File problems and feature requests on the x/pkgsite issue tracker.