Rok Garbas

Learn the Nix Language in 15 Minutes

I keep watching people copy-paste NixOS configurations without understanding them. They grab a snippet from someone’s dotfiles, tweak it until it works, and move on. Then something breaks and they’re stuck because they don’t know what inherit does, why rec exists, or how functions with curly braces differ from functions with a colon. The fix: learn the language. Not nixpkgs, not the ecosystem. Just the language. About 15 minutes.

This post covers only Nix syntax and constructs. After reading it, .nix files should stop looking like alien hieroglyphs. What about nixpkgs, the module system, all those helper functions in lib? Different topic. Later posts.

The language is surprisingly small. Maybe a dozen concepts total.

Everything is an Expression

Nix is a functional language. No statements, only expressions. Everything evaluates to a value.

# This is an expression that evaluates to 42
40 + 2

# This is an expression that evaluates to a string
"hello " + "world"

# This is an expression that evaluates to a boolean
1 < 2

No return statements. The last expression is the result. Coming from Python or JavaScript, this felt wrong to me at first. You get used to it fast.

One thing that trips people up: expressions themselves don’t use semicolons. You will see semicolons in Nix, though. They show up inside attribute sets (after each key-value pair) and inside let bindings (after each binding). Semicolons separate bindings. They don’t terminate expressions. Once you see that distinction, the syntax clicks.

Attribute Sets: The Building Block

If you learn one thing about Nix, learn attribute sets. They’re everywhere. Every NixOS configuration, every package definition, every flake. If you squint, they look like JSON objects. Curly braces, key-value pairs, nesting. The main differences: = instead of :, semicolons after each pair. Unlike JSON though, attribute sets can reference themselves recursively and hold functions or any other Nix expression.

{
  name = "hello";
  version = "2.12";
  meta = {
    description = "A program that produces a familiar greeting";
    license = "gpl3";
  };
}

Nothing surprising here. Nesting works like you’d guess.

You access values with dot notation:

let
  pkg = {
    name = "hello";
    version = "2.12";
  };
in
  pkg.name  # evaluates to "hello"

Or with the or keyword for defaults:

pkg.homepage or "https://example.com"  # returns default if homepage doesn't exist

Recursive Attribute Sets

Sometimes you need attributes to reference each other. Use rec:

rec {
  name = "hello";
  fullName = name + "-world";  # can reference 'name' because of rec
}

Without rec, referencing name inside the same set would fail. Be careful with this, though. Once an attribute set grows large, rec makes it hard to tell where a name comes from. Is name defined in this attribute set or somewhere outside? In a small example, obvious. In a 200-line config, a real source of confusion. Same issue as with. I reach for let bindings instead whenever I can.

Functions: Always Single Argument

This tripped me up for a while. Nix functions take exactly one argument. Always. Sounds limiting, but the workarounds are clean.

# A simple function
x: x + 1

# Calling it
(x: x + 1) 5  # evaluates to 6

No parentheses around parameters. No function keyword. Just argument: body.

Multiple Arguments via Currying

Want multiple arguments? Chain functions:

# A function that takes two arguments
x: y: x + y

# Calling it
(x: y: x + y) 3 5

# Here's how Nix evaluates this, step by step:
#   (x: y: x + y) 3 5
#   (y: 3 + y) 5          # x gets replaced with 3
#   3 + 5                  # y gets replaced with 5
#   8

Each step replaces one argument. The first call returns a new function with x filled in. The second call fills in y. That’s currying. Weird? Maybe. But after reading a handful of real Nix files, currying stops looking strange.

The Pattern You’ll Actually See

Most Nix code uses attribute set arguments with defaults:

{ name, version, src, buildInputs ? [] }:

{
  inherit name version src;
  buildInputs = buildInputs ++ [ someDependency ];
}

This function takes an attribute set with specific keys. The ? syntax provides defaults. If buildInputs isn’t passed, it defaults to an empty list.

You’ll see this pattern in every derivation, every NixOS module, every flake. Get comfortable with it.

Let Bindings: Local Variables

Need to name intermediate values? Use let...in:

let
  x = 1;
  y = 2;
  sum = x + y;
in
  sum * 2  # evaluates to 6

Everything between let and in defines bindings. The expression after in is the result, and it can use those bindings.

Bindings can reference earlier bindings:

let
  a = 1;
  b = a + 1;  # b is 2
  c = b + 1;  # c is 3
in
  c  # evaluates to 3

This is how you build up complex configurations piece by piece without repeating yourself.

with and inherit: Reducing Noise

The with Keyword

Typing pkgs. before everything gets old fast:

# Without with
{
  buildInputs = [ pkgs.python pkgs.nodejs pkgs.git ];
}

# With with - scoped right before the list
{
  buildInputs = with pkgs; [ python nodejs git ];
}

The with expression brings all attributes of a set into scope. Convenient, but it carries a real caveat: it can silently shadow things. Say you already have a python variable in scope. You use with pkgs;, and now pkgs.python wins. Your original python disappears without warning. That’s why I only use with right before a list of packages, where the scope is tight and the intent is obvious. Wrapping a whole attribute set with with pkgs; is asking for trouble.

The inherit Keyword

When you want to copy attributes from one set to another with the same name:

let
  name = "hello";
  version = "2.12";
in
{
  # Instead of writing name = name; version = version;
  inherit name version;
  src = ./src;
}

You can also inherit from a specific set:

{
  inherit (pkgs) python nodejs git;
  # Same as: python = pkgs.python; nodejs = pkgs.nodejs; git = pkgs.git;
}

Both keywords exist to reduce boilerplate. You’ll see them everywhere.

Lazy Evaluation

This one bit me more than once. Nix is lazy. Nothing gets evaluated until something needs it:

let
  x = throw "error!";  # This doesn't throw yet
  y = 1;
in
  y  # Evaluates to 1, error is never triggered

The error only fires if you actually use x. Attribute sets work the same way:

let
  config = {
    working = "fine";
    broken = throw "this is wrong!";
  };
in
  config.working  # evaluates to "fine", broken is never touched

Nix won’t evaluate broken because nothing asks for it. This laziness is what makes nixpkgs possible. One repository describes over 100,000 packages in a single giant attribute set. Build one package and Nix touches only what that package requires. The other 99,999 sit there inert. You can describe the entire world of software in one place and still build a single thing out of it efficiently.

The tradeoff is that bugs hide. I had a typo in one of my NixOS modules that survived for months. Nobody caught it because nothing ever triggered that code path. Perfectly valid Nix, just never evaluated.

String Interpolation

Use ${} for interpolation inside strings:

let
  name = "world";
in
  "hello ${name}"  # evaluates to "hello world"

But watch out. A plain $ followed by something other than { is just a literal dollar sign:

"costs $5"       # literal string: costs $5
"costs ${five}"  # interpolation: costs <value of five>

In shell scripts inside Nix, this creates a common trap:

''
  # This is Nix interpolation
  echo ${pkgs.hello}/bin/hello

  # This is a shell variable (not Nix!)
  for f in $files; do
    echo $f
  done
''

If you need a literal ${ in a string (like for shell variables), escape it: ''\${.

String interpolation in Nix is powerful enough that people have built entire tools around it. Styx is a static site generator written purely in Nix, using string interpolation to template HTML. That’s how far you can push it.

Paths vs Strings

Nix distinguishes between paths and strings:

./src           # This is a path (no quotes)
"./src"         # This is a string
/absolute/path  # This is a path
"/absolute/path"  # This is a string

Paths are special. Nix copies them to the Nix store. This matters for reproducibility:

{
  src = ./src;        # Copies ./src to /nix/store/... and uses that
  src = "./src";      # Just a string, won't work as a source
}

When you see paths without quotes in Nix code, that’s intentional. The file gets hashed and stored, making the build reproducible.

Try It Yourself

The best way to internalize all of this? Play with it. Nix gives you two ways.

nix repl is your sandbox. Fire it up and start typing:

$ nix repl
nix-repl> 1 + 2
3

nix-repl> { name = "hello"; version = "1.0"; }
{ name = "hello"; version = "1.0"; }

nix-repl> let x = 5; in x * x
25

nix-repl> map (x: x * 2) [ 1 2 3 ]
[ 2 4 6 ]

nix-repl> :q

Instant feedback. No compilation, no setup.

For one-off checks, nix eval does the job without dropping into a REPL:

$ nix eval --expr '1 + 2'
3

$ nix eval --expr '{ name = "hello"; }.name'
"hello"

$ nix eval --expr 'let add = x: y: x + y; in add 3 5'
8

I keep a REPL open whenever I’m reading unfamiliar Nix. See something weird? Paste it in, poke at it. Beats reading docs every time.

What’s Next

That’s the whole language. Seriously. I skipped some edge cases and builtins, but the concepts above cover 95% of what you’ll encounter reading real Nix code.

After I learned this, I opened my own configuration.nix and read it top to bottom. First time I understood every line. Then I opened a random package in nixpkgs and traced through it. Taught me more than any tutorial.

From here? NixOS if you want to configure a whole machine. Home Manager for dotfiles and user packages. Flakes for project structure. Same language underneath all of them.

Go read some Nix. Break something. Fix it. That’s how it sticks.