diff --git a/cmake/ManageCodegenVenv.py b/cmake/ManageCodegenVenv.py
index 3b13820e757aecac8d8123e7ced4e6c40a036722..740d66cb52b9b53fc731a72231968afe45c6309d 100644
--- a/cmake/ManageCodegenVenv.py
+++ b/cmake/ManageCodegenVenv.py
@@ -17,6 +17,7 @@ class VenvState:
     venv_dir: str | None = None
     main_requirements_file: str | None = None
     initialized: bool = False
+    populated: bool = False
 
     user_requirements: list[list[str]] = field(default_factory=list)
     user_requirements_hash: str | None = None
@@ -86,6 +87,7 @@ def action_initialize(args):
 
         #   Reset user requirements
         state.user_requirements = []
+        state.populated = False
 
 
 def action_require(args):
@@ -93,7 +95,14 @@ def action_require(args):
 
     with VenvState.lock(statefile) as state:
         if not state.initialized:
-            raise RuntimeError("Virtual environment is not initialized.")
+            raise RuntimeError(
+                "venv-require action failed: virtual environment was not initialized"
+            )
+
+        if state.populated:
+            raise RuntimeError(
+                "venv-require action failed: cannot add requirements after venv-populate was run"
+            )
 
         state.user_requirements.append(list(args.requirements))
 
@@ -103,7 +112,11 @@ def action_populate(args):
 
     with VenvState.lock(statefile) as state:
         if not state.initialized:
-            raise RuntimeError("Virtual environment is not initialized.")
+            raise RuntimeError("venv-populate action failed: virtual environment was not initialized")
+
+        if state.populated:
+            raise RuntimeError("venv-populate action failed: venv-populate action called twice")
+
         h = hashlib.sha256()
         for req in state.user_requirements:
             h.update(bytes(";".join(str(r) for r in req), encoding="utf8"))
@@ -124,6 +137,13 @@ def action_populate(args):
                 subprocess.run(install_args).check_returncode()
 
         state.user_requirements_hash = digest
+        state.populated = True
+
+
+def fail(*args: str):
+    for arg in args:
+        print(arg, file=sys.stderr)
+    exit(-1)
 
 
 def main():
@@ -159,8 +179,13 @@ def main():
     parser_populate = subparsers.add_parser("populate")
     parser_populate.set_defaults(func=action_populate)
 
-    args = parser.parse_args()
-    args.func(args)
+    try:
+        args = parser.parse_args()
+        args.func(args)
+    except RuntimeError as e:
+        fail(*e.args)
+    except subprocess.CalledProcessError as e:
+        fail()
 
 
 if __name__ == "__main__":
diff --git a/cmake/WalberlaCodegen.cmake b/cmake/WalberlaCodegen.cmake
index 73803d4a5d5e9e1e32cb50e6c6eb14854a87a7dc..c01b3fe2cd9eece2b7af555953648f2211e0e672 100644
--- a/cmake/WalberlaCodegen.cmake
+++ b/cmake/WalberlaCodegen.cmake
@@ -57,10 +57,11 @@ if( WALBERLA_CODEGEN_PRIVATE_VENV )
             init
             ${_init_args}
         RESULT_VARIABLE _lastResult
+        ERROR_VARIABLE _lastError
     )
 
     if( ${_lastResult} )
-        message( FATAL_ERROR "Codegen virtual environment setup failed" )
+        message( FATAL_ERROR "Codegen virtual environment setup failed:\n${_lastError}" )
     endif()
 
     set(
@@ -133,10 +134,11 @@ function(walberla_codegen_venv_require)
             --
             ${ARGV}
         RESULT_VARIABLE _lastResult
+        ERROR_VARIABLE _lastError
     )
 
     if( ${_lastResult} )
-        message( FATAL_ERROR "venv-require: Operation failed" )
+        message( FATAL_ERROR ${_lastError} )
     endif()
 endfunction()
 
@@ -150,10 +152,11 @@ function(walberla_codegen_venv_populate)
             $CACHE{_WALBERLA_CODEGEN_VENV_INVOKE_MANAGER}
             populate
         RESULT_VARIABLE _lastResult
+        ERROR_VARIABLE _lastError
     )
 
     if( ${_lastResult} )
-        message( FATAL_ERROR "venv-populate: Operation failed" )
+        message( FATAL_ERROR ${_lastError} )
     endif()
 endfunction()
 
diff --git a/user_manual/index.md b/user_manual/index.md
index 29564ac46045ef4171c75bd23f1ee63d1e19985c..20dee507cbf9a43724a7b4e5b51fc97bfd791e74 100644
--- a/user_manual/index.md
+++ b/user_manual/index.md
@@ -38,7 +38,7 @@ examples/ForceDrivenChannel/ForceDrivenChannel
 :caption: Reference
 :maxdepth: 1
 
-Python Environment <reference/PythonEnvironment>
+reference/PythonEnvironment
 :::
 
 
diff --git a/user_manual/reference/PythonEnvironment.md b/user_manual/reference/PythonEnvironment.md
index df1530814c6606a9998f221ee30dd2d125c55728..6249039d929851587849991474eeb6260df4d5ed 100644
--- a/user_manual/reference/PythonEnvironment.md
+++ b/user_manual/reference/PythonEnvironment.md
@@ -1,38 +1,45 @@
-# Managing the Code Generator's Python Environment
+# Python Environment for Code Generation
 
-On this page, you can find information on managing, customizing, and extending the Python environment
-used by the waLBerla code generation system.
+The waLBerla build system will set up a [virtual Python environment][venv] inside its
+build tree, and use its Python interpreter to run code generation scripts.
+On this page, you can find reference information on how this Python environment can be customized.
 
-## Using the Private Virtual Environment
+## Setting the Base Interpreter
 
-By default, `sfg-walberla` creates a new Python virtual environment within the CMake build tree,
-and there installs all packages required for code generation.
-This can be disabled by setting the `WALBERLA_CODEGEN_PRIVATE_VENV` CMake cache variable to `FALSE`.
+WaLBerla uses [FindPython][FindPython] to locate the base Python interpreter
+which will be used to create the virtual environment.
+Refer to its documentation for ways to affect the discovery process.
 
-### Install Additional Packages
+## Adding Additional Packages
 
-For projects that require external dependencies, *sfg-walberla* exposes the CMake function
-`walberla_codegen_venv_install`, which can be used to install additional packages into the
-code generator virtual environment;
-for instance, the following invocation installs `pyyaml`:
+To install additional packages into the code generation environment,
+first register them in your CMake file using the `walberla_codegen_venv_require` function.
+This function can be invoked multiple times to add multiple requirements.
+The arguments to `walberla_codegen_venv_require` will be directly forwarded to `pip install`,
+so you can include any options `pip install` understands to affect the installation.
 
-```CMake
-walberla_codegen_venv_install( pyyaml )
-```
+Calls to `walberla_codegen_venv_require` will only collect the set of requirements.
+To perform the installation, `walberla_codegen_venv_populate` must be called after all
+requirements are declared.
+
+:::{card} Example
 
-The arguments passed to `walberla_codegen_venv_install` are forwarded directly to `pip install`.
-You can therefore use any parameters that `pip` can interpret, for instance `-e` to perform an
-editable install, or `-r <requirements-file>` to install packages from a requirements file.
+```CMake
+#   First, list requirements
+walberla_codegen_venv_require( pycowsay )  # Require a single package
+walberla_codegen_venv_require( -r my-requirements.txt )  # Specify a requirements file
 
-## Using an External Virtual Environment
+#   Then, populate the virtual environment
+walberla_codegen_venv_populate()
+```
 
-If `WALBERLA_CODEGEN_PRIVATE_VENV` is set to `FALSE`, sfg-walberla will use the Python interpreter
-found in the CMake environment for running the code generators.
-You can customize your Python interpreter by setting the `Python_EXECUTABLE` or `Python_ROOT_DIR` hints.
+:::
 
-:::{seealso}
-[FindPython CMake Module](https://cmake.org/cmake/help/latest/module/FindPython.html)
+:::{error}
+It is an error for your CMake system to call
+`walberla_codegen_venv_require` after `walberla_codegen_venv_populate`,
+or to call `walberla_codegen_venv_populate` more than once.
 :::
 
-Sfg-walberla will check if the required packages are installed into the given external Python environment,
-and raise an error if any are missing.
+[venv]: https://docs.python.org/3/library/venv.html
+[FindPython]: https://cmake.org/cmake/help/latest/module/FindPython.html