# libbpfgo wraps libbpf so Go programs can drive eBPF programs

> libbpfgo is Aqua Security's Go library for Linux eBPF, built as a thin wrapper around libbpf, the C library that upstream Linux maintains for loading and attaching eBPF programs. It removes the C calling conventions but keeps the dependency: CGO, a system libbpf or a vendored submodule, and a version number that encodes the minimum libbpf you must link.

**aquasecurity/libbpfgo** — eBPF library for Go. Powered by libbpf.

- Repository: https://github.com/aquasecurity/libbpfgo
- Stars: 851 · Forks: 100
- Language: Go
- License: Apache-2.0
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/aquasecurity-libbpfgo

## A wrapper, deliberately thin

libbpfgo is a Go library for Linux eBPF, and the README is explicit about its nature. It was created for Tracee, the open source runtime security and eBPF tracing tool from the same organisation, and it is built around libbpf, described as the standard library for interacting with eBPF programs from userspace, which is a C library maintained in Linux upstream. The library is a thin Go wrapper around that project.

Thin is the operative word, and it has consequences in both directions. You get libbpf's behaviour, because it is libbpf doing the work, and you get libbpf's release cadence, because the C library keeps moving and the Go wrapper has to follow. You also keep the C dependency, since the interop is done through CGO.

What the wrapper does change is the shape of the API. The README says the library tries to make it natural for Go developers by abstracting away the C technicalities, and it gives three specifics: it translates low level return codes into Go error values, it organises functionality around Go structs, and it uses channels to let you consume events.

The repository layout reflects that structure. The CGO bridge is libbpfgo.c and libbpfgo.h, the Go side is split by concern with module.go, prog.go, map.go, buf-ring.go, buf-perf.go and buf-common.go, and there are also elf.go, btf.go, link.go, link-reader.go, iterator helpers for maps and modules, and a builder directory. The selftest directory holds the tests, and the Makefile drives everything.

The licence is Apache-2.0, the module path is github.com/aquasecurity/libbpfgo, and the last push to the main branch was on 2026-10-14.

## Importing the library is not enough

The Installing section exists because the dependency is not self-contained, and it says so in the first line. libbpfgo uses CGO to interop with libbpf and expects to be linked with libbpf at run or link time. Simply importing it is not enough to get started.

There are two ways to satisfy that dependency, and the choice matters more than it looks.

The first is to install libbpf as a shared object in the system. The README notes that libbpf may already be packaged for your distribution and, if not, that you can build and install it from source. The dynamic build path in the Makefile uses pkg-config to find it, with CGO flags that add /usr/include to the include path.

The second is to embed libbpf into your Go project as a vendored dependency, so the libbpf code is statically linked into the resulting binary and there are no runtime dependencies. Tracee takes this approach, and the repository carries libbpf as a git submodule with the .gitmodules file at the top level.

The README also explains why the choice can force your hand. Dynamic builds need your operating system to have a recent enough libbpf package and its headers installed, and recent features sometimes require backported packages so that the system contains the latest libbpf features. And some distributions carry local changes to their libbpf package, with versions that include backports and fixes differently from upstream versions; in those cases the README recommends using libbpfgo statically compiled.

That last sentence is the practical takeaway. On a distribution with a patched libbpf, the version you see in your package manager may not correspond to upstream, and the compatibility guarantee encoded in the release tag stops meaning what you hoped.

## The build matrix: dynamic, static and the selftests

The Makefile exposes the two linking modes as separate targets, and the README's tables list them.

The generic rules are all, which builds libbpfgo dynamically, clean, which cleans the entire tree, selftest, which builds all selftests statically, and selftest-run, which runs all of them statically.

For the dynamic build, against the libbpf from the operating system, the rules are libbpfgo-dynamic to build, libbpfgo-dynamic-test for go test against the dynamic build, and selftest-dynamic with selftest-dynamic-run for building and running the selftests the same way.

For the static build, using the libbpf submodule, the rules are libbpfgo-static to build, libbpfgo-static-test for go test, and selftest-static with selftest-static-run for the selftest build and run.

The examples the README gives are short:

```bash
make libbpfgo-static => libbpfgo statically linked with libbpf
make -C selftest/perfbuffers => single selftest build (static libbpf)
make -C selftest/perfbuffers run-dynamic => single selftest run (dynamic libbpf)
make selftest-static-run => will build & run all static selftests
```

Note 01 says that dynamic builds need a recent enough libbpf package and its headers, and that some recent features might require backported packages. Note 02 says that static builds need git submodule init first, and that the libbpf submodule must be synced before you statically compile or test.

The Makefile itself shows the toolchain assumptions: clang is the C compiler, clang-format-14 handles formatting, and the Go linting and static analysis stack is revive, staticcheck, goimports-reviser and errcheck, with a .clang-format, a .clang-tidy and a .revive.toml in the repository. There are also vagrant targets, vagrant-up and vagrant-ssh, so that contributors can compile and test on Linux and Darwin hosts on amd64 and arm64.

## The workflow: object file, Module, BPFProg, BPFMap, RingBuffer

The Concepts section walks through the sequence, and it is short enough to remember.

First you compile your BPF program into an object file. Then you initialise a Module struct, which the README describes as a unit of BPF functionality around your compiled object file. You load BPF programs from the object file using the BPFProg struct, and attach BPFProg to system facilities, for example to raw tracepoints or kprobes, using the BPFProg's associated functions. You instantiate and manipulate BPF Maps through the BPFMap struct and its methods, and you instantiate and manipulate the perf buffer for communicating events from your BPF program to the userspace program using the RingBuffer struct.

The example shows the same thing in code, and it is a good illustration of how much C disappears:

```go
// initializing
import bpf "github.com/aquasecurity/libbpfgo"
...
bpfModule := bpf.NewModuleFromFile(bpfObjectPath)
bpfModule.BPFLoadObject()

// maps
mymap, _ := bpfModule.GetMap("mymap")
mymap.Update(key, value)

// ring buffer
rb, _ := bpfModule.InitRingBuffer("events", eventsChannel, buffSize)
rb.Poll(300)
e := <-eventsChannel
```

Three things are worth noting. The module is opened from a file path and the object loaded with a single call. Map updates are direct method calls with no pointer arithmetic. And the event path is a Go channel: the ring buffer is initialised with a channel and a buffer size, Poll takes a timeout in milliseconds, and events arrive by receiving from the channel.

The channel is the piece that changes the shape of a Go program most, because it turns the callback style that C libraries usually impose into something that composes with goroutines and select statements.

## Version numbers that name your libbpf

The release section of the README explains a versioning scheme that is unusual and worth reading before you pin a version.

First, the cadence. libbpfgo does not yet have a regular schedule for cutting releases, and there has not yet been a major release. The compatibility promise is that API backwards compatibility will be maintained for all releases with the same major release number, and milestones are created when preparing for a release.

Major releases are cut when backwards compatibility is broken or major milestones are completed, such as reaching parity with libbpf's API. Minor releases incorporate new support for libbpf APIs, and patch releases carry important individual or groupings of bug fixes.

Then there is the libbpf support numbering, which encodes the minimum required libbpf version. The example given is v0.2.1-libbpf-0.4.0, which means that version 0.2.1 of libbpfgo requires v0.4.0 or newer of libbpf. The same convention is visible in the release tags: v0.10.0-libbpf-1.5.1 was published on 2026-05-15, v0.9.2-libbpf-1.5.1 on 2025-08-11, and the most recent one is v0.11.0-libbpf-1.8-dev-2bbc483 from 2026-07-29, whose name shows it tracks a development build of libbpf 1.8.

Two practical consequences follow. The suffix tells you what your system has to provide, which is the question you should answer before writing code rather than after a link error. And because there has not been a major release, the compatibility promise applies within the 0.x line, so an upgrade that crosses a minor version can still bring a new minimum libbpf requirement.

For the Go side, the go.mod is modest: the module is github.com/aquasecurity/libbpfgo, it declares go 1.18, and it requires testify for testing plus the libcap library from kernel.org, with its psx companion as an indirect dependency.

## What the wrapper saves you, and what it does not

It is worth being clear about the boundary, because the value of a wrapper is often overestimated by people who have not written the C.

What libbpfgo saves is the mechanical part. You no longer deal with C return codes, you no longer manage pointers into the kernel, you no longer write a callback for every event, and you no longer read a header file to find out which function takes which argument. For a Go team that has to attach a handful of probes and read a ring buffer, that is a genuine reduction in work.

What it does not save is the eBPF itself. The BPF program is still compiled separately, usually from C, by clang, with the verifier's rules applying to your program rather than to the wrapper. The concepts section is telling in that respect: the first step of the workflow is compiling your BPF program into an object file, before any Go code appears. A team that hoped to avoid learning BPF and the verifier will be disappointed.

The other cost is the dependency chain. CGO means the Go build now needs a C toolchain, pkg-config and headers, and your binary either needs a system libbpf at runtime or needs the submodule vendored and statically linked. The static path vendors the libbpf submodule and links it in, and the README notes that the dynamic path needs a recent enough package on your distribution.

The alternative approach, for teams who want to avoid CGO and the version coupling entirely, is a pure-Go eBPF library that compiles and loads programs without a system libbpf. The difference in approach is fidelity versus portability: libbpfgo keeps libbpf's exact semantics and its exact versioning, while a pure-Go implementation trades that for a build with no C dependency. If your program uses only the common parts of the eBPF interface, the pure-Go route is less to manage; if you depend on a recent libbpf feature, matching upstream behaviour is what this wrapper is for.

## Conclusion

Adopt libbpfgo when you want Go programs to load and attach eBPF programs with libbpf's own semantics rather than a reimplementation, and when you can live with CGO in your build. Do not adopt it for a project that must produce a dependency-free static binary without vendoring the libbpf submodule, or when your distribution's libbpf package is too old for the features you need. Before you commit, check the minimum libbpf version encoded in the release tag you plan to use, since the suffix names it, and try the static build with the submodule synced, because that is the path the project itself recommends when a distribution ships a patched libbpf.

## FAQ

### What is libbpfgo?

It is Aqua Security's Go library for Linux eBPF, created for Tracee and built as a thin wrapper around libbpf, the C library maintained in Linux upstream. It translates C return codes into Go errors, organises functionality around structs and delivers events over Go channels.

### Why does importing libbpfgo not work on its own?

Because libbpfgo uses CGO to interop with libbpf and expects to be linked with libbpf at run or link time. You either install libbpf as a system shared object, as the README notes it may already be packaged for your distribution, or vendor it into the project so it is statically linked with no runtime dependency.

### How do I build libbpfgo statically?

Sync the libbpf git submodule first, since the README says static builds need git submodule init and the submodule synced, and then run make libbpfgo-static. The make selftest-static-run target builds and runs all the static selftests, and the selftest/perfbuffers directory can be built on its own.

### What does the libbpf version in a libbpfgo release tag mean?

It is the minimum libbpf version that must be linked for compatibility, so v0.2.1-libbpf-0.4.0 means libbpfgo 0.2.1 requires libbpf 0.4.0 or newer. Minor releases add support for new libbpf APIs, major releases break compatibility, and the README recommends static compilation when a distribution ships a patched libbpf.

## Sources

- [aquasecurity/libbpfgo on GitHub](https://github.com/aquasecurity/libbpfgo)
- [Issues](https://github.com/aquasecurity/libbpfgo/issues)
- [License: Apache-2.0](https://github.com/aquasecurity/libbpfgo/blob/main/LICENSE)
- [README](https://github.com/aquasecurity/libbpfgo/blob/main/README.md)
- [Releases](https://github.com/aquasecurity/libbpfgo/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/aquasecurity-libbpfgo
