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.