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:
Argument
What it is
config
The final merged configuration — the result of all modules combined. A thunk (unevaluated promise) when your function runs.
options
The final merged option declarations — the schema of all options from all modules. Also a thunk.
lib
Utility functions from nixpkgs (mkOption, mkIf, types, etc.)
pkgs
The 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:
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:
Collect modules. Recursively follow all imports, deduplicate, call each module function with the shared argument set. Result: a flat list of { options, config } attrsets.
Build the fixed-point. Define mergedConfig and mergedOptions in terms of each other — neither is evaluated yet (they’re thunks):
This is safe because Nix is lazy. Neither side evaluates until something forces a specific attribute path.
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.
Merge option definitions. For each option, collect all config definitions across all modules, then:
Evaluate lib.mkIf conditions (discard branches where condition is false)
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 valuesin{ # 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:
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:
Function
Priority
Use case
lib.mkForce
50
Override everything — “I insist”
plain assignment
100
Normal user configuration
lib.mkOverride 900
900
Company/team defaults that yield to user config
lib.mkDefault
1000
Suggested value — “use this if nothing else is set”
When two definitions share the same priority, the option’s type determines what happens:
Type
Merge behavior
types.str, types.bool, types.int, types.package
Error — “conflicting definition values”
types.listOf T
Concatenate all definitions
types.attrsOf T
Recursive merge (each key merged independently)
types.nullOr T
Same 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.enablebefore 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:
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 evalModules → mergeConfig → 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:
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:
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} ''; };}
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:
Filters by condition. Evaluates all mkIf wrappers, discards definitions where the condition is false.
Extracts priority. Unwraps mkOverride tags to get { priority, value } pairs. Bare values get priority 100.
Selects winners. Finds the minimum priority number. Keeps only definitions at that priority.
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
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