So a sort of crazy thing happened to the Nix UI between Nix 2.3 and 2.4.

nix shell, the command that opens a shell, was renamed to nix develop.

nix develop is not only more to type (no, nix dev does not work), but it’s also a… misleading name for this command. I publish and preview my blog from a Nix shell, which has dependencies like my static site generator and CSS prefixer and such. I do not think of this as “developing” my blog. I think of it as writing my blog within a Nix shell. Shells are more broadly useful than “just” software development, even if that is the main use case.

Anyway. Coming from the classic commands: nix-shell is now called nix develop, and nix-shell -p is now called nix shell.

I don’t know why.

Maybe there’s some reason why nix shell and nix develop are separate now. It always made sense to me that nix-shell -p was just a specific, simple case of nix-shell, since they do basically the same thing. But maybe nix develop does something more? Something different? I don’t know.

Let’s find out.

In fact, let’s use this blog as an example. Its shell.nix looks like this:

$ cat shell.nix
with import <nixpkgs> {}; mkShellNoCC {
  nativeBuildInputs = [

One of the things I’m excited about with the new nix shell – er, nix develop – is dependency locking. I currently have a janky little hand-rolled pinning scheme for my blog, because nodePackages.postcss-cli broke at some point in the past and I haven’t had time to look into why. So I keep this pinned to the last known good revision of Nixpkgs.

It basically just runs:

$ nix-shell -I nixpkgs=

Er, except that it also does the fancy garbage collection dance that I’ve talked about earlier. Which is another thing that nix develop can do automatically, or so I’ve heard. Let’s try it out.

How do I migrate a shell.nix file to a flake?

Let’s start with the obvious one:

$ nix develop
error: path '/Users/ian/src/' is not a flake (because it doesn't contain a 'flake.nix' file)

Alas. That is what I expected, but there was a small chance that nix develop was backwards compatible.

So let’s learn how to write the equivalent flake.nix file. I expect I should be able to import my shell.nix file from a flake.nix file…?

Let’s start by reading nix develop --help:

nix develop - run a bash shell that provides the build environment of a derivation

Oh gosh oh no. That documentation is not promising. I expected that I would have to rediscover how to run zsh, but… well, if it’s not possible, I guess I’m sticking with nix-shell.

Let’s see… it shows some usage commands. It shows this:

  • Record a build environment in a profile:
# nix develop --profile /tmp/my-build-env nixpkgs#hello

Presumably that’s how I save things from the garbage collector. I’ll have to come back to that.

  • Replace all occurences of the store path corresponding to with a writable directory:
# nix develop --redirect ~/my-glibc/outputs/dev

Note that this is useful if you’re running a nix develop shell for nixpkgs#glibc in ~/my-glibc and want to compile another package against it.

Whoa. Weird. Okay. I’ll have to come back to that too.

Then it describes some stuff about the shell environment; okay…

nix develop starts a bash shell that provides an interactive build environment nearly identical to what Nix would use to build installable. Inside this shell, environment variables and shell functions are set up so that you can interactively and incrementally build your package.

Nix determines the build environment by building a modified version of the derivation installable that just records the environment initialised by stdenv and exits. This build environment can be recorded into a profile using --profile.

Sounds a bit like the inputDerivation scheme that I use. I wonder how this works if you aren’t using stdenv?

The prompt used by the bash shell can be customised by setting the bash-prompt and bash-prompt-suffix settings in nix.conf or in the flake’s nixConfig attribute.

Not encouraging for zsh users. Which is, you may recall, the default login shell for macOS users as of Catalina – which came out in October 2019.

Alright. So now it talks about what it actually does:

If no flake output attribute is given, nix develop tries the following flake output attributes:

  • devShell.<system>
  • defaultPackage.<system>

If a flake output name is given, nix develop tries the following flake output attributes:

  • devShells.<system>.<name>
  • packages.<system>.<name>

Okay. Well. Let’s try this:

$ cat shell.nix
{ nixpkgs ? import <nixpkgs> {} }:
with nixpkgs; mkShellNoCC {
  nativeBuildInputs = [
$ cat flake.nix
  outputs = { nixpkgs }: {
    devShell.${builtins.currentSystem} =
      import ./shell.nix { inherit nixpkgs };
$ nix develop
warning: Git tree '/Users/ian/src/' is dirty
error: source tree referenced by 'git+file:///Users/ian/src/' does not contain a '/flake.nix' file

Ah. Sigh. Because this is a Git repository, it’s assuming that I mean to treat it like a Git repository, and it’s upset that I haven’t added flake.nix yet? But I just want it to be a local path:

$ time nix develop path:.
copying '/Users/ian/src/'
error: syntax error, unexpected '}'

       at /nix/store/7nk5xzhpmfq6g5ldzwf4cf1433pndhb6-source/flake.nix:4:44:

            3|     devShell.${builtins.currentSystem} =
            4|       import ./shell.nix { inherit nixpkgs };
             |                                            ^
            5|   };
1.34s user 2.18s system 83% cpu 4.198 total

Okay, no, because it spends 4 seconds copying this folder for some reason.

$ git add flake.nix

$ time nix develop
warning: Git tree '/Users/ian/src/' is dirty
error: syntax error, unexpected '}'

       at /nix/store/58r3rydwwni2mym0bnwjx1i9x8snpcmn-source/flake.nix:4:44:

            3|     devShell.${builtins.currentSystem} =
            4|       import ./shell.nix { inherit nixpkgs };
             |                                            ^
            5|   };
0.25s user 0.15s system 84% cpu 0.480 total

That’s better. And we can see that, as always, I forgot a semicolon on a singleton attribute set…

I fix that, and try again:

$ nix develop
warning: Git tree '/Users/ian/src/' is dirty
warning: creating lock file '/Users/ian/src/'
warning: Git tree '/Users/ian/src/' is dirty
error: 'outputs' at /nix/store/rawzl90y6d1mvk8qcfqshk5x6j0gvx9b-source/flake.nix:2:13 called with unexpected argument 'self'

       at «string»:45:21:

           45|           outputs = flake.outputs (inputs // { self = result; });
             |                     ^

Okay. I forgot how flakes work. Let’s try that again:

$ cat flake.nix
  outputs = { self, nixpkgs }: {
    devShell.${builtins.currentSystem} =
      import ./shell.nix { inherit nixpkgs; };


$ nix develop
warning: Git tree '/Users/ian/src/' is dirty
error: attribute 'currentSystem' missing

       at /nix/store/k5vy4d9l72isk2mbqw0slbc4hbcj0q0n-source/flake.nix:3:16:

            2|   outputs = { self, nixpkgs }: {
            3|     devShell.${builtins.currentSystem} =
             |                ^
            4|       import ./shell.nix { inherit nixpkgs; };

Ugh. Obviously builtins.currentSystem does exist, normally, but apparently it’s forbidden when evaluating a flake? Just to be annoying? So I have to actually type out x86_64-darwin. Which is like a Mavis Beacon boss level. One more try:

$ nix develop
warning: Git tree '/Users/ian/src/' is dirty
error: undefined variable 'mkShellNoCC'

       at /nix/store/w1m5vcxihca30mj0wm1yyijl0lqzcmv6-source/shell.nix:2:15:

            1| { nixpkgs ? import <nixpkgs> {} }:
            2| with nixpkgs; mkShellNoCC {
             |               ^
            3|   nativeBuildInputs = [

Ah, okay, right, because nixpkgs is a flake now, not an attribute set. I have to do…

$ cat flake.nix
  outputs = { self, nixpkgs }: {
    devShell.x86_64-darwin =
      import ./shell.nix {
        nixpkgs = nixpkgs.outputs.legacyPackages.x86_64-darwin; 

And I finally got it:

$ nix develop
warning: Git tree '/Users/ian/src/' is dirty
[0/5 built, 1/2/14 copied (19.8/177.0 MiB), 5.5/51.5 MiB DL] fetching hugo-0.90.0 from https://cac

Whoooo. I don’t know where it was copying from, because the Nix CLI once again assumes that my terminal spans the full length of a widescreen monitor, and it chops off at my paltry 98 columns. ian$ hugo version
hugo v0.90.0+extended darwin/amd64 BuildDate=unknown

But it did drop me into a bash shell! Wow. Ians-MBP. Is that the name of this computer? This poor computer. It deserves something a little more personalized. I honestly don’t know what that string is or how I would change it, though. And hopefully I will never see it again, so I choose to ignore it.

Okay. I eventually wrote a flake. I only had to type x86_64-darwin twice to do it, and my fingers are only barely cramping from the effort.

nix develop did in fact ignore my NIX_BUILD_SHELL that I’ve set to run zsh, so I once again find myself plunged into a land free from tab completion and shell aliases. Wonderful.

Now let’s lock it to the correct version… nix flake lock --help gets me here:

$ nix flake lock --update-input nixpkgs --override-flake nixpkgs
error: input '' is unsupported

Er, darn. That’s the URL I was using before. But I guess the flaky version is:

$ nix flake lock --update-input nixpkgs --override-flake nixpkgs github:nixos/nixpkgs/fc39a32aa1322d8eec8f7224261c005cbf339549
[24.7 MiB DL] downloading '

Nice. It’s downloading something and spending the traditional full minute unpacking or copying or whatever it’s doing with it. I expected that it would just… update the lock file for me. But nope, it’s also gotta download and cache this. I dunno.

$ nix flake lock --update-input nixpkgs --override-flake nixpkgs github:nixos/nixpkgs/fc39a32aa1322d8eec8f7224261c005cbf339549
warning: Git tree '/Users/ian/src/' is dirty
warning: updating lock file '/Users/ian/src/':
• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/2beba9a23a8381eb95187f46b10c3677d8f63ca1' (2021-12-19)
  → 'github:nixos/nixpkgs/fc39a32aa1322d8eec8f7224261c005cbf339549' (2021-10-01)
warning: Git tree '/Users/ian/src/' is dirty

Hey, that’s neat! I love that it shows the date.

I don’t know why it keeps telling me that my Git tree is dirty. I don’t know what I would possibly do with that information. I know that my worktree is dirty. You don’t have to tell me, twice, in the same command.

Let’s take a look:

$ cat flake.lock
  "nodes": {
    "nixpkgs": {
      "locked": {
        "lastModified": 1633070184,
        "narHash": "sha256-B0G23rPFBhiBsM/OU7WsFXL2dDaLdgRUU7onC/CCyiA=",
        "owner": "nixos",
        "repo": "nixpkgs",
        "rev": "fc39a32aa1322d8eec8f7224261c005cbf339549",
        "type": "github"
      "original": {
        "id": "nixpkgs",
        "type": "indirect"
    "root": {
      "inputs": {
        "nixpkgs": "nixpkgs"
  "root": "root",
  "version": 7

Okay, neat. Looks great. It did the thing. And now:

$ nix develop
warning: Git tree '/Users/ian/src/' is dirty ian$ hugo version
hugo v0.88.1+extended darwin/amd64 BuildDate=unknown

So it worked! Nice.

And now let’s try the profile thingy…

$ nix develop --profile ~/scratch/test-profile
warning: Git tree '/Users/ian/src/' is dirty ian$

Okay. Did that do anything?

$ nix profile list --profile ~/scratch/test-profile

Umm… no. It did not do anything, in fact. Did I… do something wrong?

The example from the --help is:

  • Record a build environment in a profile:
# nix develop --profile /tmp/my-build-env nixpkgs#hello

Let’s try exactly that, minus the sudo

$ nix develop --profile /tmp/my-build-env nixpkgs#hello
zsh: no matches found: nixpkgs#hello

Sigh. Of course.

$ nix develop --profile /tmp/my-build-env 'nixpkgs#hello' ian$

Did that do anything?

$ ll /tmp/my-build-env
/tmp/my-build-env -> my-build-env-1-link

$ ll /tmp/my-build-env-1-link
/tmp/my-build-env-1-link -> /nix/store/2cxr6dr0vpihcidc1b3h0vp0xvzxf8v4-hello-2.10-env

$ ll /nix/store/2cxr6dr0vpihcidc1b3h0vp0xvzxf8v4-hello-2.10-env
56K /nix/store/2cxr6dr0vpihcidc1b3h0vp0xvzxf8v4-hello-2.10-env

Okay. It produced a 56 kilobyte file. It seems to contain… all of the stdenv? I guess? Like the source code of the stdenv/setup script? The file looks like this:

  "bashFunctions": {
    "_activatePkgs":" \n    local hostOffset targetOffset;\n    local pkg;\n    for hostOffset in \"${allPlatOffsets[@]}\";\n    do\n        local pkgsVar=\"${pkgAccumVarVars[hostOffset + 1]}\";\n        for targetOffset in \"${allPlatOffsets[@]}\";\n        do\n            (( hostOffset <= targetOffset )) || continue;\n            local pkgsRef=\"${pkgsVar}[$targetOffset - $hostOffset]\";\n            local pkgsSlice=\"${!pkgsRef}[@]\";\n            for pkg in ${!pkgsSlice+\"${!pkgsSlice}\"};\n            do\n                activatePackage \"$pkg\" \"$hostOffset\" \"$targetOffset\";\n            done;\n        done;\n    done\n",
    "_addRpathPrefix":" \n    if [ \"${NIX_NO_SELF_RPATH:-0}\" != 1 ]; then\n        export NIX_LDFLAGS=\"-rpath $1/lib ${NIX_LDFLAGS-}\";\n        if [ -n \"${NIX_LIB64_IN_SELF_RPATH:-}\" ]; then\n            export NIX_LDFLAGS=\"-rpath $1/lib64 ${NIX_LDFLAGS-}\";\n        fi;\n        if [ -n \"${NIX_LIB32_IN_SELF_RPATH:-}\" ]; then\n            export NIX_LDFLAGS=\"-rpath $1/lib32 ${NIX_LDFLAGS-}\";\n        fi;\n    fi\n",
    ... dozens more bash functions ...
  "variables": {
    "AR": {"type": "exported", "value": "ar"},
    "AS": {"type": "exported", "value": "as"},
    "BASH": {"type": "var", "value": "/nix/store/3npg6a8nc5vpcyw98v085cmlz7f78kgs-bash-5.1-p12/bin/bash"},
    "BASHOPTS": {"type": "unknown"},
    ... dozens more environment variables ...

I have no idea. I mean, it created a thing. And when I manually inspect my ~/scratch/test-profile, I can see that it is also a symlink to a thing that is kind of like this. But… that’s not what a profile is…?

$ nix profile install 'nixpkgs#hello' --profile ~/scratch/test-profile

$ nix profile list --profile ~/scratch/test-profile
0 flake:nixpkgs#legacyPackages.x86_64-darwin.hello github:NixOS/nixpkgs/2beba9a23a8381eb95187f46b10c3677d8f63ca1#legacyPackages.x86_64-darwin.hello /nix/store/6sv1isax4axkxfnmg4gxg1pzr4417r6v-hello-2.10

Okay. So theoretically now this profile contains nixpkgs#hello and my weird shell thing?

$ tree ~/scratch/test-profile
├── bin -> /nix/store/6sv1isax4axkxfnmg4gxg1pzr4417r6v-hello-2.10/bin
├── manifest.json
└── share -> /nix/store/6sv1isax4axkxfnmg4gxg1pzr4417r6v-hello-2.10/share
$ jq . /nix/store/s5ms489y3rsxbf8qijrqixc2qzyw8py2-profile/manifest.json
  "elements": [
      "active": true,
      "attrPath": "legacyPackages.x86_64-darwin.hello",
      "originalUri": "flake:nixpkgs",
      "storePaths": [
      "uri": "github:NixOS/nixpkgs/2beba9a23a8381eb95187f46b10c3677d8f63ca1"
  "version": 1

Nope! Running nix profile blew away whatever nix develop --profile was doing. I guess… those are like… supposed to be separate…? Or… what?

I don’t know.


I had suspected that this “adding devShells to profiles” was one of the reasons that nix profile list output was so crazy. Because your profile doesn’t just consist of simple outputs-of-flakes anymore, instead they consist of these weird computed expressions.

But I guess… that was wrong.

So I suppose the intended usage here, to prevent shells from being garbage-collected, is to like…?

$ nix develop --profile ~/scratch/my-dev-profile

$ ln -s ~/scratch/my-dev-profile /nix/var/nix/gcroots/per-user/ian/my-dev-profile

Make a separate “profile” for each “devShell” and manually add them as gcroots…?

This is, once again, not exactly the API I was hoping for. But perhaps I can wrap this into something friendly.

All of this is moot, of course, if I can’t get nix develop to run zsh. So let’s try that now…

There’s nothing in nix develop --help about changing the shell.

nix --help teaches me nix print-dev-env. It says:

print shell code that can be sourced by bash to reproduce the build environment of a derivation

Which… yep.

$ nix print-dev-env
unset shellHook
export HOST_PATH

# ...dozens of lines of environment variables...

_activatePkgs ()
    local hostOffset targetOffset;
    local pkg;
    for hostOffset in "${allPlatOffsets[@]}";
        local pkgsVar="${pkgAccumVarVars[hostOffset + 1]}";
        for targetOffset in "${allPlatOffsets[@]}";
            (( hostOffset <= targetOffset )) || continue;
            local pkgsRef="${pkgsVar}[$targetOffset - $hostOffset]";
            local pkgsSlice="${!pkgsRef}[@]";
            for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
                activatePackage "$pkg" "$hostOffset" "$targetOffset";

# ... hundreds of lines of shell functions...

export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)"
eval "$shellHook"

Okay. Not exactly useful.

All I really care about are the environment variables, in my typical usage. I don’t care about the ability to run buildPhase interactively. I have never actually written something that uses Nix’s build infrastructure to build itself. I write shell.nix files as a way to document my dependencies, and I invoke my normal interactive build tools from within the shell.

So nix develop does not seem like it’s useful to me.

Perhaps I can get nix shell to do what I want?

Returning to my original shell.nix:

with import <nixpkgs> {}; mkShellNoCC {
  nativeBuildInputs = [

I can run nix shell with all of these at the command line and, you know, it works:

nix shell 'nixpkgs#fswatch' 'nixpkgs#hugo' 'nixpkgs#nodePackages.autoprefixer' 'nixpkgs#nodePackages.postcss-cli' ian$

But it’s still bash. I can manually invoke zsh, though:

$ nix shell 'nixpkgs#fswatch' 'nixpkgs#hugo' 'nixpkgs#nodePackages.autoprefixer' 'nixpkgs#nodePackages.postcss-cli' 'nixpkgs#zsh' -c zsh

nix-shell:shell ~/src/ ➜ hugo version
hugo v0.90.0+extended darwin/amd64 BuildDate=unknown

Ah, a rare glimpse at my actual prompt. Except… huh, it sets the name environment variable to shell instead of nix-shell, so it’s showing up in the long form nix-shell:shell. Although nix-shell -p does the same thing, apparently. I should maybe special-case that in my prompt.

Can I do this same thing with nix develop?

$ nix develop -c zsh
nix-shell ~/src/ ➜ lsof -p $$ | grep /bin/zsh
zsh     45836  ian  txt    REG    1,4  1348368 1152921500312807015 /bin/zsh

Yeah that’s sort of convoluted but macOS doesn’t have /proc so this is the best I can do here.

Note that $name is nix-shell again, which renders as just nix-shell instead of nix-shell:nix-shell. This is a property of my own prompt and not really interesting to anyone else.

Hmmmm. So this is starting the zsh on my PATH which is, for some reason, /bin/zsh. That’s not really what I want, though. I want to start nixpkgs#zsh.

The obvious thing doesn’t work…

$ nix develop -c 'nixpkgs#zsh'
warning: Git tree '/Users/ian/src/' is dirty
/tmp/nix-shell.G4K12z/nix-shell.fozDix: line 1452: exec: nixpkgs#zsh: not found

Which, whatever. But maybe I can do something weird…

$ nix develop -c 'nix run nixpkgs#zsh'
warning: Git tree '/Users/ian/src/' is dirty
/tmp/nix-shell.pnLUvj: line 1452: exec: nix run nixpkgs#zsh: not found

Okay fair enough. But apparently…

$ nix develop -c nix run 'nixpkgs#zsh'
warning: Git tree '/Users/ian/src/' is dirty

nix-shell ~/src/ ➜ lsof -p $$ | grep bin/zsh
zsh     46402  ian  txt    REG    1,6   770048             6523002 /nix/store/90f8r5pc7w5bbdslxcq18hyn6rdx5vb5-zsh-5.8/bin/zsh

Okay. That’s… something, I guess. This is a usable nix develop setup. There’s no way to make this the default, as far as I can tell, but I could at least make an alias to invoke it with zsh.

In fact it’s almost as usable as nix-shell. The disadvantage is the annoying flake.nix boilerplate, and adding GC roots is… a weird new puzzle that I don’t want to solve right now. So… I dunno. It doesn’t seem like flake.lock is sufficiently more useful than my hand-rolled pinning scheme – to me – to justify the boilerplate. And I’m sure third-party tools like niv work even better.

Which means that, once again, the only reason to use flakes is because nix search forces me to.

Perhaps the correct next move is to fork Nix 2.4 and add a nix-search binary that searches through my channel again, and pretend like this whole flakes thing was just a bad dream.

  • How can I suppress all the “warning: Git tree '...' is dirty” messages?
  • How am I supposed to use nix develop --profile?
  • What’s up with that nix develop --redirect thing?