A template can only do so much with Jinja. Some work has to happen as code: rejecting a command name that would shadow a system tool, deleting the Dockerfile when the user said they did not want one, creating the git repository and lockfile, and telling the user what to do next. Cookiecutter runs two optional Python scripts around generation for exactly this — pre_gen_project.py before any files are written, and post_gen_project.py inside the freshly generated project. This guide covers what belongs in each hook for a CLI template, how to write them so they are fast and safe on other people's machines, and how to test them. It builds on building a cookiecutter template for Typer CLIs and belongs to the CLI project scaffolding topic.
Prerequisites
- A working cookiecutter template with a
hooks/directory next tocookiecutter.json. - git and uv available on the machines that will generate projects (the hooks check for them and degrade gracefully when they are missing).
When hooks run
Cookiecutter collects answers, runs hooks/pre_gen_project.py (rendered through Jinja first, so it can read the answers), renders the template tree, then runs hooks/post_gen_project.py with the current working directory set to the new project. If either hook exits non-zero, cookiecutter stops and deletes whatever it generated, so a failed validation never leaves a half-made project behind.
Because hooks are rendered as Jinja templates, answers appear in them as literal values: "{{ cookiecutter.command_name }}" inside a Python string becomes "dh" before the script runs.
The recipe: validating answers before anything is written
The pre-generation hook is the place to reject answers that would produce a broken project:
# hooks/pre_gen_project.py
import keyword
import re
import shutil
import sys
package = "{{ cookiecutter.package_name }}"
command = "{{ cookiecutter.command_name }}"
errors = []
if not re.fullmatch(r"[a-z][a-z0-9_]*", package) or keyword.iskeyword(package):
errors.append(f"package name {package!r} is not a valid Python identifier")
if not re.fullmatch(r"[a-z][a-z0-9-]*", command):
errors.append(f"command {command!r} should be lowercase letters, digits and dashes")
if shutil.which(command):
errors.append(f"command {command!r} already exists on this machine ({shutil.which(command)}); "
"users would get the wrong program")
if errors:
for e in errors:
print(f"error: {e}", file=sys.stderr)
sys.exit(1)
The shadowing check is specific to CLI templates and surprisingly valuable: a tool named test, deploy or sync collides with existing commands on many machines, and the resulting confusion ("why does test --help print nothing?") is miserable to debug later.
The recipe: finishing the project after rendering
The post-generation hook handles everything that cannot be expressed as a file: removing optional parts, initialising version control, locking dependencies and printing next steps.
# hooks/post_gen_project.py
import shutil
import subprocess
import sys
from pathlib import Path
INCLUDE_DOCKER = "{{ cookiecutter.include_docker }}" == "yes"
PROJECT = "{{ cookiecutter.project_slug }}"
COMMAND = "{{ cookiecutter.command_name }}"
def remove(*paths: str) -> None:
for p in map(Path, paths):
if p.is_dir():
shutil.rmtree(p)
elif p.exists():
p.unlink()
def run(*cmd: str) -> bool:
"""Run a helper command; report and continue if it is missing or fails."""
if shutil.which(cmd[0]) is None:
print(f"note: {cmd[0]} not found; skipped: {' '.join(cmd)}", file=sys.stderr)
return False
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"note: '{' '.join(cmd)}' failed:\n{result.stderr.strip()}", file=sys.stderr)
return False
return True
if not INCLUDE_DOCKER:
remove("Dockerfile", ".dockerignore")
locked = run("uv", "lock")
if run("git", "init", "-q", "-b", "main"):
run("git", "add", "-A")
run("git", "commit", "-q", "-m", f"Initial {PROJECT} from template")
print(f"""
Created {PROJECT}. Next steps:
cd {PROJECT}
{"uv sync" if locked else "uv lock && uv sync"}
uv run {COMMAND} --help
uv run pytest
""")
Choosing template logic or a hook
Small optional differences — an extra dependency line, a conditional section in the README — read best as {% if %} blocks in the template. Whole optional files and directories are clearer to delete in the hook: a template tree with conditionally empty file names is hard to follow, whereas remove("Dockerfile") says exactly what happens.
Principles for hooks on other people's machines
- Degrade, do not fail, on missing tools. A developer without uv installed should still get a project, plus a note saying which step was skipped. Only validation failures in the pre hook should abort generation.
- Stay local and fast.
uv lockdownloads metadata, which is usually acceptable; anything slower, or anything that needs credentials — creating a GitHub repository, registering a PyPI name — belongs in documentation or a separate command the user chooses to run. - Use only the standard library. Hooks run in cookiecutter's environment, not the new project's. Importing a third-party package in a hook fails for anyone who does not happen to have it installed.
- Never touch anything outside the new directory. The hook's working directory is the generated project; keep it there.
Debugging hooks
Hook failures are confusing because the hook runs as a rendered copy of your script in a temporary location, and cookiecutter deletes the half-generated output when it fails. A few habits make them tractable:
- Run cookiecutter with
--verbose. It prints the rendered hook path and the command used to run it, so you can see exactly what executed. - Keep failed output while debugging.
--keep-project-on-failureleaves the generated directory in place after a post-hook failure, so you can inspect what was rendered and rerun the hook by hand inside it. - Check the rendered script first. Most hook bugs are Jinja bugs: a variable that renders to an empty string, or a quote inside an answer that breaks the Python string it was substituted into. Rendering answers through Python literals —
PROJECT = {{ cookiecutter.project_slug | tojson }}— avoids the quoting problem entirely, becausetojsonproduces a valid Python string literal for any value. - Remember Windows. Hooks run with the interpreter that runs cookiecutter, on whatever platform the user has. Use
pathlibandsubprocessargument lists rather than shell strings, so the same hook works in PowerShell and bash alike.
UX considerations
- End with the next three commands. The single most useful thing a post hook can do is tell the user how to run what they just created. Tailor it to what actually succeeded — as the
lockedflag does above. - Explain validation failures in the user's terms. "command 'test' already exists on this machine (/usr/bin/test)" explains the problem and implies the fix.
- Make the initial commit meaningful. A first commit containing exactly what the template produced makes it easy to see later which changes the team made on top.
- Keep output short. Notes about skipped steps on stderr, the next-steps block on stdout, nothing else.
Testing the behaviour
Hooks run as part of cookiecutter(...), so the same generate-and-inspect tests from the template guide exercise them. Add cases for each branch:
# tests/test_hooks.py
import shutil
import subprocess
from pathlib import Path
import pytest
from cookiecutter.exceptions import FailedHookException
from cookiecutter.main import cookiecutter
TEMPLATE = str(Path(__file__).parent.parent)
def generate(tmp_path, **context) -> Path:
return Path(cookiecutter(TEMPLATE, no_input=True, output_dir=str(tmp_path),
extra_context=context))
def test_docker_files_removed_when_not_wanted(tmp_path):
project = generate(tmp_path, project_name="Alpha Tool", include_docker="no")
assert not (project / "Dockerfile").exists()
def test_git_repository_and_lockfile_created(tmp_path):
if not (shutil.which("git") and shutil.which("uv")):
pytest.skip("needs git and uv")
project = generate(tmp_path, project_name="Beta Tool")
assert (project / "uv.lock").exists()
log = subprocess.run(["git", "log", "--oneline"], cwd=project, capture_output=True, text=True)
assert "Initial beta-tool from template" in log.stdout
def test_shadowing_command_is_rejected(tmp_path):
with pytest.raises(FailedHookException):
generate(tmp_path, project_name="Gamma", command_name="ls")
assert list(tmp_path.iterdir()) == [] # nothing left behind
def test_invalid_package_name_is_rejected(tmp_path):
with pytest.raises(FailedHookException):
generate(tmp_path, project_name="Class", package_name="class")
The "nothing left behind" assertion verifies cookiecutter's cleanup on hook failure — the behaviour users rely on when they mistype an answer. Run these tests in the template's CI; hooks are exactly the part of a template that breaks silently when a tool changes its command-line flags.
Conclusion
Hooks are where a template stops being a pile of files and becomes a tool. Use the pre-generation hook to reject answers that would produce broken or confusing projects — invalid identifiers, commands that shadow existing programs — and the post-generation hook to remove unused files, lock dependencies, create the first commit and print the next steps. Keep hooks fast, local and standard-library-only, let them degrade gracefully when tools are missing, and test every branch by generating projects in pytest.
Frequently asked questions
Can hooks be shell scripts instead of Python?
Yes — pre_gen_project.sh works on POSIX systems. Python hooks are the better choice for CLI templates because they run identically on Windows, macOS and Linux, and your template's users are Python developers anyway.
How do I pass data from the pre hook to the post hook?
There is no direct channel. Anything both hooks need should be a cookiecutter variable, possibly a private one (prefixed with an underscore) computed from other answers in cookiecutter.json.
Should the post hook run the tests?
It can, but it slows generation and makes it fail for reasons unrelated to the template (a flaky network during uv sync). Printing uv run pytest as a next step is usually better; the template's own CI already proves generated projects pass.
Does Copier have the same hooks?
Copier calls them tasks, defined in copier.yml as commands run after copying, and adds migrations for template updates. The same principles apply: validate early, keep tasks local and fast, and test them.