Chapter 9: The Nix Language
Before you can understand or modify your NixOS configuration, you need to be able to read and write the Nix expression language. This chapter covers everything you will encounter in this repository.
Nix is a purely functional language. There are no variables that change, no loops, and no side effects. You write expressions that evaluate to values. That is all.
Basic Data Types
Strings
"hello world"
Multi-line strings use double single-quotes:
''
This is a multi-line string.
Leading whitespace is stripped intelligently.
''
String interpolation uses ${}:
"Hello, ${name}"
You will see this in your config. For example, in a profile module:
inputs.claude-code-nix.packages.${pkgs.stdenv.hostPlatform.system}.claude-code
Here ${pkgs.stdenv.hostPlatform.system} evaluates to something like "x86_64-linux" and gets inserted into the attribute path.
Numbers
42
3.14
You will rarely use numbers directly. One place they appear is in byte values like nix-settings.nix:
min-free = 5368709120; # 5GB in bytes
Booleans
true
false
These appear constantly:
services.xserver.enable = true;
hardware.nvidia.open = true;
Null
null
Represents the absence of a value.
Paths
./hardware.nix
../common
../../modules/desktop/plasma.nix
Paths are a first-class type in Nix. They are not strings. When Nix evaluates a path, it resolves it relative to the file containing it. This is why your imports work with relative paths.
Lists
[ "nvme" "xhci_pci" "thunderbolt" ]
Lists are ordered. Items are separated by spaces, not commas. From your hardware-configuration.nix:
boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "thunderbolt" "usbhid" "usb_storage" "sd_mod" ];
Attribute Sets (attrsets)
The most important data type in Nix. An attrset is a collection of key-value pairs:
{
name = "electra-dgpu";
cores = 6;
enable = true;
}
Attrsets are delimited by { } and each assignment ends with ;. This is the structure that makes up your entire NixOS configuration.
Nested attrsets
You can nest them:
{
hardware = {
bluetooth = {
enable = true;
powerOnBoot = true;
};
};
}
Or use dot notation as a shorthand:
{
hardware.bluetooth.enable = true;
hardware.bluetooth.powerOnBoot = true;
}
Both forms are identical. Your configuration mixes both styles freely. For example, hosts/common/default.nix uses dot notation:
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
networking.networkmanager.enable = true;
While modules/hardware/bluetooth.nix uses the nested form:
hardware.bluetooth = {
enable = true;
powerOnBoot = true;
settings = {
General = {
Experimental = true;
};
};
};
Recursive attrsets
Prefixing with rec lets attributes reference each other:
rec {
x = 1;
y = x + 1; # y = 2
}
You will not see this in your configuration, but you should know it exists.
Functions
Nix functions take exactly one argument. They look like this:
x: x + 1
The part before the colon is the argument. The part after is the body.
Attrset destructuring
Most functions in NixOS configuration take an attrset argument and destructure it:
{ config, pkgs, ... }:
This means: "I accept an attrset. Bind its config attribute to the name config, its pkgs attribute to pkgs, and ignore everything else (...)."
Every single .nix file in your configuration is a function with this shape. For example, modules/common/system.nix:
{ config, pkgs, ... }:
{
# ... configuration ...
}
The function receives the attrset, and returns an attrset (the configuration). NixOS calls this function, passing in config, pkgs, and many other things.
The ... (ellipsis)
The ... means "there might be more attributes in this attrset that I do not care about." Without it, Nix would throw an error if it passed extra attributes.
Extra arguments: inputs
Some of your files accept inputs:
{ config, pkgs, inputs, ... }:
This works because inputs is passed through specialArgs in your flake.nix. We will cover this in detail in Chapter 13.
Key Language Constructs
with expression
with pkgs; [ firefox git wget ]
This brings all attributes of pkgs into scope so you do not have to write pkgs.firefox, pkgs.git, pkgs.wget. You see this in modules/common/system.nix:
environment.systemPackages = with pkgs; [
btop
htop
git
wget
# ...
];
Without with, you would write:
environment.systemPackages = [
pkgs.btop
pkgs.htop
pkgs.git
pkgs.wget
];
inherit keyword
inherit copies a name from the enclosing scope into an attrset:
let
x = 1;
y = 2;
in {
inherit x y;
# equivalent to: x = x; y = y;
}
You see this in flake.nix:
specialArgs = { inherit inputs; };
# equivalent to: specialArgs = { inputs = inputs; };
let ... in expression
Defines local bindings:
let
name = "electra-dgpu";
greeting = "Hello, ${name}";
in
greeting
# evaluates to "Hello, electra-dgpu"
You see this in modules/services/nixos-auto-update.nix:
let
cfg = config.services.nixos-auto-update;
updateScript = pkgs.writeShellScript "nixos-auto-update" ''
...
'';
in
{
options.services.nixos-auto-update = { ... };
config = mkIf cfg.enable { ... };
}
let bindings are only visible within the in block.
if ... then ... else
if x > 0 then "positive" else "non-positive"
Note: if is an expression, not a statement. It always produces a value. Both branches are required.
import
import reads a Nix file and evaluates it:
import ./some-file.nix
If the file contains a function (which your NixOS modules do), import returns that function. NixOS then calls it with the appropriate arguments.
@ pattern
You will see this in flake.nix:
{ self, nixpkgs, nixpkgs-stable, ... }@inputs:
This destructures the argument and binds the entire attrset to inputs. So you can use nixpkgs directly and also pass the whole inputs set to other things.
The lib Library
NixOS provides a large standard library called lib. You will see functions from it throughout your config. Key ones:
lib.mkForce
Overrides a value that was set elsewhere, forcing it to take priority:
security.pam.services.login.fprintAuth = lib.mkForce true;
lib.mkDefault
Sets a value with low priority, so other modules can override it:
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
lib.mkIf
Conditionally includes configuration:
config = mkIf cfg.enable {
# This configuration only applies when cfg.enable is true
};
lib.mkEnableOption
Creates a boolean option that defaults to false:
enable = mkEnableOption "automatic NixOS updates";
lib.mkOption
Creates an option with a type, default, and description:
flakePath = mkOption {
type = types.str;
default = config.nixosConfig.flakeRepo;
description = "Path to the NixOS configuration flake";
};
These last three (mkIf, mkEnableOption, mkOption) are used when writing modules with custom options, which we cover in Chapter 12.
Reading Your Configuration Files
Now you should be able to read any file in your configuration. Let us walk through hosts/common/default.nix as a complete example:
{ config, pkgs, inputs, ... }: # Function that takes config, pkgs, inputs
{ # Returns an attrset (the configuration)
imports = [ # List of other modules to include
inputs.disko.nixosModules.disko # Attribute path into the disko flake input
./nix-settings.nix # Relative path to a file
./locale.nix
../../modules/common/default-config.nix
../../modules/common/sops-host.nix
../../modules/maintenance/garbage-collection.nix
../../modules/maintenance/btrfs-maintenance.nix
];
boot.loader.systemd-boot.enable = true; # Dot notation to set nested values
boot.loader.efi.canTouchEfiVariables = true;
networking.networkmanager = { # Nested attrset form
enable = true;
wifi.powersave = true; # Dot notation inside a nested block
settings = {
connectivity = {
enabled = false; # Disable captive portal detection
};
};
};
}
Every concept used here was covered in this chapter. The entire file is a function that takes some arguments and returns an attrset of configuration. Notice how it mixes dot notation (boot.loader.systemd-boot.enable) with nested attrsets (networking.networkmanager = { ... }).