Building Go with PGO using Bazel

February 12, 2026
Read in:日本語

Introduction

Profile-Guided Optimization (PGO) became officially GA from Go 1.21, and you can apply PGO either by placing default.pgo in the main package or by specifying a PGO profile with the -pgo flag. However, in environments where Bazel is used for building, these mechanisms do not work, so a different setup using rules_go is required.

This article explains how to perform PGO builds for Go using Bazel (rules_go).

Prerequisites

This article assumes the following versions.

  • Go: 1.25
  • Bazel: 8.2.1
  • Bazelisk: 1.28.1
  • rules_go: 0.60.0

The sample code used in this article is published in the following repository.

What is PGO (Profile-Guided Optimization)?

PGO is a technique that feeds runtime profile information of a program back to the compiler and performs compile-time optimizations based on that information. Typical compiler optimizations are performed based on static analysis of source code, but PGO takes the actual execution patterns into account, enabling more effective optimization.

In Go, PGO was introduced as a preview in 1.20 and became officially GA in 1.21. According to the official Go documentation, PGO has been reported to bring performance improvements of about 2–7%. Since the rate of improvement depends on the characteristics of the application, the actual effect varies by workload.

Optimizations performed by PGO

In Go's PGO, the following optimizations are mainly performed.

Inlining

Functions determined to be hot from the profile are inlined more aggressively than usual. Not only is the overhead of function calls reduced, but inlining also enables further optimizations at the call site (e.g., constant propagation and improved escape analysis).

Devirtualization

For indirect calls on interface values, the dominant concrete type is identified from the profile information and converted into a direct call.

Specifically, the following kind of transformation takes place.

go
// Before PGO (indirect call)
var r io.Reader = f
r.Read(b)

// After PGO (direct call with runtime guard)
if f, ok := r.(*os.File); ok {
    f.Read(b)  // direct call → further inlining is possible
} else {
    r.Read(b)  // fallback
}

When the profile indicates that the concrete type of r is *os.File with high probability, it is converted into a guarded direct call using a type assertion. The direct call then becomes a candidate for further inlining, which can lead to chained optimizations.

Collecting profiles

To perform a PGO build, you first need to collect a CPU profile of the target application. In Go, the net/http/pprof package allows you to obtain profiles via an HTTP endpoint.

The following is an example of a server with the pprof endpoint enabled.

cmd/server/main.go
package main

import (
	"fmt"
	"log"
	"net/http"
	_ "net/http/pprof" // registers the /debug/pprof/ endpoints
)

func main() {
	http.HandleFunc("/health", handleHealth)
	// ... omitted ...

	log.Println("server listening on :8080")
	log.Println("pprof available at /debug/pprof/")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

By importing _ "net/http/pprof", various profiling endpoints are automatically registered under /debug/pprof/. The CPU profile can be obtained from the following endpoint.

bash
$ curl -o profile/default.pgo "http://localhost:8080/debug/pprof/profile?seconds=30"
NOTE
When collecting profiles in production, it is important to collect them while a representative workload is running. With a biased profile, you may not get the full benefit of optimization.

If you are already collecting profiles with Grafana Pyroscope, Datadog, or similar tools, you can also export PGO profiles using the CLIs provided by those products. When considering production use, we recommend checking whether the observability tool you use supports it.

Performing PGO builds with Bazel

Defining the profile as a Bazel target

To use the PGO profile as a Bazel build target, define exports_files in profile/BUILD.bazel.

profile/BUILD.bazel
exports_files(
    ["default.pgo"],
    visibility = ["//visibility:public"],
)

How to specify PGO

In rules_go, there are two ways to specify a PGO profile.

1. The pgoprofile attribute of go_binary

You specify the label of the profile file in the pgoprofile attribute of the go_binary rule.

cmd/server/BUILD.bazel
load("@rules_go//go:def.bzl", "go_binary", "go_library")

go_library(
    name = "server_lib",
    srcs = ["main.go"],
    importpath = "github.com/ucpr/workspace2026/bazel_go_pgo_build/cmd/server",
    visibility = ["//visibility:private"],
)

go_binary(
    name = "server",
    embed = [":server_lib"],
    pgoprofile = "//profile:default.pgo",
    visibility = ["//visibility:public"],
)

With this method, the profile specified in the pgoprofile attribute is also applied to the compilation of all go_library targets the binary depends on. Internally, this is implemented using Bazel's configuration transition, so the profile specification is propagated through the entire dependency graph.

2. Command-line flag

You can also specify the PGO profile via a command-line flag at build time.

bash
$ bazel build //cmd/server \
    --@rules_go//go/config:pgoprofile=//profile:default.pgo

This method can also be used as a permanent setting by writing the configuration in .bazelrc.

.bazelrc
build --@rules_go//go/config:pgoprofile=//profile:default.pgo

Behavior when the profile does not exist

If the PGO profile specified by the pgoprofile attribute or the command-line flag does not exist, the build fails with a missing input file error.

bash
$ bazel build //cmd/server
ERROR: missing input file '//profile:default.pgo'
ERROR: Build did NOT complete successfully

If you want to temporarily disable PGO, you need to either comment out the pgoprofile attribute or remove the command-line flag specification.

How to verify a PGO build

When applying PGO with a regular go build, you can check for the presence of the -pgo flag using debug.ReadBuildInfo() or go version -m.

bash
$ go version -m /path/to/binary
# ...
# build	-pgo=/path/to/default.pgo

To check from within the program, look up the -pgo key in debug.ReadBuildInfo()'s Settings as follows.

go
func isPGOEnabled() bool {
	info, ok := debug.ReadBuildInfo()
	if ok {
		for _, bs := range info.Settings {
			if bs.Key == "-pgo" {
				return bs.Value != ""
			}
		}
	}
	return false
}

However, when built with Bazel, debug.ReadBuildInfo() does not return build information (it returns false), so neither method above can be used for verification.

To check whether PGO has been applied to a binary built with Bazel, use the --subcommands flag and inspect the build log.

bash
$ bazel build //cmd/server --subcommands 2>&1 | grep pgoprofile
NOTE
--subcommands does not output build commands when there is a cache hit. When verifying, run bazel clean first and then build.

If the build log contains -pgoprofile profile/default.pgo, you can confirm that PGO has been applied.

bash
# PGO is applied to the stdlib build
bazel-out/.../builder stdlib ... -pgoprofile profile/default.pgo)
# PGO is applied to the compilation of application code
bazel-out/.../builder compilepkg ... -pgoprofile profile/default.pgo)

Conclusion

This article explained an overview of Go's PGO and how to perform PGO builds using Bazel (rules_go).

In rules_go, you can use PGO via two methods: the pgoprofile attribute of go_binary and a command-line flag. As Go versions advance, the scope of PGO optimizations is also expanding, so it is worth considering adopting PGO in performance-critical applications.

If you find any errors or inaccurate expressions in this article, I would appreciate it if you could let me know.

References