AlgoMaster Logo

CGO Basics

Low Priority28 min readUpdated June 6, 2026

CGO is the Go feature that lets a Go program call functions written in C. It plugs the Go toolchain into the system's C compiler so a single go build produces one binary that contains both Go-compiled and C-compiled code. This chapter covers what CGO is, the special import "C" line, the preamble comment that holds C code and #cgo directives, how the build flow actually works, the CGO_ENABLED switch, and the cross-compilation caveat.

CGO and the cgo Tool

It helps to separate two things that share a name. CGO with capital letters refers to the language feature: the rules that let a .go file call C functions. The lowercase cgo is the tool that implements those rules, and it ships with the standard Go distribution. When you run go build or go run on a package that imports "C", the Go toolchain invokes the cgo tool to do the heavy lifting.

The cgo tool reads each Go file that has import "C", extracts the C preamble (the comment block right above the import), generates a pair of intermediate files (one Go file, one C file), then asks the C compiler to compile the C side and the Go compiler to compile the Go side. The linker stitches the two object files together into the final binary. You don't usually run cgo by hand. The go command knows how to call it.

The diagram shows the build pipeline. Your source file feeds the cgo tool, which splits it into a Go half and a C half. Each half goes through its respective compiler, and the linker merges the resulting object files into the final binary. The output looks like any other Go binary except it also contains the compiled C code.

A few facts worth pinning down before we get into syntax. CGO needs a C toolchain on the build machine. On Linux that usually means gcc from the distribution package manager. On macOS the Xcode Command Line Tools provide clang, which the Go toolchain calls through the cc driver. On Windows you need MinGW-w64 or a similar gcc port, because the Microsoft compiler isn't compatible with Go's calling conventions. The CC environment variable controls which compiler the Go toolchain uses, defaulting to whatever cc resolves to on the system.

CGO is not a foreign function interface in the dynamic-loading sense: there's no dlopen happening at runtime to load a .so file by path. Everything is wired up at build time. The C functions you call are linked into the binary directly, just as if you'd written the program in pure C with a Go-shaped front end. This is why a working cc is required during the build.

The import "C" Pseudo-Package

The trigger for the whole CGO machinery is one line: import "C". This looks like a normal import, but "C" is not a real Go package. There's no source code for it, no package C directory anywhere. It's a signal to the Go toolchain that this file uses CGO, plus a namespace through which you reach C symbols. C.atoi, C.printf, C.int, and so on all refer to symbols on the C side.

The import "C" line follows two strict rules. First, it has to appear on a line by itself, not inside a parenthesised import group with other packages. Second, the preamble comment, if there is one, has to sit directly above the import with no blank line between them.

Here's the correct shape.

The /* ... */ block is the preamble. It contains real C code that the cgo tool feeds to the C compiler. Right after */ comes import "C" on its own line. Below that, the regular Go imports go in their own block. Trying to combine them like this:

won't work. The Go toolchain reports cannot import "C" because import "C" has to be its own standalone statement. The same is true for putting a blank line between the comment and the import.

This compiles, but the comment is no longer attached to the import. The cgo tool treats it as a regular comment and ignores it, so the #include never reaches the C compiler. The fix is to remove the blank line.

The error you'd see in this case looks like could not determine kind of name for C.atoi. The cgo tool is reporting that it didn't get any declaration for atoi because the preamble it processed was empty. The fix is always the same: check that the comment block sits immediately above import "C" with no blank line.

The Preamble Comment

The block comment directly above import "C" is the preamble. The cgo tool extracts its contents verbatim and hands them to the C compiler as a header file. You can put four kinds of things in there.

The first kind is #include directives that pull in standard C headers or your own headers. The second kind is #define macros and other C preprocessor directives. The third kind is inline C function definitions, useful for small helpers when you don't want to create a separate .c file. The fourth kind is #cgo directives, special instructions to the cgo tool itself (not to the C compiler) that control compiler and linker flags, covered in the next section.

Here's a preamble that uses all four.

The #include pulls in stdio.h so any helper that calls printf would have its declaration available. The #define sets a macro. The static double total_with_tax(...) defines a complete C function inline, and main calls it through C.total_with_tax. The #cgo CFLAGS: -O2 asks the C compiler to optimise the C side at level two.

The static keyword on the inline function matters. Without static, every Go file that includes this preamble would get its own copy of the function, and the linker would complain about duplicate symbols if the same package contained two CGO files with the same definition. static keeps the function private to the translation unit, which is the safe default for preamble-defined helpers.

If your C code grows past a few short helpers, move it out of the preamble and into a separate .c file alongside the Go source. The cgo tool automatically compiles any .c, .cc, or .cpp file in the same package, so this lets you keep the Go side clean. A typical layout is api.go (with the CGO preamble that #includes a header), helpers.h (declarations), and helpers.c (the real C implementation). The inline-in-preamble form is fine for one-file demos but doesn't scale to anything larger.

The #cgo Directives

Inside the preamble, lines that start with #cgo are not regular C. They are instructions for the cgo tool, telling it what flags to pass to the C compiler and the linker. The cgo tool strips these lines out before handing the rest of the preamble to the C compiler.

There are four common variants. Each one corresponds to a different stage of the C build.

DirectiveStage it affectsTypical use
#cgo CPPFLAGS:C preprocessor-I/path/to/headers, -D defines
#cgo CFLAGS:C compiler-O2, -Wall, -std=c11
#cgo CXXFLAGS:C++ compiler (if you have .cpp files)same idea, C++ specific
#cgo LDFLAGS:Linker-lzlib, -L/path/to/libs, -lm

CFLAGS and CPPFLAGS are sometimes used interchangeably, but the distinction matters when you mix C and C++ code. CPPFLAGS runs through both compilers, so -I paths defined there are visible everywhere. CFLAGS is C-only, CXXFLAGS is C++-only.

A simple example: imagine you want to call zlib's checksum function to validate product-feed lines. The preamble includes zlib.h, and the linker has to know to link against libz.

The above example uses unsafe.Pointer and C.CString. The point here is the #cgo LDFLAGS: -lz line, which tells the linker "also include libz". Without it the build would fail with an undefined reference to crc32.

You can put more than one #cgo line in the preamble. They accumulate. Two CFLAGS lines combine into a single argument list in the order they appear. This is useful when one directive is conditional on the OS or architecture, which we'll see next.

The #cgo directives also support OS and architecture conditions. Writing #cgo linux LDFLAGS: -lrt applies the flag only when building on Linux. #cgo darwin amd64 CFLAGS: -DMACOS_INTEL applies only when building on macOS for the x86_64 architecture. The conditions go right after #cgo and before the flag name.

The Go toolchain picks the right line based on the build target. On Linux, only the linux line takes effect. On macOS, only the darwin line. On Windows, only the windows line. The unconditional CFLAGS: -O2 applies everywhere.

For libraries that ship a pkg-config file, the #cgo directives also accept pkg-config: followed by package names. The cgo tool then runs pkg-config --cflags and pkg-config --libs on your behalf and feeds the results to the compiler and linker.

This expands at build time into whatever pkg-config --cflags openssl and pkg-config --libs openssl say. The advantage is that you don't have to hardcode paths or library names. The disadvantage is that the build now depends on pkg-config and the right .pc file being installed.

A common pattern is to combine an unconditional #cgo line with per-OS overrides for libraries that have different names on different systems.

The -ldl flag (dynamic loader) is needed on Linux for dlopen but doesn't exist on macOS. Frameworks like -framework Security are macOS-specific syntax. Windows uses entirely different library names.

You can also pass flags through environment variables at build time. CGO_CFLAGS and CGO_LDFLAGS accumulate on top of any #cgo lines in the source.

This adds an extra linker flag without touching the source, useful when shipping a binary that loads a vendored .so file from a non-standard location.

The Role of the System C Compiler

CGO doesn't ship its own C compiler. It calls whatever C toolchain is already on the system. The driver name is cc by default, but you can override it by setting the CC environment variable before running go build.

The first command forces clang. The second pins gcc to version 13. The third points at a cross-compiler in a non-standard location, useful for embedded targets.

On a typical Linux distribution, cc resolves to /usr/bin/gcc. On macOS, cc is a symlink to clang shipped with the Command Line Tools. On Windows, you need to install MinGW-w64 and put its gcc on PATH, then set CC=gcc (or rely on the cc driver if MinGW provides one). The Microsoft cl.exe compiler does not work with CGO because it uses different calling conventions than Go expects.

PlatformDefault C compilerHow to install
Linux (Debian/Ubuntu)gcc via ccapt install build-essential
Linux (Fedora/RHEL)gcc via ccdnf install gcc
macOSclang via ccxcode-select --install
Windowsgcc from MinGW-w64Install MSYS2 or TDM-GCC
FreeBSDclang via ccShips with base system

The C compiler also has to be able to find any external headers and libraries you reference. #include <zlib.h> works only if zlib.h is on the compiler's include path, usually because the OS has a zlib-dev (or zlib-devel) package installed. Without the headers, the build fails before it even reaches the linker.

You can confirm which compiler your Go toolchain will call by running go env CC. On a fresh Ubuntu install this prints gcc. On macOS with the Command Line Tools it prints clang. The variable can also be set with go env -w CC=clang, which makes the change persist across shell sessions.

If the C compiler is missing entirely, the toolchain emits a clear error: cgo: C compiler "cc" not found. The fix on Linux is apt install build-essential (Debian/Ubuntu) or dnf install gcc (Fedora). On macOS, run xcode-select --install. On Windows, install MSYS2 and add the MinGW-w64 bin directory to PATH.

The C compiler isn't called once per Go file. It's called once per .c file in the package, plus once per CGO-enabled Go file (because the cgo tool generates an intermediate .c file from each preamble). For a package with five Go files all importing "C", you'll see the C compiler invoked at least five times, and build times grow accordingly.

The cgo Build Tag

Go has a build-tag system that lets you include or exclude files based on conditions. The build tag cgo is set automatically when CGO is enabled, and you can use it to write files that compile only under CGO.

The two relevant tag forms are //go:build cgo (include this file only when CGO is on) and //go:build !cgo (include only when CGO is off). They sit at the very top of the file, above the package line, separated from the package by a blank line.

This is the standard pattern for offering a pure-Go fallback when CGO isn't available. You write two files: one with the CGO implementation tagged cgo, and one with a pure-Go alternative tagged !cgo. The toolchain picks exactly one based on whether CGO is enabled at build time.

A small currency-conversion example: imagine a package that converts an amount from one currency to another. The CGO version calls a C function that uses an external library's exchange-rate engine. The pure-Go version uses a hardcoded table.

And the fallback in the same package:

Both files declare a function named Convert with the same signature. The build tags make sure exactly one compiles into the final binary. The caller doesn't care which version they got, the API is identical.

This pattern is common in standard library packages too. net uses cgo for its DNS resolver on systems where the libc resolver matters, and a pure-Go resolver when CGO is off. os/user has a similar split for looking up Unix users.

A common point of confusion: the cgo build tag is set by the toolchain based on CGO_ENABLED, not by the -tags command-line flag. Passing -tags cgo does nothing useful. The right knob is CGO_ENABLED=0 itself, which the toolchain reads and uses to set or clear the cgo tag automatically.

CGO_ENABLED and Pure-Go Builds

The environment variable CGO_ENABLED controls whether the Go toolchain uses CGO at all. It accepts 0 (off) or 1 (on). The default depends on the build target. For native builds (same OS and architecture as the host) it's usually on. For cross-builds it's off.

Setting CGO_ENABLED=0 produces a pure-Go binary. The toolchain skips the cgo tool entirely, and any package that imports "C" fails to build with an explicit error: imports "C": cgo: C compiler "cc" not found (if CGO is enabled but no compiler is available) or imports "C": build constraints exclude all Go files (if CGO is disabled).

A pure-Go build has three properties that often matter in production.

First, the resulting binary has no dependency on the system's libc. It can run on a different Linux distribution, or inside a minimal Docker image like scratch or distroless/static, without worrying about glibc versions.

Second, the binary is fully statically linked by default, with no external .so or .dylib files needed at runtime. This makes deployment easier: you copy one file to a machine and it runs.

Third, cross-compilation is straightforward. GOOS=linux GOARCH=arm64 go build produces a Linux ARM64 binary from any host. With CGO enabled, the same command needs a cross-compiler and a sysroot for the target, which is much more setup.

The file command confirms the binary is statically linked. With CGO enabled, the same build would produce a dynamically linked binary that depends on libc at runtime.

CGO_ENABLEDImports "C"?Result
1 (default native)YesBuilds with C, dynamic libc link
1 (default native)NoBuilds, may still link libc for net/os
0YesBuild fails: build constraints exclude all Go files
0NoPure-Go binary, statically linked

The middle row deserves attention. Even without import "C", the standard library uses CGO for parts of net, os/user, and a few other packages on Linux and macOS, unless you set CGO_ENABLED=0. So a binary built with the default settings may still depend on libc even if your own code never touches CGO. Production binaries destined for scratch containers usually set CGO_ENABLED=0 explicitly.

You can verify the linkage type after building:

The first binary links against the host's libc. Move it to a different Linux distribution with an incompatible glibc and it may refuse to start. The second binary has no dynamic dependencies, so it runs anywhere the kernel ABI is compatible.

Cross-Compilation Caveats

One of Go's headline features is easy cross-compilation: GOOS=windows GOARCH=amd64 go build produces a Windows binary from a Linux host without any extra setup. CGO breaks this in a way that surprises people.

When CGO is on, the build needs a C compiler that targets the chosen OS and architecture. The host cc usually doesn't qualify. Building a Linux ARM64 binary from a macOS x86_64 host requires a cross-compiler like aarch64-linux-gnu-gcc, plus a sysroot with the target's headers and libraries. Installing this toolchain is doable but not trivial. The native package managers on most desktops don't include cross-compilers by default.

The simplest workaround is to set CGO_ENABLED=0 for cross-builds and accept the consequences. If your code doesn't actually need CGO, this just works.

Three different binaries, three different targets, no cross-compiler needed. The catch is that any package that imports "C" (yours or a third-party dependency) breaks the build. The error message points you at the offending package, and from there you can either find a pure-Go alternative or accept that you need a real cross-toolchain.

When you do need CGO and cross-compilation together, the usual answers are:

  1. Build inside a container that runs the target OS, removing the need for a cross-compiler. Docker plus --platform makes this convenient.
  2. Use a tool like xgo or zig cc, which wrap cross-compilation in a single binary.
  3. Set up a real cross-toolchain (apt install gcc-aarch64-linux-gnu on Debian, for example) and point CC at it.

This last form works once the cross-compiler is installed, but the setup steps are platform-specific and easy to get wrong. For most teams, option 1 (build inside a container) is the path of least resistance.

A summary table of what you can cross-compile under each setting:

Build settingSame OS + archDifferent arch, same OSDifferent OS
CGO_ENABLED=0WorksWorksWorks
CGO_ENABLED=1, default CCWorksFails (no cross-cc)Fails (no cross-cc)
CGO_ENABLED=1, custom CCWorksWorks if CC targets the archWorks if CC targets the OS

The middle row is the most common source of trouble. They start a project with the default settings, everything works on the dev machine, and then a CI pipeline that builds Linux containers from a macOS host fails on the first commit that introduces CGO. The fix is either to drop CGO or to switch CI to a Linux runner that matches the deployment target.

A Minimal Hello-World

Time to put everything together in a single file that runs. This program calls C.sqrt from the standard math library to compute the square root of two and prints the result.

Save this as main.go and run it.

Walk through what's happening line by line. The preamble has two things: #cgo LDFLAGS: -lm tells the linker to pull in the math library (libm), and #include <math.h> makes sqrt visible to the C compiler. import "C" activates CGO. Inside main, C.double(2.0) converts the Go float64 literal 2.0 into a C double. C.sqrt(x) calls the C library's square-root function. float64(root) converts the result back to a Go float64 for printing.

A second example uses C.atoi to parse an integer from a string. Imagine a product-feed pipeline where each line has a numeric quantity field that arrives as text from an external supplier.

C.CString is one of the helper functions CGO provides for crossing the Go-to-C boundary. It allocates a C string (a *C.char pointing to a null-terminated byte array) from a Go string. C.atoi parses it into a C int. The defer C.free(unsafe.Pointer(text)) releases the memory that C.CString allocated, because the Go garbage collector doesn't manage C heap memory. Here it's enough to see the shape: allocate, use, free.

Both examples are complete files. go run main.go builds and executes them in one step. There's no Makefile, no separate cc invocation, no manual linking. The Go toolchain handles all of it.

If you want to inspect what cgo actually generates, run go build -work and the toolchain will print the path to a temporary directory holding the intermediate files: _cgo_gotypes.go (the generated Go bindings), main.cgo1.go (your Go code with C.sqrt(x) rewritten as a call into a synthesised wrapper), and main.cgo2.c (the wrapper that marshalls the arguments and calls the real sqrt). Reading these is the fastest way to understand how CGO actually works.

When CGO Is Worth Using

CGO is a powerful escape hatch. Use it deliberately. The cost is real: slower builds, harder cross-compilation, runtime overhead per call, fragile dependencies on system libraries. The benefit is access to the entire C ecosystem.

Three situations make CGO worth the cost: a mature C library does exactly what you need and rewriting it in Go would be a major project (SQLite, libcurl, ffmpeg, OpenCV); you need a system API that Go's standard syscall package doesn't expose (some Linux ioctls, macOS frameworks, Windows COM interfaces); or a performance-critical kernel runs a small C function on bulk data, amortising the per-call overhead.

To put numbers on it: if a CGO call costs roughly 100 nanoseconds and the C function does 10 microseconds of work per invocation, the overhead is one percent. If the C function does 50 nanoseconds of work, the overhead is 200 percent. The break-even point sits around the one-microsecond mark.

Three situations argue against CGO: a pure-Go library exists and is good enough (crypto/tls beats calling OpenSSL, pgx beats libpq bindings); you're considering CGO for "speed" without measuring (pure Go is usually within a factor of two of C); or cross-platform reach matters and the C dependency isn't available everywhere.

C libraryCommon CGO bindingPure-Go alternative
SQLitemattn/go-sqlite3modernc.org/sqlite
OpenSSLvariouscrypto/tls (stdlib)
libpqgithub.com/lib/pq (CGO mode)github.com/jackc/pgx
zlibdirect CGOcompress/zlib (stdlib)
libcurlandelf/go-curlnet/http (stdlib)

For each row, the pure-Go option is the right default for new projects. Use the C binding only when there's a specific feature you can't replicate, and document why.

A decision flow that captures the typical reasoning:

The flow biases toward pure Go because that's the right default for most projects. CGO is justified only when a Go alternative is missing and the cost is acceptable. The bottom-right branch (rewrite or accept) covers the awkward case where you genuinely need a C library that doesn't exist on every target platform; the trade-off there is between effort (port to Go) and reach (give up on some platforms).

For now, the rule of thumb is to default to pure Go and switch to CGO only when a specific concrete need outweighs the downsides.

Quiz

cgo Basics Quiz

10 quizzes