diff --git a/modules/misc/news.nix b/modules/misc/news.nix
index 39527efea..4b432dfaf 100644
--- a/modules/misc/news.nix
+++ b/modules/misc/news.nix
@@ -1150,6 +1150,13 @@ in
           A new modules is available: 'programs.darcs'
         '';
       }
+
+      {
+        time = "2023-07-08T09:21:06+00:00";
+        message = ''
+          A new module is available: 'programs.pyenv'.
+        '';
+      }
     ];
   };
 }
diff --git a/modules/modules.nix b/modules/modules.nix
index 6db9c29f3..8822eeed8 100644
--- a/modules/modules.nix
+++ b/modules/modules.nix
@@ -169,6 +169,7 @@ let
     ./programs/pls.nix
     ./programs/powerline-go.nix
     ./programs/pubs.nix
+    ./programs/pyenv.nix
     ./programs/pylint.nix
     ./programs/qutebrowser.nix
     ./programs/rbw.nix
diff --git a/modules/programs/pyenv.nix b/modules/programs/pyenv.nix
new file mode 100644
index 000000000..a83713bbf
--- /dev/null
+++ b/modules/programs/pyenv.nix
@@ -0,0 +1,79 @@
+{ config, pkgs, lib, ... }:
+
+let
+
+  cfg = config.programs.pyenv;
+
+  tomlFormat = pkgs.formats.toml { };
+
+in {
+  meta.maintainers = with lib.maintainers; [ tmarkus ];
+
+  options.programs.pyenv = {
+    enable = lib.mkEnableOption "pyenv";
+
+    package = lib.mkOption {
+      type = lib.types.package;
+      default = pkgs.pyenv;
+      defaultText = lib.literalExpression "pkgs.pyenv";
+      description = "The package to use for pyenv.";
+    };
+
+    enableBashIntegration = lib.mkOption {
+      type = lib.types.bool;
+      default = true;
+      description = ''
+        Whether to enable pyenv's Bash integration.
+      '';
+    };
+
+    enableZshIntegration = lib.mkOption {
+      type = lib.types.bool;
+      default = true;
+      description = ''
+        Whether to enable pyenv's Zsh integration.
+      '';
+    };
+
+    enableFishIntegration = lib.mkOption {
+      type = lib.types.bool;
+      default = true;
+      description = ''
+        Whether to enable pyenv's Fish integration.
+      '';
+    };
+
+    rootDirectory = lib.mkOption {
+      type = lib.types.path;
+      apply = toString;
+      default = "${config.xdg.dataHome}/pyenv";
+      defaultText = "\${config.xdg.dataHome}/pyenv";
+      description = ''
+        The pyenv root directory (PYENV_ROOT).
+        </para><para>
+        Note: Deviating from upstream which uses `$HOME/.pyenv`,
+        the default path is set according to the XDG base directory specification.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    # Always add the configured `pyenv` package.
+    home.packages = [ cfg.package ];
+
+    programs.bash.initExtra = lib.mkIf cfg.enableBashIntegration ''
+      export PYENV_ROOT="${cfg.rootDirectory}"
+      eval "$(${lib.getExe cfg.package} init - bash)"
+    '';
+
+    programs.zsh.initExtra = lib.mkIf cfg.enableZshIntegration ''
+      export PYENV_ROOT="${cfg.rootDirectory}"
+      eval "$(${lib.getExe cfg.package} init - zsh)"
+    '';
+
+    programs.fish.interactiveShellInit = lib.mkIf cfg.enableFishIntegration ''
+      set -Ux PYENV_ROOT "${cfg.rootDirectory}"
+      ${lib.getExe cfg.package} init - fish | source
+    '';
+  };
+}
diff --git a/tests/default.nix b/tests/default.nix
index 738b158b2..c21b4b7e6 100644
--- a/tests/default.nix
+++ b/tests/default.nix
@@ -117,6 +117,7 @@ import nmt {
     ./modules/programs/pls
     ./modules/programs/powerline-go
     ./modules/programs/pubs
+    ./modules/programs/pyenv
     ./modules/programs/qutebrowser
     ./modules/programs/readline
     ./modules/programs/ripgrep
diff --git a/tests/modules/programs/pyenv/bash.nix b/tests/modules/programs/pyenv/bash.nix
new file mode 100644
index 000000000..ac6a8f0c1
--- /dev/null
+++ b/tests/modules/programs/pyenv/bash.nix
@@ -0,0 +1,17 @@
+{ ... }:
+
+{
+  programs = {
+    bash.enable = true;
+    pyenv.enable = true;
+  };
+
+  test.stubs.pyenv = { name = "pyenv"; };
+
+  nmt.script = ''
+    assertFileExists home-files/.bashrc
+    assertFileContains \
+      home-files/.bashrc \
+      'eval "$(@pyenv@/bin/pyenv init - bash)"'
+  '';
+}
diff --git a/tests/modules/programs/pyenv/default.nix b/tests/modules/programs/pyenv/default.nix
new file mode 100644
index 000000000..c2e650587
--- /dev/null
+++ b/tests/modules/programs/pyenv/default.nix
@@ -0,0 +1,5 @@
+{
+  pyenv-bash = ./bash.nix;
+  pyenv-zsh = ./zsh.nix;
+  pyenv-fish = ./fish.nix;
+}
diff --git a/tests/modules/programs/pyenv/fish.nix b/tests/modules/programs/pyenv/fish.nix
new file mode 100644
index 000000000..41b4911bb
--- /dev/null
+++ b/tests/modules/programs/pyenv/fish.nix
@@ -0,0 +1,17 @@
+{ ... }:
+
+{
+  programs = {
+    fish.enable = true;
+    pyenv.enable = true;
+  };
+
+  test.stubs.pyenv = { name = "pyenv"; };
+
+  nmt.script = ''
+    assertFileExists home-files/.config/fish/config.fish
+    assertFileContains \
+      home-files/.config/fish/config.fish \
+      '@pyenv@/bin/pyenv init - fish | source'
+  '';
+}
diff --git a/tests/modules/programs/pyenv/zsh.nix b/tests/modules/programs/pyenv/zsh.nix
new file mode 100644
index 000000000..da029fc2c
--- /dev/null
+++ b/tests/modules/programs/pyenv/zsh.nix
@@ -0,0 +1,17 @@
+{ ... }:
+
+{
+  programs = {
+    zsh.enable = true;
+    pyenv.enable = true;
+  };
+
+  test.stubs.pyenv = { name = "pyenv"; };
+
+  nmt.script = ''
+    assertFileExists home-files/.zshrc
+    assertFileContains \
+      home-files/.zshrc \
+      'eval "$(@pyenv@/bin/pyenv init - zsh)"'
+  '';
+}