purcell/envrc

Emacs support for direnv which operates buffer-locally

514

stars

271

commits

Emacs Lisp

primary language

Sep 5, 2026

updated

README

MELPA Status MELPA Stable Status Build Status Support me

envrc.el - buffer-local direnv integration for Emacs

A GNU Emacs library which uses the direnv tool to determine per-directory/project environment variables and then set those environment variables on a per-buffer basis. This means that when you work across multiple projects which have .envrc files, all processes launched from the buffers "in" those projects will be executed with the environment variables specified in those files. This allows different versions of linters and other tools to be used in each project if desired.

How does this differ from direnv.el?

direnv.el repeatedly changes the global Emacs environment, based on tracking what buffer you're working on.

Instead, envrc.el simply sets and stores the right environment in each buffer, as a buffer-local variable.

From a user perspective, both are well tested and typically work fine, but the envrc.el approach feels cleaner to me.

Additionally, at the time of writing, envrc.el has early TRAMP support, while direnv.el does not.

Installation

Installable packages are available via MELPA: do M-x package-install RET envrc RET.

Alternatively, download the latest release or clone the repository, and install envrc.el with M-x package-install-file.

Usage

Add a snippet like the following at the bottom of your init.el:

(envrc-global-mode)

or

(add-hook 'after-init-hook 'envrc-global-mode)

or, if you're a use-package fan (don't ask the author about this ๐Ÿ˜):

(use-package envrc
  :hook (after-init . envrc-global-mode))

Why must you enable the global mode late in your startup sequence like this? You normally want envrc-mode to be initialized in each buffer before other minor modes like flycheck-mode which might look for executables. Counter-intuitively, this means that envrc-global-mode should be enabled after other global minor modes, since each prepends itself to various hooks.

The global mode will only have an effect if direnv is installed and available in the default Emacs exec-path. (There is a local minor mode envrc-mode, but you should not try to enable this granularly, e.g. for certain modes or projects, because compilation and other buffers might not get set up with the right environment.)

Regarding interaction with the mode, see envrc-mode-map, and the commands envrc-reload, envrc-allow and envrc-deny.

In particular, you can enable keybindings for the above commands by binding your preferred prefix to envrc-command-map in envrc-mode-map, e.g.

(with-eval-after-load 'envrc
  (define-key envrc-mode-map (kbd "C-c e") 'envrc-command-map))

When and how often direnv is executed

When envrc-mode is enabled for the first time in a particular direnv environment, envrc.el will run direnv export. Subsequent buffers will always re-use the resulting environment indefinitely, i.e. there is no implicit reloading of the direnv environment within Emacs. Use the command envrc-reload to reload the current environment explicitly as needed.

Blocking vs async loading of direnv

By default, envrc-mode blocks Emacs until direnv has finished executing, which means that subsequent mode hooks will be guaranteed to see the desired local environment, and they can use it to find executables they need, like LSP servers or flymake tools. This is therefore a robust default with predictable results.

However, in some cases (particularly with GUIX and Nix) direnv execution can sometimes be very slow, and it is undesirable to block Emacs for that long. For this purpose, envrc.el provides a variable called envrc-async.

When set to t ("always"), Emacs will never be blocked by direnv execution, and will remain completely responsive. After direnv has run, the results will be quietly propagated to the corresponding buffers.

Even with envrc-async set to nil (the default), if you find that Emacs is blocked on direnv, you can hit C-g, and then direnv execution will continue in the background, and its eventual result will still be propagated to the relevant envrc-mode buffers.

As a further option, envrc-async can be set to a number of seconds: this then specifies a time limit for blocking Emacs waiting for direnv. After this time, execution will automatically continue in the background.

When direnv is running asynchronously for the current buffer's environment, the envrc-mode mode-line lighter indicates this with a *.

Troubleshooting

If you find that a particular Emacs command isn't picking up the environment of your current buffer, and you're sure that envrc-mode is active in that buffer, then it's possible you've found code that runs a process in a temp buffer and neglects to propagate your environment to that buffer before doing so.

A couple of common Emacs commands that suffer from this defect are also patched directly via advice in envrc.el โ€” shell-command-to-string is a prominent example!

The inheritenv package was designed to handle this case in general.

lsp-mode users might want to use lsp-deferred rather than lsp, so that path modifications are available when the sub-process is launched.

Design notes

By default, Emacs has a single global set of environment variables used for all subprocesses, stored in the process-environment variable. direnv.el switches that global environment using values from direnv when the user performs certain actions, such as switching between buffers in different projects.

In practice, this is simple and mostly works very well. But there are some quirks, and it feels wrong to me to mutate the global environment in order to support per-directory environments.

Now, in Emacs we can also set process-environment locally in a buffer. If this value could be correctly maintained in all buffers based on their various respective .envrc files, then buffers across multiple projects could simultaneously be "connected" to the environments of their corresponding project directories. I wrote envrc.el to explore this approach.

envrc.el uses a global minor mode (envrc-global-mode) to hook into practically every buffer created by Emacs, including hidden and temporary ones. When a buffer is found to be "inside" an .envrc-managed project, process-environment is set buffer-locally by running direnv, the results of which are also cached indefinitely so that this is not too costly overall. Each buffer has a local minor mode (envrc-mode) with an indicator which displays whether or not a direnv is in effect in that buffer. (Hooking into every buffer is important, rather than just those with certain major modes, since separate temporary, compilation and repl buffers are routinely used for executing processes.)

This approach also has some trade-offs:

  • Buffers like *Help* will have envrc-mode enabled based on the directory of the buffer which caused them to be created initially, and then those buffers often live for a long time. If you launch programs from such buffers while working on a different project, the results might not be what you expect. I might exclude certain modes to minimise confusion, but users will always have to be aware of the fact that environments are buffer-specific.

  • There's a (very small) overhead every time a buffer is created, and that happens quite a lot.

  • direnv updates are not automatic. direnv.el re-executes direnv when switching between buffers that visit files in different directories, whereas envrc-mode caches the environment until the user refreshes it explicitly with envrc-reload.

Overall this approach works well in practice, and feels cleaner than trying to strategically modify the global environment.

It's also possible that there's a way to call direnv more aggressively by allowing it to see values of DIRENV_* obtained previously such that it becomes a no-op.


๐Ÿ’ Support this project and my other Open Source work via Patreon

๐Ÿ’ผ LinkedIn profile

โœ sanityinc.com

Contributors

purcell

224 commits

dependabot[bot]

13 commits

DamienCassou

5 commits

marsam

4 commits

purcell/envrc

Emacs support for direnv which operates buffer-locally

514

stars

271

commits

Emacs Lisp

primary language

Sep 5, 2026

updated

README

MELPA Status MELPA Stable Status Build Status Support me

envrc.el - buffer-local direnv integration for Emacs

A GNU Emacs library which uses the direnv tool to determine per-directory/project environment variables and then set those environment variables on a per-buffer basis. This means that when you work across multiple projects which have .envrc files, all processes launched from the buffers "in" those projects will be executed with the environment variables specified in those files. This allows different versions of linters and other tools to be used in each project if desired.

How does this differ from direnv.el?

direnv.el repeatedly changes the global Emacs environment, based on tracking what buffer you're working on.

Instead, envrc.el simply sets and stores the right environment in each buffer, as a buffer-local variable.

From a user perspective, both are well tested and typically work fine, but the envrc.el approach feels cleaner to me.

Additionally, at the time of writing, envrc.el has early TRAMP support, while direnv.el does not.

Installation

Installable packages are available via MELPA: do M-x package-install RET envrc RET.

Alternatively, download the latest release or clone the repository, and install envrc.el with M-x package-install-file.

Usage

Add a snippet like the following at the bottom of your init.el:

(envrc-global-mode)

or

(add-hook 'after-init-hook 'envrc-global-mode)

or, if you're a use-package fan (don't ask the author about this ๐Ÿ˜):

(use-package envrc
  :hook (after-init . envrc-global-mode))

Why must you enable the global mode late in your startup sequence like this? You normally want envrc-mode to be initialized in each buffer before other minor modes like flycheck-mode which might look for executables. Counter-intuitively, this means that envrc-global-mode should be enabled after other global minor modes, since each prepends itself to various hooks.

The global mode will only have an effect if direnv is installed and available in the default Emacs exec-path. (There is a local minor mode envrc-mode, but you should not try to enable this granularly, e.g. for certain modes or projects, because compilation and other buffers might not get set up with the right environment.)

Regarding interaction with the mode, see envrc-mode-map, and the commands envrc-reload, envrc-allow and envrc-deny.

In particular, you can enable keybindings for the above commands by binding your preferred prefix to envrc-command-map in envrc-mode-map, e.g.

(with-eval-after-load 'envrc
  (define-key envrc-mode-map (kbd "C-c e") 'envrc-command-map))

When and how often direnv is executed

When envrc-mode is enabled for the first time in a particular direnv environment, envrc.el will run direnv export. Subsequent buffers will always re-use the resulting environment indefinitely, i.e. there is no implicit reloading of the direnv environment within Emacs. Use the command envrc-reload to reload the current environment explicitly as needed.

Blocking vs async loading of direnv

By default, envrc-mode blocks Emacs until direnv has finished executing, which means that subsequent mode hooks will be guaranteed to see the desired local environment, and they can use it to find executables they need, like LSP servers or flymake tools. This is therefore a robust default with predictable results.

However, in some cases (particularly with GUIX and Nix) direnv execution can sometimes be very slow, and it is undesirable to block Emacs for that long. For this purpose, envrc.el provides a variable called envrc-async.

When set to t ("always"), Emacs will never be blocked by direnv execution, and will remain completely responsive. After direnv has run, the results will be quietly propagated to the corresponding buffers.

Even with envrc-async set to nil (the default), if you find that Emacs is blocked on direnv, you can hit C-g, and then direnv execution will continue in the background, and its eventual result will still be propagated to the relevant envrc-mode buffers.

As a further option, envrc-async can be set to a number of seconds: this then specifies a time limit for blocking Emacs waiting for direnv. After this time, execution will automatically continue in the background.

When direnv is running asynchronously for the current buffer's environment, the envrc-mode mode-line lighter indicates this with a *.

Troubleshooting

If you find that a particular Emacs command isn't picking up the environment of your current buffer, and you're sure that envrc-mode is active in that buffer, then it's possible you've found code that runs a process in a temp buffer and neglects to propagate your environment to that buffer before doing so.

A couple of common Emacs commands that suffer from this defect are also patched directly via advice in envrc.el โ€” shell-command-to-string is a prominent example!

The inheritenv package was designed to handle this case in general.

lsp-mode users might want to use lsp-deferred rather than lsp, so that path modifications are available when the sub-process is launched.

Design notes

By default, Emacs has a single global set of environment variables used for all subprocesses, stored in the process-environment variable. direnv.el switches that global environment using values from direnv when the user performs certain actions, such as switching between buffers in different projects.

In practice, this is simple and mostly works very well. But there are some quirks, and it feels wrong to me to mutate the global environment in order to support per-directory environments.

Now, in Emacs we can also set process-environment locally in a buffer. If this value could be correctly maintained in all buffers based on their various respective .envrc files, then buffers across multiple projects could simultaneously be "connected" to the environments of their corresponding project directories. I wrote envrc.el to explore this approach.

envrc.el uses a global minor mode (envrc-global-mode) to hook into practically every buffer created by Emacs, including hidden and temporary ones. When a buffer is found to be "inside" an .envrc-managed project, process-environment is set buffer-locally by running direnv, the results of which are also cached indefinitely so that this is not too costly overall. Each buffer has a local minor mode (envrc-mode) with an indicator which displays whether or not a direnv is in effect in that buffer. (Hooking into every buffer is important, rather than just those with certain major modes, since separate temporary, compilation and repl buffers are routinely used for executing processes.)

This approach also has some trade-offs:

  • Buffers like *Help* will have envrc-mode enabled based on the directory of the buffer which caused them to be created initially, and then those buffers often live for a long time. If you launch programs from such buffers while working on a different project, the results might not be what you expect. I might exclude certain modes to minimise confusion, but users will always have to be aware of the fact that environments are buffer-specific.

  • There's a (very small) overhead every time a buffer is created, and that happens quite a lot.

  • direnv updates are not automatic. direnv.el re-executes direnv when switching between buffers that visit files in different directories, whereas envrc-mode caches the environment until the user refreshes it explicitly with envrc-reload.

Overall this approach works well in practice, and feels cleaner than trying to strategically modify the global environment.

It's also possible that there's a way to call direnv more aggressively by allowing it to see values of DIRENV_* obtained previously such that it becomes a no-op.


๐Ÿ’ Support this project and my other Open Source work via Patreon

๐Ÿ’ผ LinkedIn profile

โœ sanityinc.com

Contributors

purcell

224 commits

dependabot[bot]

13 commits

DamienCassou

5 commits

marsam

4 commits

Languages

Emacs Lisp

97.8%

Makefile

2.2%