system.stateVersion: What It Changes, and How to Bump It Without Losing Data
system.stateVersion: What It Changes, and How to Bump It Without Losing Data
Goal: Know exactly what the one line system.stateVersion = "…"; does on a NixOS host and what it does not do. See which defaults it selects on your configuration, and why changing it “to match the release” can start a database server on an empty data directory. Learn the safe way to move a host to newer defaults: diff, pin or migrate, diff again. Recognise the newer per-module stateVersion that lets one service migrate on its own schedule.
Version note: everything below was evaluated against nixos-26.05 (26.05.20261018.8ce7336) with Nix 2.35.3. All evaluation output is pasted from those runs. Evaluation does not need a NixOS machine, so nothing was booted. The data-migration steps in Case 6 and The trap (pg_upgrade, moving a data folder) need a running host with real data. They are written from the NixOS manual and marked as not run.
Mental model
A NixOS host has two different “versions”, and people mix them up:
what you RUN what your DATA was created with
────────────── ───────────────────────────────
inputs.nixpkgs → nixos-26.05 (rev 8ce7336) system.stateVersion = "23.11"
· packages, kernel, systemd, security fixes · set once, by nixos-generate-config,
· you update it every week, freely to the release you INSTALLED
· nixos-version prints it · read by module DEFAULTS only:
if versionAtLeast stateVersion "25.11"
then pkgs.postgresql_17 else …
Modules use stateVersion only where a newer default would break existing state: a database major version whose on-disk format needs a manual upgrade, a data directory that was renamed, a daemon setting whose default flipped. Code that can migrate its own data does not need it, and the option description says so. Everything else follows nixpkgs, whatever stateVersion says.
The consequences are simple:
- A low
stateVersionis not outdated, unsupported or insecure. A host installed on 23.11 and updated to nixos-26.05 runs 26.05’s PostgreSQL 15 build, with 26.05’s patches. It just has not been moved to PostgreSQL 17. - Changing
stateVersionchanges defaults, and nothing else. Options you set explicitly are not affected. Defaults that depend on it switch to the new value on the nextswitch, and the data on disk stays where it is. - If you leave it unset, it defaults to the current release and evaluation prints a warning (Case 3). The next release upgrade then moves your defaults, with only that warning as notice. That is the one way to get the dangerous behaviour without editing anything.
nixos-generate-config writes the line for you, with a warning comment (nixos/modules/installer/tools/tools.nix):
system.stateVersion = "${config.system.nixos.release}"; # Did you read the comment?Worked examples
No host is booted. Each case evaluates a minimal NixOS configuration with nixpkgs.lib’s eval-config.nix and reads back option values or the generated systemd units. Everything runs as an ordinary user. The nixpkgs revision is the locked nixos-26.05 commit from the version note.
$ nix --version; nix eval --raw github:NixOS/nixpkgs/8ce73368d1efd4d657e9064d29b144a52046b4e8#lib.version; echo
nix (Nix) 2.35.3
26.05.20261018.8ce7336
Case 1: How many modules care?
A census of the module tree at that revision:
# census.sh — which NixOS modules read system.stateVersion, and at which release thresholds
cd "$1/nixos/modules"
echo "modules mentioning system.stateVersion: $(rg -l -g '*.nix' 'system\.stateVersion|inherit \(config\.system\) stateVersion' . | wc -l)"
echo "comparisons per threshold, code + defaultText:"
rg -o --no-filename -g '*.nix' 'version(Older|AtLeast) (config\.system\.|cfg\.)?stateVersion "[0-9]+\.[0-9]+"' . \
| grep -o '"[0-9.]*"' | tr -d '"' | sort -V | uniq -c | awk '{printf " %s:%s", $2, $1} END {print ""}'
echo "thresholds 26.05 and later:"
rg -l -g '*.nix' 'stateVersion "26\.(05|11)"' . | sed 's|^\./| |' | sort$ bash census.sh ./nixpkgs # an unpacked checkout of rev 8ce7336
modules mentioning system.stateVersion: 48
comparisons per threshold, code + defaultText:
17.09:5 18.03:2 19.03:1 19.09:2 20.03:2 20.09:4 21.03:1 21.11:1 22.05:6 23.05:1 23.11:6 24.05:5 24.11:6 25.05:11 25.11:8 26.05:14 26.11:3
thresholds 26.05 and later:
services/databases/mysql.nix
services/mail/dovecot.nix
services/mail/stalwart.nix
services/misc/seerr.nix
services/misc/tandoor-recipes.nix
services/misc/taskchampion-sync-server.nix
services/networking/sabnzbd/default.nix
services/web-apps/lauti.nix
services/web-apps/netbox.nix
services/web-apps/nextcloud.nix
services/web-apps/olivetin.nix
tasks/filesystems/zfs.nix
48 of the 2,421 .nix files under nixos/modules mention the option. A few only set it (the installer’s generated configuration, test and builder-VM profiles), and the rest read it. Most of the readers are services that own a data directory: databases, mail, web apps, media servers. Thresholds as old as 17.09 stay in the code. When the package an old branch would select is removed from nixpkgs, that branch turns into an evaluation error (PostgreSQL 13 and older, Case 4). Note the three 26.11 comparisons that are already on the 26.05 branch, for example boot.zfs.forceImportRoot and a MySQL first-start change. A host installed with the coming 26.11 release will get the new default, and every host installed earlier keeps the old one.
Case 2: The same configuration under five stateVersions
# probe.nix — evaluate one minimal NixOS configuration per stateVersion and read back defaults
let
nixpkgs = builtins.getFlake "github:NixOS/nixpkgs/8ce73368d1efd4d657e9064d29b144a52046b4e8";
host = stateVersion: (import "${nixpkgs}/nixos/lib/eval-config.nix" {
system = "x86_64-linux";
modules = [{
boot.isContainer = true; # no bootloader / fileSystems needed for evaluation
system.stateVersion = stateVersion;
services.postgresql.enable = true; # enabled, but nothing pinned
services.vaultwarden.enable = true;
services.dovecot2.enable = true;
}];
}).config;
probe = sv: let c = host sv; in {
"postgresql.package" = c.services.postgresql.package.name;
"postgresql.dataDir" = c.services.postgresql.dataDir;
"vaultwarden StateDirectory" = c.systemd.services.vaultwarden.serviceConfig.StateDirectory;
"dovecot2.package" = c.services.dovecot2.package.name;
"oci-containers.backend" = c.virtualisation.oci-containers.backend;
"docker live-restore" = c.virtualisation.docker.daemon.settings.live-restore;
"boot.swraid.enable" = c.boot.swraid.enable;
"zfs.forceImportRoot" = c.boot.zfs.forceImportRoot;
};
in builtins.listToAttrs (map (sv: { name = sv; value = probe sv; }) [ "22.11" "23.11" "24.11" "25.11" "26.05" ])$ nix eval --impure --json -f probe.nix > probe.json && python3 table.py probe.json
stateVersion → 22.11 23.11 24.11 25.11 26.05
boot.swraid.enable true false false false false
docker live-restore true true false false false
dovecot2.package dovecot-2.3.21.1 dovecot-2.3.21.1 dovecot-2.3.21.1 dovecot-2.3.21.1 dovecot-2.4.5
oci-containers.backend podman podman podman podman podman
postgresql.dataDir /var/lib/postgresql/14 /var/lib/postgresql/15 /var/lib/postgresql/16 /var/lib/postgresql/17 /var/lib/postgresql/17
postgresql.package postgresql-14.24 postgresql-15.19 postgresql-16.15 postgresql-17.11 postgresql-17.11
vaultwarden StateDirectory bitwarden_rs bitwarden_rs vaultwarden vaultwarden vaultwarden
zfs.forceImportRoot true true true true true
table.py only turns the JSON into rows:
# table.py — print probe.json as rows = options, columns = stateVersions
import json, sys
d = json.load(open(sys.argv[1]))
svs = sorted(d, key=lambda v: tuple(map(int, v.split("."))))
opts = list(d[svs[0]])
cell = lambda v: str(v).lower() if isinstance(v, bool) else str(v)
rows = [["stateVersion →"] + svs] + [[o] + [cell(d[s][o]) for s in svs] for o in opts]
w = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
for r in rows:
print(" ".join(c.ljust(w[i]) for i, c in enumerate(r)).rstrip())Read it column by column: these are the defaults one nixpkgs revision gives each host. Every column runs the same 26.05 packages. What differs is which variant the module chooses for state it cannot migrate itself:
- PostgreSQL follows a staircase: 14 → 15 → 16 → 17. The data directory has the major version in its name (
/var/lib/postgresql/${psqlSchema}). That detail decides what happens on a bump (Case 5). - Vaultwarden keeps the pre-rename data folder
bitwarden_rsfor hosts older than 24.11. - Dovecot 2.4 changed its configuration format, so only hosts installed with 26.05 get it by default.
- Docker
live-restorewas a NixOS-specifictrueuntil 24.11. Newer hosts follow upstream (false). boot.swraid.enablewas implicitly on before 23.11. Newer hosts must enable it, asnixos-generate-configdoes when it finds MD arrays.oci-containers.backendandzfs.forceImportRootdo not change within these columns. Their thresholds are 22.05 (Podman) and 26.11 (not yet released).
Case 3: stateVersion left unset
# edge.nix — edge cases: stateVersion unset, very old, and a module with its own stateVersion
let
nixpkgs = builtins.getFlake "github:NixOS/nixpkgs/8ce73368d1efd4d657e9064d29b144a52046b4e8";
eval = m: (import "${nixpkgs}/nixos/lib/eval-config.nix" {
system = "x86_64-linux";
modules = [ { boot.isContainer = true; } m ];
}).config;
in {
unset = (eval { }).system.stateVersion;
old-pg = (eval { system.stateVersion = "21.11"; services.postgresql.enable = true; })
.services.postgresql.package.name;
stalwart = (eval { system.stateVersion = "26.05"; services.stalwart.enable = true; })
.services.stalwart.dataDir;
stalwart-own = builtins.mapAttrs (_: sv: (eval {
system.stateVersion = "26.05"; # the host was installed on 26.05 …
services.stalwart = { enable = true; stateVersion = sv; }; # … the mail server earlier
}).services.stalwart.dataDir) { "25.11" = "25.11"; "26.05" = "26.05"; };
}$ nix eval --impure --raw -f edge.nix unset; echo
evaluation warning: system.stateVersion is not set, defaulting to 26.05. Read why this matters on https://nixos.org/manual/nixos/stable/options.html#opt-system.stateVersion.
26.05
The default is config.system.nixos.release, the release of the nixpkgs you evaluate. Today that is harmless, because the host gets today’s defaults. On the day you move the flake input to nixos-26.11, the default becomes 26.11 and every module from Case 1 that has a 26.11 threshold switches over at once. The warning is the only notice you get. Treat it as an error. Copy the value it prints into the configuration and commit it.
Case 4: The floor: stateVersions older than the oldest supported default
$ nix eval --impure --raw -f edge.nix old-pg 2>&1 | grep -E '^\s*error: [a-zA-Z]'
error: postgresql_13 was removed, please upgrade your postgresql version.
A 21.11 host that left PostgreSQL unpinned would get postgresql_13, which nixpkgs no longer ships. Rather than silently starting a newer major version on old data, the module refuses to evaluate. The same will happen to postgresql_14 once it is removed. Upstream support for PostgreSQL 14 ends in November 2026, and the module already carries a warning helper for “the oldest version available and thus the next that will be removed”. If your host’s stateVersion is between 22.05 and 23.05, pin the package and plan the pg_upgrade now, while you choose the time.
Case 5: What a bump from 23.11 to 26.05 would actually change
The useful question is not “what is the newest stateVersion?” but “what would change on my host?”. Generate the units for both values and diff them:
# host.nix — a small server installed on 23.11 and upgraded release by release since
{ ... }: {
boot.isContainer = true;
services.postgresql.enable = true;
services.vaultwarden.enable = true;
services.dovecot2.enable = true;
virtualisation.docker.enable = true;
}# units.nix — the systemd units host.nix generates under a given stateVersion
{ stateVersion, hostModule ? ./host.nix }:
let
nixpkgs = builtins.getFlake "github:NixOS/nixpkgs/8ce73368d1efd4d657e9064d29b144a52046b4e8";
cfg = (import "${nixpkgs}/nixos/lib/eval-config.nix" {
system = "x86_64-linux";
modules = [ hostModule { system.stateVersion = stateVersion; } ];
}).config;
wanted = [ "postgresql" "vaultwarden" "dovecot" "docker" ];
pick = s: { inherit (s) environment serviceConfig; preStart = s.preStart; };
in builtins.listToAttrs (map (n: { name = n; value = pick cfg.systemd.services.${n}; })
(builtins.filter (n: cfg.systemd.services ? ${n}) wanted))
// { "docker daemon.json" = cfg.virtualisation.docker.daemon.settings; }# diff.py — flatten two JSON documents and print every leaf that differs
import json, sys
def flat(x, p=""):
if isinstance(x, dict):
for k, v in x.items():
yield from flat(v, f"{p}.{k}" if p else k)
elif isinstance(x, list):
for i, v in enumerate(x):
yield from flat(v, f"{p}[{i}]")
else:
yield p, x
def short(v, n=96):
s = json.dumps(v) if not isinstance(v, str) else v
s = " ⏎ ".join(l.strip() for l in s.splitlines() if l.strip())
return s if len(s) <= n else s[: n - 1] + "…"
def window(x, y, n=88):
"""Show both strings from a little before the first character where they differ."""
x, y = short(x, 10**6), short(y, 10**6)
i = next((i for i, (p, q) in enumerate(zip(x, y)) if p != q), min(len(x), len(y)))
start = max(0, i - 24)
cut = lambda s: ("…" if start else "") + (s[start:start + n] + ("…" if len(s) > start + n else ""))
return cut(x), cut(y)
a, b = (dict(flat(json.load(open(f)))) for f in sys.argv[1:3])
for k in sorted(a.keys() | b.keys()):
if a.get(k) != b.get(k):
print(k)
old, new = window(a.get(k, "(absent)"), b.get(k, "(absent)"))
print(" -", old)
print(" +", new)$ for v in 23.11 26.05; do nix eval --impure --json --expr "import ./units.nix { stateVersion = \"$v\"; }" > units-$v.json; done
$ python3 diff.py units-23.11.json units-26.05.json
docker daemon.json.live-restore
- true
+ false
docker.serviceConfig.ExecStart[1]
- …-config-file=/nix/store/84qkcv0j9v7vj8f9wymbx0s26mc7pz0b-daemon.json \
+ …-config-file=/nix/store/r1c783pjbja7as7qyjzqf6r62vbkv0ai-daemon.json \
dovecot.serviceConfig.ExecReload
- /nix/store/0jf59pcd4sqbgrx6swq356ynzad7lk53-dovecot-2.3.21.1/bin/doveadm reload
+ /nix/store/4n0shh682cd47yb71sjkhx6vr22l328m-dovecot-2.4.5/bin/doveadm reload
dovecot.serviceConfig.ExecStart
- /nix/store/0jf59pcd4sqbgrx6swq356ynzad7lk53-dovecot-2.3.21.1/bin/dovecot -F
+ /nix/store/4n0shh682cd47yb71sjkhx6vr22l328m-dovecot-2.4.5/bin/dovecot -F
postgresql.environment.PATH
- /nix/store/fhwggl31v8v7yxlkg1fvq5c2m89h0jsv-postgresql-and-plugins-15.19/bin:/nix/store/…
+ /nix/store/apxhlnhqwj588xzrhrm2bqi6jw8h25xk-postgresql-and-plugins-17.11/bin:/nix/store/…
postgresql.environment.PGDATA
- /var/lib/postgresql/15
+ /var/lib/postgresql/17
postgresql.preStart
- …-e /var/lib/postgresql/15/PG_VERSION; then ⏎ # Cleanup the data directory. ⏎ rm -f /var/…
+ …-e /var/lib/postgresql/17/PG_VERSION; then ⏎ # Cleanup the data directory. ⏎ rm -f /var/…
postgresql.serviceConfig.ExecStart
- /nix/store/fhwggl31v8v7yxlkg1fvq5c2m89h0jsv-postgresql-and-plugins-15.19/bin/postgres
+ /nix/store/apxhlnhqwj588xzrhrm2bqi6jw8h25xk-postgresql-and-plugins-17.11/bin/postgres
postgresql.serviceConfig.ExecStartPre[0]
- /nix/store/px983wkhz9vlc9ss8ksc0b0a12flpgyg-unit-script-postgresql-pre-start/bin/postgre…
+ /nix/store/v3c2ba8kssw9l803c93kzzk76b105b39-unit-script-postgresql-pre-start/bin/postgre…
postgresql.serviceConfig.StateDirectory
- postgresql postgresql/15
+ postgresql postgresql/17
vaultwarden.serviceConfig.EnvironmentFile[0]
- /nix/store/7vc2iqrapfga7jic8wmcyay6z5wwb932-vaultwarden.env
+ /nix/store/lq210axv4v22lk56zp1fj41nqrqikab1-vaultwarden.env
vaultwarden.serviceConfig.StateDirectory
- bitwarden_rs
+ vaultwarden
That list is the real meaning of the one-line change for this host:
- PostgreSQL: new binaries (
17.11instead of15.19), andPGDATA,StateDirectoryand thepreStartcheck all move to/var/lib/postgresql/17.preStartrunsinitdbwhenPG_VERSIONis missing, and there is noPG_VERSIONin a directory that has never existed. So the first start after the bump creates a new, empty cluster, and the service comes up “healthy” with no databases. The old cluster is still in/var/lib/postgresql/15. Nothing was deleted, but every application now sees an empty database. - Vaultwarden:
StateDirectory(andDATA_FOLDERin the environment file) move frombitwarden_rstovaultwarden. It starts with an empty data folder: no users, no vault. - Dovecot: 2.3 → 2.4, a major version whose configuration syntax changed. A hand-written
extraConfigfrom the 2.3 days may no longer parse. - Docker:
live-restoreflips tofalse. Restartingdockerd(for example during the nextswitchthat updates Docker) now stops the running containers.
A diff of the units catches what changes in services. It does not catch defaults outside systemd.services, such as boot.swraid.enable in Case 2. For a complete answer, build both system closures and compare them with nix store diff-closures, or nvd diff, before switching. That needs both systems built and was not run here.
Case 6: The safe bump: pin first, then diff again
Write down every default you rely on, then bump:
# host-pinned.nix — host.nix with every stateVersion-dependent default it relies on written down
{ pkgs, ... }: {
imports = [ ./host.nix ];
services.postgresql.package = pkgs.postgresql_15; # was the 23.11 default
services.dovecot2.package = pkgs.dovecot_2_3; # was the default before 26.05
virtualisation.docker.daemon.settings.live-restore = true; # was the default before 24.11
}$ nix eval --impure --json --expr 'import ./units.nix { stateVersion = "26.05"; hostModule = ./host-pinned.nix; }' > units-26.05-pinned.json
$ python3 diff.py units-23.11.json units-26.05-pinned.json
vaultwarden.serviceConfig.EnvironmentFile[0]
- /nix/store/7vc2iqrapfga7jic8wmcyay6z5wwb932-vaultwarden.env
+ /nix/store/lq210axv4v22lk56zp1fj41nqrqikab1-vaultwarden.env
vaultwarden.serviceConfig.StateDirectory
- bitwarden_rs
+ vaultwarden
Three of the four services are now identical to the 23.11 host. Vaultwarden has no option for its data folder: it is a let binding inside the module. You either keep stateVersion below 24.11 or migrate the data yourself: stop the service, move /var/lib/bitwarden_rs to /var/lib/vaultwarden, bump and start. That was not run here. Repeat the diff until it is empty or every remaining line is one you have migrated. At that point the bump is a no-op for this host, which is the only kind of bump worth making. After that, upgrading PostgreSQL is a separate, deliberate change: follow the NixOS manual’s upgrade-pg-cluster/pg_upgrade procedure, then raise services.postgresql.package (not run here).
Case 7: A stateVersion per module
Stalwart (mail and collaboration server) got its own services.stalwart.stateVersion in 26.05. It has no default, and the 26.05 release notes say existing installations must set it to their system.stateVersion:
$ nix eval --impure --raw -f edge.nix stalwart 2>&1 | grep -E '^\s*error: [a-zA-Z]'
error: The option `services.stalwart.stateVersion' was accessed but has no value defined. Try setting the option.
With it set, the host and the service move independently. The host below was installed with 26.05, but its mail data came from 25.11:
$ nix eval --impure --json -f edge.nix stalwart-own; echo
{"25.11":"/var/lib/stalwart-mail","26.05":"/var/lib/stalwart"}
At this revision Stalwart’s option is the only module-level stateVersion in the module tree (rg 'stateVersion = (lib\.)?mkOption' nixos/modules finds it and version.nix, nothing else). It shows the alternative to one global switch: each service carries its own migration state. Until more modules adopt it, system.stateVersion remains the default that every other module reads.
The trap
“My stateVersion is three releases old, so I should update it.” That is exactly what the option description tells you not to do:
Most users should never change this value after the initial install, for any reason, even if you’ve upgraded your system to a new NixOS release.
This value does not affect the Nixpkgs version your packages and OS are pulled from, so changing it will not upgrade your system.
The failure looks harmless: evaluation succeeds, switch succeeds and the services report active. What is lost is the connection between services and their data (Case 5): PostgreSQL on an empty cluster, Vaultwarden with an empty vault. If you have already done it, do not write new data. Set system.stateVersion back (or pin the old defaults as in Case 6) and switch. The old directories are still there and are used again. Anything written to the new, empty directories in the meantime has to be merged by hand.
The mirror-image trap is copying a configuration to a new machine together with its old stateVersion. The new host then starts with the oldest defaults (PostgreSQL 15 instead of 17, a legacy data folder) and walks into Case 4 one release earlier. A fresh install gets the release it was installed with, even when the rest of the configuration is shared. In a fleet, set stateVersion per host, not in a shared module.
The boring rule
Set system.stateVersion once, to the release you installed. Commit it per host, and never move it as part of a release upgrade. Upgrade services by setting their package or option explicitly (services.postgresql.package), using the service’s own migration procedure. If you ever want newer defaults, diff the generated units (or closures) for the old and new value, pin or migrate every difference, and bump only when the diff is empty. For modules with their own stateVersion, set it the same way and move it per service.
Try this
- Add your own host’s enabled services to
host.nix(or pointunits.nixat your real host module). Run the 23.11 → 26.05 diff. Which lines are package changes, and which are path changes? - Extend
probe.nixwithservices.nextcloud.packageandservices.mattermost.dataDir(both needenable = trueand, for Nextcloud, ahostName). At which thresholds do they move? - Find the
stateVersioncomparisons on thenixos-unstablebranch and compare the 26.11 list with Case 1. Which defaults will a host installed on 26.11 get that yours never will? - Build two toplevels that differ only in
stateVersionwithnix build .#nixosConfigurations.<a>.config.system.build.toplevel(and<b>), then runnix store diff-closures ./result-a ./result-b. What does the closure diff show that the unit diff missed? - Set
system.stateVersionto"26.11"while still on nixos-26.05. Does anything check the value against the release? Which defaults from Case 1 change?
Lab files, all shown above: census.sh, probe.nix, table.py, edge.nix, host.nix, host-pinned.nix, units.nix, diff.py.
Sources
- nixpkgs
nixos/modules/misc/version.nixon branchnixos-26.05(rev8ce7336): thesystem.stateVersionoption, its default, the “not set” warning and the full description quoted above: https://github.com/NixOS/nixpkgs/blob/nixos-26.05/nixos/modules/misc/version.nix - NixOS options search,
system.stateVersion(26.05): https://search.nixos.org/options?channel=26.05&show=system.stateVersion - nixpkgs modules read in this chapter (same revision):
services/databases/postgresql.nixandpostgresql.md(version staircase,dataDir, upgrade procedure),services/security/vaultwarden/default.nix,services/mail/dovecot.nix,virtualisation/docker.nix,tasks/swraid.nix,tasks/filesystems/zfs.nix,services/mail/stalwart.nix,installer/tools/tools.nix: https://github.com/NixOS/nixpkgs/tree/nixos-26.05/nixos/modules - NixOS 26.05 release notes (
nixos/doc/manual/release-notes/rl-2605.section.md): Stalwart’s module-levelstateVersion, Dovecot 2.4 and Nextcloud defaults: https://nixos.org/manual/nixos/stable/release-notes - NixOS manual, PostgreSQL, “Upgrading”: https://nixos.org/manual/nixos/stable/#module-services-postgres-upgrading
- PostgreSQL versioning policy (14 EOL November 2026): https://www.postgresql.org/support/versioning/
- Nix manual,
nix store diff-closures: https://nix.dev/manual/nix/latest/command-ref/new-cli/nix3-store-diff-closures