skip to content

Should a library you publish depend on golang.org/x/sys/unix, and which GOOS builds do you commit to keeping green?

level: principalimportance: nice to knowfreq 20%

answer

  1. a library's dependency is everyone's dependency
  2. outside the compatibility promise
  3. one internal package, your own interface
  4. compiled is not the same as tested
  5. publish the support matrix, stub the rest

basics

~20 s

Take it only when the frozen syscall package cannot do the job, confine the import to one internal package behind your own interface, and publish a support matrix naming what CI compiles and tests. Everything else gets a loud stub.

solid answer

~50 s

The judgment has two halves. On the dependency: `golang.org/x/sys/unix` is maintained by the Go team, has no transitive dependencies and is far safer than hand-rolling syscall numbers, but it lands in every consumer's module graph, sits outside the Go 1 compatibility promise, and drags your minimum Go version forward as it drops old releases. So I take it only when the frozen `syscall` package lacks what I need, keep the import in one internal package behind my own small interface, and consider splitting the platform-specific half into its own module. On platforms: every GOOS file is a promise somebody must keep green, so I name the targets CI cross-compiles and the smaller set it runs tests on, and give everything else a stub returning an error wrapping `errors.ErrUnsupported` rather than an undefined symbol. Upgrades of `x/sys` go through the same matrix, deliberately, not on an auto-bump.

code

go · 3 lines
go
func newWatcher(root string) (*watcher, error) {
	return nil, fmt.Errorf("watching %s: %w", root, errors.ErrUnsupported)
}

go deeper

for a junior

Know that adding a module to a library's go.mod adds it to everyone who imports that library, and that golang.org/x/sys is a normal versioned dependency rather than part of the standard library.

for a middle

Be able to argue the trade concretely: what the frozen syscall package still covers, what x/sys adds, and why the import belongs in one internal package behind your own interface.

for a senior

Show the operational half — a CI matrix that cross-compiles every platform file, tests on the ones you promise, and a fallback returning an unsupported error instead of an undefined symbol.

for a principal

Own it as policy: the published support matrix, the version floor and how upgrades are reviewed, whether the platform code becomes its own module, and what survives if a dependency reviewer says no.

## Two decisions, one owner A library that reaches past the standard library into the kernel forces its author to make two calls that other people will live with: *whose code goes in the module graph*, and *which platforms are supported*. Both are reversible only at the cost of a breaking release, which is why they belong to whoever owns the module rather than to whoever happens to be writing the feature. ## Decision one: does x/sys go in go.mod Start by refusing the false binary. The options are not "x/sys or nothing": - **Use the frozen `syscall` package.** If the call and the constants you need already exist there, and your targets are covered, this is free: standard library, Go 1 compatibility promise, no module line. - **Take `golang.org/x/sys`.** Maintained by the Go team, regenerated from platform headers, no transitive dependencies, pure Go. This is the only supported way to reach anything the frozen package never gained. - **Hand-write the trap.** Raw syscall numbers and struct layouts differ per architecture and drift per kernel. Almost never the right answer; treat a proposal to do this as a red flag. - **Do without.** Sometimes the feature is not worth a platform-specific implementation at all, and a portable approximation through `os` is the honest engineering call. When `x/sys` wins, the costs to name out loud are: it is outside the Go 1 promise, so a bump is a change to review rather than a formality; it appears in every downstream module graph, licence review and vulnerability scan; and it supports only recent Go releases, so upgrading it can raise your module's Go floor and therefore your consumers'. Containment blunts most of that. Put the import in exactly one internal package that exposes your own interface — `type Watcher interface { Events() <-chan Event; Close() error }` — so the dependency is an implementation detail rather than part of your API. Nothing in your exported signatures should mention a type from `x/sys`, because the moment it does, your consumers' compilation depends on their version of it matching yours. The stronger lever, for a widely imported library, is **module boundaries**: publish the platform-specific implementation as its own module path so that consumers who only want the portable core never acquire the dependency at all. It costs you a second release process; it buys everyone downstream a smaller graph. ## Decision two: which platforms you keep green A per-GOOS file is not free. Each one is code somebody must compile, keep in sync with the shared signature, and — the expensive part — actually test. The useful discipline is to write down three tiers and put them in the README: 1. **Supported and tested.** CI runs the test suite on this target. Usually one or two: the platform production runs on. 2. **Compiled only.** CI cross-compiles it, so signatures and constants are checked, but nothing runs. Developer laptops usually live here — cross-compiling pure Go costs seconds and catches the whole class of "that file has not been type-checked in a year". 3. **Unsupported.** A fallback file whose entry point returns an error wrapping `errors.ErrUnsupported`. This is materially better than leaving the symbol undefined: the build succeeds, the failure is a value the caller can test for with `errors.Is`, and a consumer discovers it at their build time rather than in production. Be suspicious of accepting a contributed platform file for a target CI cannot build. It reads as generosity and behaves as a liability: it will drift, and the first bug report will be from someone who assumed "there is a file for it" meant "it works". ## Version policy Because `x/sys` is outside the compatibility promise, treat its version as a decision. Keep a deliberate floor in `go.mod` rather than chasing the newest tag; when an automated bump arrives, run the full cross-compile matrix, because the change most likely to hurt you is a regenerated struct layout or a constant whose value moved on one platform. A `x/sys` upgrade that changes behaviour is a behaviour change in *your* library, and your release notes are the only place a consumer will learn that. ## Being overruled A platform or dependency reviewer can reject the dependency outright — some organisations allow only the standard library plus an audited list. Have the fallback ready rather than arguing: the portable path through `os` with the degradation documented, or the split module so the policy only blocks the optional half. Knowing in advance which half of the feature survives the refusal is the part that separates a decision from a preference.

  • How do you keep the x/sys dependency from becoming part of your library's public API?
    Never name a type from it in an exported signature. Define your own interface and your own error and event types, and put the import in a single internal package behind them. Then a consumer's build cannot depend on their `x/sys` version matching yours, and replacing the implementation — or dropping to the standard library on some platform — is a patch release rather than a breaking one.
  • A contributor sends a well-written implementation for a platform your CI cannot build. Do you merge it?
    Not as a supported platform. Merging it implies a promise the project cannot keep: the file is never compiled, its signature drifts, and users read its existence as "this works here". Either find a way to at least cross-compile it in CI — cheap for pure Go — and label it compile-only, or keep it out and point people at a fork. Say which tier it is in the README.
  • An automated dependency bot raises a pull request upgrading golang.org/x/sys. What has to happen before it merges?
    The full cross-compile matrix, plus the tests on every tested tier. The risk in that package is not a new API — it is a regenerated struct layout or a constant whose value moved on one platform, which compiles cleanly and misbehaves at runtime. Also check whether the new version raised its minimum Go release, because that floor propagates to every consumer of your library.
  • Your dependency reviewer refuses any module outside the standard library. What is your fallback?
    Split the feature. Keep the portable core, implemented on `os` and the frozen `syscall` package, in the main module, and either drop the platform-specific optimisation or publish it as a separate optional module that only teams with the appetite import. Document the degradation explicitly, so the constrained build's behaviour is a stated contract rather than something users discover.

saying these in an interview costs you the question

  • Adds the dependency because it is convenient, without weighing consumers
  • Exposes an x/sys type in the library's exported API
  • Ships a platform file CI never compiles and calls it supported
  • Auto-merges x/sys upgrades without the cross-compile matrix
  • Leaves unsupported platforms as an undefined symbol rather than a clear error
  • Assumes an x repository carries the Go 1 compatibility promise