Unpopular opinion: Maybe `stateVersion` shouldn't exist at all

Hello there. First, I must say that I am just an ordinary NixOS user, not a module maintainer, and certainly not an experienced expert user, so my perspective may therefore be highly one-sided. And second, I must clarify that the title is not intended to disparage the hard work of the module maintainers. For the reasons behind this statement, please read on.

Today, I spent several hours investigating exactly what purpose stateVersion serves. I even attempted to use hands-on experimentation to circumvent the effects of stateVersion, though ultimately without success. Now, I would like to write an article to serve as a record of this experience.

So in short, based on my limited study of nixpkgs and home-manager source code (They are very large repos after all), stateVersion is basically the patch of default.

Here are some examples from nixpkgs:

# 1. from `./nixos/modules/tasks/swraid.nix`
enable_implicitly_for_old_state_versions = lib.versionOlder config.system.stateVersion "23.11";

# 2. from `./nixos/modules/services/x11/desktop-managers/xterm.nix`
services.xserver.desktopManager.xterm.enable = mkOption {
  type = types.bool;
  default = versionOlder config.system.stateVersion "19.09" && xSessionEnabled;
  defaultText = literalExpression ''versionOlder config.system.stateVersion "19.09" && config.services.xserver.enable;'';
  description = "Enable a xterm terminal as a desktop manager.";
};

# 3. from `./nixos/modules/services/databases/postgresql.nix`
services.postgresql.package =
  let
    mkThrow = ver: throw "postgresql_${ver} was removed, please upgrade your postgresql version.";
    mkWarn =
      ver:
      warn ''
        The postgresql package is not pinned and selected automatically by
        `system.stateVersion`. Right now this is `pkgs.postgresql_${ver}`, the
        oldest postgresql version available and thus the next that will be
        removed when EOL on the next stable cycle.

        See also https://endoflife.date/postgresql
      '';
    base =
      # XXX Don't forget to keep `defaultText` of `services.postgresql.package` up to date!
      if versionAtLeast config.system.stateVersion "25.11" then
        pkgs.postgresql_17
      else if versionAtLeast config.system.stateVersion "24.11" then
        pkgs.postgresql_16
      else if versionAtLeast config.system.stateVersion "23.11" then
        pkgs.postgresql_15
      else if versionAtLeast config.system.stateVersion "22.05" then
        pkgs.postgresql_14
      else if versionAtLeast config.system.stateVersion "21.11" then
        mkThrow "13"
      else if versionAtLeast config.system.stateVersion "20.03" then
        mkThrow "11"
      else if versionAtLeast config.system.stateVersion "17.09" then
        mkThrow "9_6"
      else
        mkThrow "9_5";
  in
  # Note: when changing the default, make it conditional on
  # ‘system.stateVersion’ to maintain compatibility with existing
  # systems!
  mkDefault (if cfg.enableJIT then base.withJIT else base);
};

Other examples from home-manager:

# 1. from `./modules/programs/zsh/default.nix`
dotDir = mkOption {
  default =
    if config.xdg.enable && lib.versionAtLeast config.home.stateVersion "26.05" then
      "${config.xdg.configHome}/zsh"
    else
      homeDir;
  defaultText = lib.literalExpression ''
    if config.xdg.enable && lib.versionAtLeast config.home.stateVersion "26.05" then
      "''${config.xdg.configHome}/zsh"
    else
      config.home.homeDirectory
  '';
  example = literalExpression ''"''${config.xdg.configHome}/zsh"'';
  description = ''
    Directory where the zsh configuration and more should be located,
    relative to the users home directory. The default is the home
    directory.
  '';
  type = types.nullOr types.str;
};

# 2. from `./modules/home-environment.nix`
home.username = lib.mkIf (lib.versionOlder config.home.stateVersion "20.09") (
  lib.mkDefault (builtins.getEnv "USER")
);
home.homeDirectory = lib.mkIf (lib.versionOlder config.home.stateVersion "20.09") (
  lib.mkDefault (builtins.getEnv "HOME")
);

As we can see, the use of these stateVersions is largely due to changes in the default values of options across versions; some features are enabled by default on older NixOS installations but disabled on newer ones, while others are the exact opposite. Certain specialized packages (such as databases) use default versions based on the current stateVersion.

In short, regardless of what stateVersion affects, their purpose is essentially the same: module maintainers use these stateVersions for compatibility. To maintain compatibility with historical default values, they ensure that older configurations do not become invalid with nixpkgs updates, preventing stateVersion from silently corrupting user data… It’s all about compatibility. Since it’s all about maintaining compatibility with past default values, the default values are, ultimately, the root of it all.

But, is it worth it? To set the default value, we need stateVersion as an anchor, which effectively introduces time into Nix’s evaluation process. In other words, the final build result isn’t determined solely by the user’s configuration, but rather by the user’s configuration combined with the time at which the user installed NixOS. You might say, “But isn’t stateVersion part of the configuration?” I’d say yes, but also no. The reason is that while stateVersion is indeed part of the configuration and can be changed by the user, both the wiki and expert users advise against doing so, because “The consequences of changing its value range from none at all, to complete destruction of data written by specific software.” Herein lies the problem: the impact of stateVersion is beyond the user’s control. While stateVersion determines the default behavior, these defaults are set solely by “module maintainers” and remain opaque to users unless they are willing to dig through the source code. So, even though stateVersion is part of the user’s configuration, it influences aspects the user is completely unaware of, is this really a good thing?

Furthermore, I believe the best practice we can identify is that there is no such thing as a best practice. What seems like a best practice today may not be appropriate in the future, which is why the default values set by module maintainers are subject to change. When default values change, some module maintainers choose to quietly include if statements to maintain compatibility, while others do the same but also print numerous warnings to inform users of the change and explain that they must configure the module themselves to make the warnings disappear.

My view is that changes made for the sake of compatibility can make modules increasingly difficult to maintain. Module maintainers are forced to write numerous if statements whenever a default value changes, and they have to rewrite them every time the default changes. This not only makes maintenance difficult but also deters potential maintainers who want to understand the module, while increasing the burden on the current maintainer.

Now let’s consider what this results in. Perhaps it’s user-friendliness, users have fewer things to think about; they simply need to enable a certain option. As for what that option actually does… they don’t fully know (unless they consult the source code). This means that if users want to be in full control, they will have to read the source code, because they don’t know exactly what an option does behind the scenes. If users want to customize their configuration from scratch and ensure NixOS doesn’t do anything they haven’t explicitly specified, they’ll need to examine a vast amount of source code. But that seems quite difficult, because no one knows exactly what these modules in the massive repository are doing behind the scenes… everyone just knows that they work.

So my thought is, maybe we should give the choice back to the users? Let them decide what they want to enable, which database version to use, and whether to configure Nvim with Lua or Viml. The modules should no longer interfere with users’ decisions, but simply inform them (through documentation or other means) of what they can do and what the consequences will be, leaving everything up to the user. I’m not saying this approach is necessarily better than the default settings, because NixOS isn’t “smart” anymore, users will be forced to learn this and that. This might drive away new users and require them to write more configuration files. And even if we were to proceed with this, such a drastic change could not be implemented overnight. Such an update would cause most NixOS builds to fail, which would undoubtedly cause an uproar. But, to all you hardworking module maintainers out there, do you really think it’s a good idea to let the scope of stateVersion continue to expand?

These are just my humble thoughts. Please don’t hesitate to share your ideas and critiques.

5 Likes

That’s incorrect the state version is part of the configuration.

How does setting default values based on the stateVersion take away user control? User can still provide those values themselves.

1 Like

Just a few days ago, there was an interesting proposal trying to fix stateVersion at least a little bit. Relevant and interesting read, including the discussion:

3 Likes

In fact, I mentioned some philosophical thoughts about this point in the post:

What I mean is, if a user wants to take full control and figure out what that programs.xxx.enable is actually doing behind the scenes, they’ll have to read the complex source code. My point is about transparency and the erosion of the ‘What You See Is What You Get’ principle.

2 Likes

This does indeed seem to limit the scope of stateVersion, but I must also point out that the modules will likely still be difficult to maintain. This is because each module’s self-managed stateVersion is still attempting to reference some default value, and any change to that default value will trigger compatibility checks for stateVersion. So, to put it bluntly, this may simply be postponing the stateVersion issue, though it does help improve the current global stateVersion.

If a user wants to take full control they better know what the options are doing.

I have only read the first half, though your opinion about “stateversion should not exist” is not as unpopular as you think. A lot of users actually agree.

Though it does exist, and removing it is not possible without a good migration strategy, or immediate fallout.

The latter is not wanted by the community, which is fine in my opinion, and there are different discussions about the former. One even happened recently.

The question is, when will any of those actually make it into an RFC, and will that RFC even be accepted?

3 Likes

My position has always been quite clear: modules should be transparent. Users certainly need to learn how to use them, but a module’s options should be self-explanatory. Unless a user explicitly enables certain features of a module, those features should not be activated.
For example, when programs.hyprland.enable is set to true, it sets services.graphical-desktop.enable to true—this is the default behavior. I believe that no matter how closely related two modules may seem, they should be kept separate and explicitly enabled by the user, rather than relying on default behavior.
Because a “default implementation” requires a stateVersion to provide an anchor for that implementation, this leads to modules that are difficult to maintain.

1 Like

That goes without saying, and I acknowledge this at the end of this article. So my idea is simply to temporarily prevent stateVersion from affecting more things, and gradually work toward making modules transparent. Most importantly, new modules should let users make the decisions; when a user’s configuration is incomplete, NixOS should fail to build and prompt the user on what to do (such as advising them to consult the documentation). In short, this is definitely a long-term endeavor.

That’s why I wrote this post, to see what others think. When I first encountered NixOS about six months ago, I felt that stateVersion was a “strange-looking” configuration, because the wiki told me it “should not be changed,” even though it is part of the configuration.

1 Like

That has little to do with stateVersion (when used “correctly”); stateVersion is for stateful data that your config cannot have knowledge of. We cannot provide eval errors here.

1 Like

Correct. That is the point, though. You cannot just drop the whole stateVersion “pinning” entirely out-of-the-blue. If you want to change it, you will have to transition slowly. The proposed approach suggests de-generalising the stateVersionto a per-module stateVersion, which would allow for potentially dropping the global stateVersionin the future, and resolve the issues on a per-module basis, if/when the user wants. Changing some defaults (precisely, data, such as paths to databases, etc.) for the user cannot be done gracefully and automatically while remaining fully foolproof (think, e.g., what to do for rollbacks), as it is now. There necessarily needs to be some versioning system for the modules, with the version manually being bumped when the user wants (after doing manual data migration as appropriate). The approach suggested there is just one such solution.

Yes, I agree with what you said. As I mentioned in my previous reply, “We need a transitional solution right now.” I also acknowledge that this solution can serve as a transitional measure, but I believe it should not be the final solution; in general, we should move toward modules without a default behavior.

What I mean is, when a user attempts to set services.postgresql.enable to true without specifying services.postgresql.package, NixOS should not default to a version based solely on stateVersion; instead, it should throw an error during the build and prompt the user to explicitly specify a version. This is what I mean by “transparency”—modules should not make decisions on behalf of the user. This is just a simple example. A more complex one, as I mentioned in my previous reply, is that enabling programs.hyprland.enable should not implicitly enable services.graphical-desktop.enable, because that is the default behavior—and default behavior requires an anchor like stateVersion.

2 Likes

We haven’t seriously been chatting with an LLM all that time?

3 Likes

Not what I’m talking about, that’s an "incorrect " usage of stateVersion (and we already got rid of most of those). These conversations have gone in circles a bit, hopefully nixos figures out something better eventually.

Well, it would be better if you could give an example that you think makes sense :smile:

I very much like this, it allows a lot more control over what happens and I know 100% that default values can sometimes cause conflicts in software ( even if said modules contribute to a feature of another module ). However, the documentation has to get a whole lot better, the use has to be told explicitly what other modules need to be enabled to allow this module to function properly, even to the point where they are told what issues will happen if they don’t enable a module ( this can be a eval warning ). But that’s the thing isn’t it, stateVersion wouldn’t be such a problem if good docs existed, you as a normal user had to go read internal src to actually figure out.

1 Like

Per the other thread, @rhendric is against any docs for stateVersion, and most of the respondents seem to agree. So it will continue to be a pain point.

I am with you here. Just not providing a default, rather than making the default dependant on some “random” value like stateVersion would have been a perfect solution, if it had been done 10+ years in the past.

From what I understand, HL can not work without the graphical desktop, so what alternatives are there to activating it? If there is only a single choice to get a working system then the choice should be abstracted away from the user, thats what we have the module system for!

If the modules were only as granular as you describe it, then services.foo.enable wouldn’t be allowed anymore to create the service using systemd.services.foo.* as that is another module. It also wouldn’t be allowed anymore to use environment.etc to create the config, as thats another module.

1 Like

I think the current situation would massively be improved by docs…

Currently the generator says “before bumping stateversion, read the release notes” (paraphrased) though the release nots do not say anything about stateversion.

2 Likes