You own a Go library's go.mod and a PR adds a dependency that needs cgo. How do you decide?
answer
- you are deciding for your consumers
- it changes their build, not just your risk
- static cross-compilation is the thing lost
- separate module or build constraint
- can you take it back later
basics
~20 sDecide on what it forces on the teams importing you, not on how good the code is. Cgo in your graph means every consumer needs a C toolchain and loses CGO_ENABLED=0 static cross-compilation. Accept it only behind a separate module or a build constraint.
solid answer
~50 sFor a library, the import list is part of the contract even though it never appears in the API. A cgo dependency propagates: every team importing me now needs a C toolchain for every platform they target, `CGO_ENABLED=0` static cross-compilation stops working, and the race detector cannot see inside the C code. So the question is not "is this library good" but "am I entitled to impose that on consumers who never chose it". I look for a pure-Go alternative first; failing that, I move the feature into a separate module with its own `go.mod`, or behind a build constraint with a pure-Go fallback, so consumers opt in. If I accept it outright I write down the constraint I gave up and what would change the answer — reversing it later, once it has leaked into exported types, is a breaking change.
code
go · 5 lines//go:build cgo
package codec
func decode(b []byte) ([]byte, error) { return cDecode(b) }go deeper
Know that cgo means Go code calling C, that it needs a C compiler to build, and that many teams build with CGO_ENABLED=0 to get a static binary. That is the constraint at stake here.
Be able to explain how the cost travels: your library's imports become your consumers' imports, so cross-compilation, static linking and race-detector coverage change for people who never chose the dependency.
Show the containment options and their tradeoffs — separate module, build-constrained fallback, reimplementing the needed slice — and be honest about the cost of maintaining two implementations.
Own the decision and the record: name the constraint given up, the teams affected, what would reverse the call, and who pays for platform support and upgrades afterwards. Expect to be overruled and make that possible.
## Why a library author's dependency decision is different In an application, taking a dependency is a decision about one binary that you build, test and deploy. In a library, it is a decision about every binary anyone builds on top of you, most of them owned by people who will never read your `go.mod`. Dependencies propagate transitively, and the costs of cgo are not costs your consumers can pay locally — they change how the consumer's build works. That asymmetry is the whole question. "The library is well written" is true and irrelevant. The thing to weigh is what you are entitled to impose. ## What cgo actually costs a consumer - **A C toolchain per target.** Building requires a working C compiler and headers for the target platform. Cross-compiling from a developer machine to the platform you deploy on stops being a single `GOOS`/`GOARCH` change and becomes a cross-compiler and sysroot problem. - **`CGO_ENABLED=0` builds break.** Many teams build with cgo disabled precisely to get a static binary with no libc dependency. With cgo disabled, files guarded by the `cgo` build constraint are excluded; if the package has no pure-Go implementation behind an alternate constraint, the build simply fails. - **Slower builds and bigger CI matrices.** The C side is not covered by the Go build cache the way Go packages are, and every target platform needs its own runner or emulation. - **Weaker diagnostics.** The race detector instruments Go code, not C. A memory error on the C side is a process crash, often without a usable Go stack trace, and the usual Go profiling tools see the call as opaque time in cgo. - **A dependency the module system does not pin.** The C library version resolved at build time comes from the machine, not from `go.sum`. ## The options ladder Work down it in order, and be explicit about which rung you stopped on: 1. **A pure-Go alternative.** Often one exists and is slower or less complete. Measure whether that difference matters for your library's actual use, not in general. 2. **Do it yourself.** If you need a small slice of a large C library, reimplementing that slice in Go is sometimes an afternoon and permanently removes the question. 3. **A separate module.** Put the cgo-using feature in its own module with its own `go.mod` under a subdirectory. Consumers who want it require it; everyone else never sees it in their graph. This is the cleanest shape and the one to reach for when the feature is optional. 4. **A build constraint with a fallback.** Two implementations behind `//go:build cgo` and `//go:build !cgo`, so a `CGO_ENABLED=0` build still compiles with a pure-Go path. This preserves consumers' builds but means you maintain and test both, and you must be honest that the two paths can diverge in behaviour. 5. **Accept it in the main module.** Legitimate when the library exists *because* of the C dependency — a binding is allowed to require the thing it binds. Then say so loudly in the README and in the major version. ## What goes on the record The decision is not the comment "looks fine to me". It is a short note in the PR that a future maintainer, and the person who overrules you, can both act on: - The constraint being traded away, stated concretely: *consumers can no longer run `CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build`.* - Who is affected and how you know — the teams importing this package today, and whether you asked any of them. - The alternatives considered and why they were rejected, with numbers where numbers exist. - What would change the answer: a pure-Go implementation reaching parity, the feature becoming optional, the consumer set changing. - Who owns the follow-on cost: upgrades of the C library, new platform support, and the crash reports that will not look like Go crashes. ## The reversibility question, which people skip Ask whether you can take it back. If the dependency stays behind your own API, removing it later is an implementation change. If its types appear in your exported signatures, removing it is a breaking change for every consumer, and you have converted a dependency decision into an API decision without noticing. Keeping a third-party type out of your exported surface is cheap now and expensive later. ## Distinguish it from the neighbouring calls Not all "risky import" decisions have the same shape, and treating them alike is the mistake to avoid. A dependency using `unsafe` raises *your* correctness risk but does not change anyone's build. A dependency shelling out through `os/exec` adds an undeclared runtime prerequisite to the consumer's deployment image. Cgo is the one that changes what a consumer can compile at all — which is why, for a library, it deserves the heaviest scrutiny of the three even when the code is excellent.
- How is this decision different from accepting a dependency that uses unsafe?unsafe raises your correctness risk and stays inside your binary: consumers build exactly as before. Cgo changes what consumers can build at all — toolchain, cross-compilation, static linking, and the diagnostics they get when it fails. One is a code-quality judgement you can revisit quietly; the other is a constraint you impose on other teams' pipelines.
- What makes a cgo dependency hard to reverse once shipped?If its types or errors reach your exported API, removing it becomes a breaking change for every consumer rather than an internal refactor. Keeping third-party types out of exported signatures costs almost nothing when you add the dependency and preserves your ability to swap or drop it later without a major version.
- A consumer team says they need CGO_ENABLED=0 and you need the feature. What do you propose?Split it: the cgo path lives in its own module or behind a //go:build cgo file with a pure-Go implementation behind //go:build !cgo. Both consumers compile; the ones who want the fast path enable cgo. The cost is maintaining and testing two implementations, which you should state rather than discover.
- What belongs in the written record when you approve it?The constraint given up in concrete terms, the teams affected and whether you consulted them, the alternatives rejected and why, what would change the answer, and who owns the follow-on cost — C library upgrades, new platform support, and crash reports that will not look like Go panics. That note is what makes the decision reviewable rather than a preference.
saying these in an interview costs you the question
- Judges the dependency only on its own code quality
- Forgets that a library imposes its imports on every consumer
- Assumes cgo affects runtime only, not the consumer's build
- Lets third-party types into the exported API without noticing
- Approves without recording the constraint being traded away