Multiple outputs

Updated

July 30, 2026

Multiple outputs

Goal: Use multi-output derivations (out, dev, man, doc, …) to keep runtime closures small while preserving headers and debug assets for dependents.

Why it matters

A single $out that ships headers, man pages, static libs, and a 2 MB binary forces every dependent to download and GC the world. Multi-output packaging is how nixpkgs stays usable on laptops and CI runners.

For fleet images, multi-out is a disk and bandwidth control: servers need out, build machines need dev, humans sometimes need man.


Theory 1 — Outputs are separate store paths

/nix/store/hash-foo-1.0       ← out (runtime)
/nix/store/hash-foo-1.0-dev   ← dev (headers, pkg-config)
/nix/store/hash-foo-1.0-man   ← man
/nix/store/hash-foo-1.0-doc   ← html docs

Each is a different path. References between them are tracked so GC stays correct: runtime out should not reference dev if you did it right.

stdenv.mkDerivation {
  pname = "foo";
  version = "1.0";
  outputs = [ "out" "dev" "man" ];
  # …
}

Default if omitted: outputs = [ "out" ];.

First output is special

The first element of outputs is the default when someone depends on the package without selecting an output. Convention: out first.

outputs = [ "out" "dev" "man" ]; # good
outputs = [ "dev" "out" ];       # surprising defaults

Theory 2 — Conventional output roles

Output Typical content Who needs it
out Binaries, shared libs, runtime data Everyone running the tool
dev Headers, .pc, static libs, cmake files Compiling dependents
lib Sometimes shared libs split from bins Rare patterns / legacy
man Man pages Humans; optional on servers
doc HTML/PDF docs Optional
info Texinfo Optional
devdoc Heavy API docs Dev machines
debug Separate debuginfo (with separateDebugInfo) Debuggers

Environment variables in the build

Var Points at
$out First output (usually runtime)
$dev Dev output path
$man Man output
$outputs List of outputs

Install into the correct root:

install -Dm755 foo $out/bin/foo
install -Dm644 foo.h $dev/include/foo.h
install -Dm644 foo.pc $dev/lib/pkgconfig/foo.pc
install -Dm644 foo.1 $man/share/man/man1/foo.1

Theory 3 — Why dependents get smaller

app depends on libfoo
  → needs libfoo.out (and its runtime closure)
  → does NOT need libfoo.dev unless building against headers

nix path-info -r on an application should not pull *-dev for every library if packaging is correct.

nix path-info -r ./result | rg -- '-dev$' || echo "no dev outputs in closure (good for runtime)"

Reference leaks

If a file in $out contains the store path of $dev (e.g. a script hardcoding include paths), Nix records a reference, and GC / closure pull dev into runtime. That defeats multi-out.

# After build, hunt for leaks (illustrative)
nix path-info -r .#libgreet | rg dev || true
strings result/bin/* | rg 'dev' || true

Theory 4 — moveToOutput and helpers

stdenv provides helpers used heavily in nixpkgs:

moveToOutput share/man "$man"
moveToOutput include "$dev"
moveToOutput lib/pkgconfig "$dev"
Hazard Result Mitigation
Headers installed to $out Runtime closure grows Move to $dev
out references $dev Forces dev into runtime closure Fix install paths / moveToOutput
Forgetting outputs but installing to $dev Empty/wrong paths Declare outputs first
pkg-config file wrong prefix Consumers cannot find libs Check .pc prefix/libdir
Static libs in out Heavier runtime Prefer static in dev

Theory 5 — Consuming multi-output packages

nativeBuildInputs = [ pkg-config ];
buildInputs = [ foo ]; # pulls foo.dev for compilation via setup hooks usually

Setup hooks from dev outputs typically put headers on the search path when foo is in buildInputs.

Selecting outputs explicitly:

foo.dev  # the dev store path attribute
foo.out
foo.man
nix build nixpkgs#openssl
nix build nixpkgs#openssl.dev -o o-dev
nix build nixpkgs#openssl.man -o o-man
du -sh result o-dev o-man

Multiple outputs and nix shell

Depending on tooling, you may need the package default output only—headers come when building other packages, not always when “shelling” into a library.


Theory 6 — separateDebugInfo

stdenv.mkDerivation {
  # …
  separateDebugInfo = true;
}

When supported, debug symbols go to a separate output so runtime stays lean while GDB can still find debuginfo via standard mechanisms.

Goal Approach
Small production closure strip + multi-out; optional separate debug
Debug a crash on a server install debug outputs temporarily; don’t ship forever
Reproducible debug paths Prefer separateDebugInfo over ad-hoc unstripped bins

Theory 7 — When single-output is fine

Package kind Multi-out?
Tiny shell script CLI Usually no
Go/Rust single static-ish binary Often single out
Shared C library used by many pkgs Yes (out+dev)
Huge docs package Split doc/man
Firmware blobs Case-by-case

Do not multi-out for fashion—multi-out when dependents would otherwise drag weight.


Worked example — toy multi-output C library + app

/* src/libgreet/greet.c */
#include "greet.h"
const char *greet_msg(void) { return "hello multi-out"; }
/* src/libgreet/greet.h */
#pragma once
const char *greet_msg(void);
/* src/greetapp/main.c */
#include <stdio.h>
#include "greet.h"
int main(void) { puts(greet_msg()); return 0; }
# pkgs/libgreet.nix
{ stdenv, lib }:
stdenv.mkDerivation {
  pname = "libgreet";
  version = "0.1.0";
  outputs = [ "out" "dev" "man" ];
  src = ../src/libgreet;

  buildPhase = ''
    runHook preBuild
    $CC -shared -fPIC greet.c -o libgreet.so
    runHook postBuild
  '';

  installPhase = ''
    runHook preInstall
    install -Dm755 libgreet.so $out/lib/libgreet.so
    install -Dm644 greet.h $dev/include/greet.h
    mkdir -p $dev/lib/pkgconfig
    cat > $dev/lib/pkgconfig/libgreet.pc <<EOF
    prefix=$out
    libdir=$out/lib
    includedir=$dev/include
    Name: libgreet
    Version: 0.1.0
    Libs: -L\''${libdir} -lgreet
    Cflags: -I\''${includedir}
    EOF
    mkdir -p $man/share/man/man3
    echo ".TH GREET 3" > $man/share/man/man3/greet.3
    runHook postInstall
  '';

  meta = {
    description = "Tiny greet library (multi-output lab)";
    license = lib.licenses.mit;
  };
}
# pkgs/greetapp.nix
{ stdenv, lib, libgreet, pkg-config }:
stdenv.mkDerivation {
  pname = "greetapp";
  version = "0.1.0";
  src = ../src/greetapp;
  nativeBuildInputs = [ pkg-config ];
  buildInputs = [ libgreet ];
  buildPhase = ''
    runHook preBuild
    $CC main.c -o greetapp $(pkg-config --cflags --libs libgreet)
    runHook postBuild
  '';
  installPhase = ''
    runHook preInstall
    install -Dm755 greetapp $out/bin/greetapp
    runHook postInstall
  '';
  meta = {
    mainProgram = "greetapp";
    license = lib.licenses.mit;
  };
}
# flake fragment
{
  packages = forAllSystems (system:
    let
      pkgs = import nixpkgs { inherit system; };
      libgreet = pkgs.callPackage ./pkgs/libgreet.nix { };
    in {
      libgreet = libgreet;
      greetapp = pkgs.callPackage ./pkgs/greetapp.nix { inherit libgreet; };
      default = self.packages.${system}.greetapp;
    });
}
nix build .#libgreet
nix build .#libgreet.dev -o result-dev
nix build .#libgreet.man -o result-man
nix build .#greetapp
./result/bin/greetapp
nix path-info -r ./result | rg libgreet

Lab — multi-step

Suggested workspace: ~/lab/nixos/packaging/multi-out

Step 1 — Inspect an existing multi-out package

nix eval nixpkgs#openssl.outputs
nix build nixpkgs#openssl -o o-out
nix build nixpkgs#openssl.dev -o o-dev
du -sh o-out o-dev

Record sizes.

Step 2 — Build libgreet with three outputs

Implement the toy library (or split your stdenv tool into lib + bin).

Step 3 — Verify separate paths

nix build .#libgreet
nix build .#libgreet.dev
nix build .#libgreet.man
nix path-info .#libgreet .#libgreet.dev .#libgreet.man

Step 4 — Build greetapp against libgreet

Confirm the app runs and its runtime closure includes libgreet out but ideally not manpages.

nix path-info -r ./result | rg 'libgreet'

Step 5 — Deliberate mistake

Install a header into $out/include and rebuild app. Compare closure size / path-info. Fix it.

Step 6 — Document policy

In NOTES.md: when you will use outputs = [ "out" "dev" ] for internal packages vs single-output scripts.

Step 7 — Optional separateDebugInfo

separateDebugInfo = true;

Build and note the extra debug output if supported on your platform.

Step 8 — pkg-config break/fix

Corrupt the .pc libdir; observe consumer failure; restore.


Exercises

  1. Compare du -sh for openssl.out vs openssl.dev on your system.
  2. Find three multi-out packages in nixpkgs used by your hosts; list their outputs.
  3. Explain in two sentences how GC roots interact with multi-out (each path separately).
  4. Design outputs for a hypothetical SDK: runtime daemon, headers, CLI, docs.
  5. Measure greetapp closure before/after the intentional header-in-out mistake.

Common gotchas

Symptom / mistake What to do
dev empty Not installing into $dev; check outputs list
Runtime needs dev Cross-reference leak; fix files that embed $dev paths
pkg-config cannot find package Ensure .pc in dev and package in buildInputs
Only result symlink Multi-out: use nix build .#pkg.dev or -o names
Broke library for dependents Test a dependent package in the same flake
Docs in out moveToOutput or install directly to $doc/$man
Wrong outputs order Put out first
Static lib only in out Move .a to dev when possible

Checkpoint

  • Can explain multi-out as multiple store paths
  • Toy (or real) package produces out + dev
  • Dependent app builds against dev, runs against out
  • Closure inspection done with nix path-info
  • One deliberate mis-install fixed
  • Policy note written for internal packages
  • Understand reference leaks

Commit

git add .
git commit -m "packaging: multiple outputs libgreet + greetapp"

Write three personal gotchas before debugging builds.