NixOS Module System

Prerequisites

The NixOS module system is the infrastructure that merges hundreds of configuration files into a single, type-checked system configuration. When you run nixos-rebuild switch, NixOS reads your configuration.nix (and everything it imports), merges all those fragments together, resolves conflicts by priority, type-checks every value, and produces the final system description that gets built and activated.

The module system builds on two properties of the Nix Language: lazy evaluation (expressions aren’t computed until needed) and fixed-point computation (a value can refer to itself without infinite loops, as long as there’s no circular data dependency).

What a module is

A NixOS module is a function that takes system-wide context and returns a fragment of configuration:

{ config, options, lib, pkgs, ... }:
{
  imports = [ ... ];   # other modules to include
  options = { ... };   # declare new options (the schema)
  config  = { ... };   # define values for options (the data)
}

The arguments arrive from the module system itself:

ArgumentWhat it is
configThe final merged configuration — the result of all modules combined. A thunk (unevaluated promise) when your function runs.
optionsThe final merged option declarations — the schema of all options from all modules. Also a thunk.
libUtility functions from nixpkgs (mkOption, mkIf, types, etc.)
pkgsThe nixpkgs package set (the result of import nixpkgs { system = ...; })
...Absorbs any extra arguments passed via specialArgs (see below)

The critical detail: config and options are the final merged values, not intermediate ones. Your module sees the complete result of merging all modules — including itself. This is the circular self-reference that the fixed-point trick resolves.

How evalModules resolves the circularity

The evalModules pipeline: collect all modules, build the fixed-point of config and options, then merge definitions per-option using priority and type-specific rules.

Your entry point is nixpkgs.lib.nixosSystem — the function you call in flake.nix to define a machine:

# flake.nix (simplified)
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
  system = "x86_64-linux";
  modules = [ ./configuration.nix ./hardware-configuration.nix ];
};

nixosSystem is a thin wrapper. Internally it calls lib.evalModules — the single function that implements the entire module system. evalModules takes your modules list, adds the ~700 standard NixOS modules from nixpkgs (which declare all the services.*, networking.*, boot.* options), merges everything, and returns the final { config, options } attrset.

The source lives in nixpkgs/lib/modules.nix (~700 lines, but the core loop is ~100). The algorithm:

  1. Collect modules. Recursively follow all imports, deduplicate, call each module function with the shared argument set. Result: a flat list of { options, config } attrsets.

  2. Build the fixed-point. Define mergedConfig and mergedOptions in terms of each other — neither is evaluated yet (they’re thunks):

    let
      mergedConfig  = mergeDefinitions allModules mergedOptions;
      mergedOptions = mergeDeclarations allModules mergedConfig;
    in { config = mergedConfig; options = mergedOptions; }

    This is safe because Nix is lazy. Neither side evaluates until something forces a specific attribute path.

  3. Merge option declarations. For each option path (e.g., services.openssh.enable), exactly one module may declare it with lib.mkOption (the function that says “this option exists and has this type” — see the next section for a full example). Multiple declarations of the same option path is an error.

  4. Merge option definitions. For each option, collect all config definitions across all modules, then:

    • Evaluate lib.mkIf conditions (discard branches where condition is false)
    • Strip lib.mkOverride wrappers, extract priority numbers
    • Keep only definitions at the highest priority (lowest number wins)
    • Apply the option type’s merge function to the surviving definitions

Option declaration vs option definition

These are two fundamentally different operations that happen to live in the same module file. Confusing them is the most common NixOS beginner mistake.

Option declaration (options.*) defines the schema — “this option exists, it has this type, this default, and this description.” It’s like defining a column in a database table. It doesn’t set the value; it says what values are legal.

Option definition (config.*) sets the value — “this option should be true” or “this list should contain these packages.” It’s like inserting a row.

A complete example — a module that declares a new option and defines other existing options based on it:

# modules/myapp.nix
{ config, lib, pkgs, ... }:
 
let
  cfg = config.services.myapp;  # shorthand for reading our own option values
in
{
  # DECLARATION — defines the schema for services.myapp.*
  options.services.myapp = {
 
    enable = lib.mkOption {
      type        = lib.types.bool;
      default     = false;
      description = "Whether to run myapp as a systemd service.";
    };
 
    port = lib.mkOption {
      type        = lib.types.port;   # integer 1–65535
      default     = 8080;
      description = "Port myapp listens on.";
    };
  };
 
  # DEFINITION — sets values for options declared elsewhere
  config = lib.mkIf cfg.enable {
 
    # This option was declared by the systemd module in nixpkgs:
    systemd.services.myapp = {
      wantedBy = [ "multi-user.target" ];
      execStart = "${pkgs.myapp}/bin/myapp --port ${toString cfg.port}";
    };
 
    # This option was declared by the networking module:
    networking.firewall.allowedTCPPorts = [ cfg.port ];
  };
}

A user of this module only writes definitions — they never declare options, because the module above already did that:

# configuration.nix — this file has NO options section
{
  services.myapp.enable = true;
  services.myapp.port   = 9090;
}

The reader should understand: one module declares the option, many modules can define values for it. The NixOS options search (https://search.nixos.org/options) shows you what’s been declared. Your configuration.nix provides the definitions.

The shorthand syntax

The configuration.nix above has no explicit options or config key. The module system detects this: if the returned attrset contains neither an options key nor a config key at the top level, it wraps the entire attrset in config automatically:

# What you write:                         What evalModules sees:
{ services.myapp.enable = true; }        { config.services.myapp.enable = true; }

This is why most user configuration files “just work” without writing config. everywhere.

Don't mix shorthand with explicit options

The moment you write any options.* at the top level, you must also use config.* explicitly. Otherwise, definitions outside the config key are silently ignored:

# WRONG — services.nginx.enable is silently lost:
{ options.myapp.enable = lib.mkOption { ... };
  services.nginx.enable = true;           # ← not inside config.*!
}
 
# CORRECT:
{ options.myapp.enable = lib.mkOption { ... };
  config.services.nginx.enable = true;    # ← explicit config key
}

The logic: because options exists at the top level, the module system takes the explicit path and looks for a config key. Anything else at the top level is ignored.

Priority and merging

When two modules define the same option, priority determines which wins. Lower number = higher priority:

FunctionPriorityUse case
lib.mkForce50Override everything — “I insist”
plain assignment100Normal user configuration
lib.mkOverride 900900Company/team defaults that yield to user config
lib.mkDefault1000Suggested value — “use this if nothing else is set”

These are all wrappers around lib.mkOverride:

# lib/modules.nix
mkOverride = priority: content: { _type = "override"; inherit priority content; };
mkForce    = mkOverride 50;
mkDefault  = mkOverride 1000;
# A plain `foo = "bar"` is implicitly mkOverride 100 "bar"

Same-priority conflicts

When two definitions share the same priority, the option’s type determines what happens:

TypeMerge behavior
types.str, types.bool, types.int, types.packageError — “conflicting definition values”
types.listOf TConcatenate all definitions
types.attrsOf TRecursive merge (each key merged independently)
types.nullOr TSame as inner type

The error message names the exact option, the files that define it, and suggests using mkForce or mkDefault:

error: The option `services.openssh.settings.PermitRootLogin' has conflicting
       definition values:
  - In `/etc/nixos/hardware.nix': "prohibit-password"
  - In `/etc/nixos/configuration.nix': "no"

lib.mkIf vs plain if — avoiding infinite recursion

These look equivalent but behave differently:

# Plain if — DANGEROUS:
config = if config.services.myapp.enable then { ... } else {};
 
# lib.mkIf — SAFE:
config = lib.mkIf config.services.myapp.enable { ... };

The plain if forces Nix to evaluate config.services.myapp.enable before it can determine the structure of this module’s config attrset. But config is the fixed-point being computed — its structure depends on all modules’ contributions, including this one. This creates a genuine dependency cycle:

"What keys does this module's config have?"
  → "Depends on the if-condition"
  → "Condition reads config.services.myapp.enable"
  → "That requires merging all config definitions"
  → "Which requires knowing this module's config keys"
  → ∞

lib.mkIf breaks the cycle by wrapping the condition in a tagged value:

mkIf = condition: content: { _type = "if"; inherit condition content; };

The module system first collects the structural shape of all config attrsets — keys and option paths — without evaluating any mkIf conditions. Only after the full structure is known does it evaluate each condition to decide whether to include the content. By that point, config.services.myapp.enable is fully resolved.

The error when you get this wrong:

error: infinite recursion encountered
       at /nix/store/.../lib/modules.nix:123:45

Often with a long --show-trace that spirals through evalModulesmergeConfig → your module → evalModules again.

specialArgs — injecting values from outside

Standard module arguments (config, options, lib, pkgs) come from within the fixed-point. To pass external values — flake inputs, a self reference, custom variables — use specialArgs:

# flake.nix
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
  modules = [ ./configuration.nix ];
  specialArgs = {
    inputs = inputs;
    vars   = { username = "edmondo"; };
  };
};

specialArgs are merged into every module function’s argument set before the fixed-point is computed. They’re static values from outside the system — not subject to the lazy self-referential evaluation:

# configuration.nix — destructure specialArgs directly
{ config, lib, pkgs, inputs, vars, ... }:
{
  networking.hostName = "${vars.username}-machine";
  nix.registry.nixpkgs.flake = inputs.nixpkgs;
}

Don't forget the ...

If your module function signature doesn’t include ... and there are specialArgs keys you don’t name, you get:

error: anonymous function called with unexpected argument 'inputs'

There’s a second mechanism, _module.args, which lives inside the fixed-point (one module can define arguments that others consume). It’s more powerful but can cause infinite recursion if the value depends on config. Prefer specialArgs for flake inputs and static data.

Submodule types — structured options

lib.types.submodule lets you declare options that take structured records (like a typed struct). Each record is type-checked at evaluation time.

{ lib, config, ... }:
let
  fileType = lib.types.submodule {
    options = {
      path  = lib.mkOption { type = lib.types.str; };
      mode  = lib.mkOption { type = lib.types.str; default = "0644"; };
      owner = lib.mkOption { type = lib.types.str; default = "root"; };
    };
  };
in {
  options.myapp.files = lib.mkOption {
    type    = lib.types.listOf fileType;
    default = [];
  };
 
  # system.activationScripts runs shell commands when the system is activated
  # (i.e., during `nixos-rebuild switch`). lib.stringAfter controls ordering —
  # this script runs after user accounts are created.
  config = lib.mkIf (config.myapp.files != []) {
    system.activationScripts.myappFiles = lib.stringAfter ["users"] ''
      ${lib.concatMapStrings (f: ''
        install -m ${f.mode} -o ${f.owner} /dev/null ${f.path}
      '') config.myapp.files}
    '';
  };
}

Usage:

myapp.files = [
  { path = "/etc/myapp/config.toml"; mode = "0600"; owner = "myapp"; }
  { path = "/etc/myapp/data.json"; }  # mode and owner get defaults
];

When the module system processes this, it runs a nested evalModules for each list element — checking types, applying defaults, and reporting errors with the exact list index and field name:

error: A definition for option `myapp.files.[definition 1-entry 1].mode'
       is not of type `string'. Definition values:
       - In `/etc/nixos/configuration.nix': 644

The merge algorithm — mergeDefinitions in detail

The core function in nixpkgs/lib/modules.nix is mergeDefinitions. For each option path, it:

  1. Filters by condition. Evaluates all mkIf wrappers, discards definitions where the condition is false.
  2. Extracts priority. Unwraps mkOverride tags to get { priority, value } pairs. Bare values get priority 100.
  3. Selects winners. Finds the minimum priority number. Keeps only definitions at that priority.
  4. Merges by type. Calls the option type’s merge function on the surviving values:
    • types.str with 2+ values → error
    • types.listOf with 2+ values → concatenate
    • types.attrsOf → recursive merge per-key
  5. Applies apply. If the option declaration has an apply function, transforms the merged result.

See also

  • Nix Evaluation Model — thunks, forcing, and the lib.fix function that underpins evalModules
  • Nix Language — syntax for attribute sets, functions, and let/in bindings
  • Nix Flakes — how nixosSystem connects to the flake outputs schema
  • nixpkgs — where lib.evalModules and all module types are defined