A CLI project template starts with one variable — the project name — and grows. Someone wants Click instead of Typer. Someone needs a Dockerfile, someone else does not. The CI workflow should test Python 3.10 for one team and 3.12 for another. Each request is reasonable, and each one handled carelessly makes the template harder to use and harder to maintain: a dozen questions nobody understands, generated projects full of empty files and commented-out blocks, GitHub Actions expressions mangled because Jinja tried to render ${{ github.sha }}. This guide covers the tools Cookiecutter gives you to keep a growing template clean — derived variables, choices, booleans, readable prompts, conditional content and files, and copying files without rendering — using a small Typer/Click CLI template as the running example. It belongs to the project scaffolding topic, and builds on building a Cookiecutter template for Typer CLIs.
Prerequisites
- Cookiecutter 2.2 or later (
uv tool install cookiecutter), for boolean variables and human-friendly prompts. - A template directory with a
cookiecutter.jsonand a{{cookiecutter.package_name}}/project folder.
Kinds of variables
Every key in cookiecutter.json becomes a variable, and the type of its default value decides how Cookiecutter asks for it:
{
"project_name": "Acme Deploy",
"package_name": "{{ cookiecutter.project_name | lower | replace(' ', '_') | replace('-', '_') }}",
"cli_framework": ["typer", "click"],
"use_docker": false,
"python_min": ["3.11", "3.12", "3.10"],
"_copy_without_render": [".github/workflows/*.yml"],
"__prompts__": {
"project_name": "Name of your CLI",
"cli_framework": {
"__prompt__": "Which framework?",
"typer": "Typer (type hints)",
"click": "Click (decorators)"
},
"use_docker": "Add a Dockerfile?"
}
}
- Strings are free text with a default.
project_nameis the only one most users should need to type. - Derived strings use Jinja and earlier variables.
package_nameis computed fromproject_name, so users get a sensible identifier without being asked — but can still override it. - Lists become choices, and the first item is the default. Put the recommended option first.
- Booleans (Cookiecutter 2.2+) become yes/no questions and render as
True/Falsein templates. - Keys starting with
_are private: never asked, available to the template and to Cookiecutter itself._copy_without_renderis one of Cookiecutter's own settings; your own private keys ("_ruff_version": "0.6") are a tidy place for values the template uses in several files. __prompts__replaces raw variable names with readable questions, and can label each choice.
Order matters: variables are asked in file order, and a derived default can only use variables defined above it.
Conditional content inside files
Most variation belongs inside files, with Jinja conditionals. Keep the generated output clean by controlling whitespace with - in the tags:
[project]
name = "{{ cookiecutter.package_name | replace('_', '-') }}"
version = "0.1.0"
requires-python = ">={{ cookiecutter.python_min }}"
dependencies = [
{%- if cookiecutter.cli_framework == "typer" %}
"typer>=0.12",
{%- else %}
"click>=8.1",
{%- endif %}
]
[project.scripts]
{{ cookiecutter.package_name | replace('_', '-') }} = "{{ cookiecutter.package_name }}.cli:main"
{%- … %} trims the newline before the tag, so the rendered dependencies list has no blank lines. Whole files can switch implementations the same way — the example template's cli.py contains a Typer version and a Click version in one if/else — which works well while the variants are short. When they grow, keep separate files and choose between them in a hook.
Conditional files
Cookiecutter renders every file in the template; it has no built-in "include this file only if…". The idiomatic answer is a post-generation hook that removes what was not asked for:
# hooks/post_gen_project.py
from pathlib import Path
REMOVE_IF = {
"Dockerfile": "{{ cookiecutter.use_docker }}" != "True",
}
for name, remove in REMOVE_IF.items():
if remove:
Path(name).unlink()
Hooks are rendered as Jinja templates before they run, so {{ cookiecutter.use_docker }} becomes the literal True or False — compare it as a string. The hook runs inside the generated project directory, so relative paths work. A table of rules, rather than a series of if statements, makes it easy to see every conditional file at a glance. Post-generation hooks in CLI templates covers hooks in more depth, including git init and dependency installation.
Validation belongs in the pre-generation hook, which runs before any files are written:
# hooks/pre_gen_project.py
import keyword
import re
import sys
PACKAGE = "{{ cookiecutter.package_name }}"
if not re.fullmatch(r"[a-z][a-z0-9_]*", PACKAGE) or keyword.iskeyword(PACKAGE):
print(f"error: '{PACKAGE}' is not a valid Python package name", file=sys.stderr)
sys.exit(1)
A non-zero exit aborts generation, and Cookiecutter removes the partially created directory. "9 Lives" or "class" as a project name now fails with a clear message instead of producing a package that cannot be imported — the keyword check catches names that match the pattern but are reserved words.
Files that must not be rendered
GitHub Actions workflows, Helm charts and some documentation tools use {{ … }} syntax of their own. Rendering them through Jinja either fails or silently replaces their expressions. _copy_without_render lists glob patterns for files to copy byte for byte:
"_copy_without_render": [".github/workflows/*.yml", "docs/overrides/*.html"]
The trade-off is that those files cannot use template variables at all. If a workflow needs both — the project name and ${{ github.sha }} — render it and escape the foreign expressions with Jinja's {% raw %}…{% endraw %} block around them instead.
Sharing values across files
Some values appear in many files: the minimum Python version in pyproject.toml, the CI matrix and the Dockerfile; tool versions in the pre-commit config and the CI workflow. Repeating the logic in each file invites drift inside the template itself. Two techniques keep it in one place.
Private variables hold values computed once:
{
"python_min": ["3.11", "3.12", "3.10"],
"_python_versions": ["3.10", "3.11", "3.12", "3.13"],
"_ruff_version": "0.6.9"
}
Jinja macros hold reusable snippets. Put them in a file excluded from generation (via a leading underscore directory and _copy_without_render, or Cookiecutter's _extensions) and import them where needed — useful for a block such as the list of Python versions at or above python_min:
{%- set versions = cookiecutter._python_versions
| select("ge", cookiecutter.python_min) | list -%}
python-version: {{ versions | tojson }}
String comparison works here because the versions share a format; for anything more complex, compute the list in the pre-generation hook or keep an explicit mapping in a private variable. The goal is that changing "which Python versions do we support?" is a one-line change in cookiecutter.json, not a hunt through every file in the template.
UX considerations
The users of a template answer its questions once, often while in a hurry:
- Ask as little as possible. Derive what you can, default the rest, and make the first choice the recommended one. Every extra question is a chance to get it wrong.
- Write questions, not variable names.
__prompts__costs a few lines and makes the template usable by people who did not write it. - Prefer a few coarse switches to many fine ones. "Add a Dockerfile?" is better than five separate container options; each boolean doubles the number of possible outputs you must keep working.
- Fail early and clearly. Validate names and combinations in the pre-generation hook, before files exist.
- Support non-interactive use.
cookiecutter --no-input gh:acme/cli-template project_name="Acme Deploy" use_docker=trueshould always work, which it does when every variable has a sensible default.
Testing the behaviour
Each variable multiplies the number of possible projects, so test the combinations that matter rather than trusting a manual run. Cookiecutter's Python API makes that straightforward:
# tests/test_variables.py
from pathlib import Path
import pytest
from cookiecutter.exceptions import FailedHookException
from cookiecutter.main import cookiecutter
TEMPLATE = Path(__file__).resolve().parents[1]
def bake(tmp_path, **context) -> Path:
return Path(cookiecutter(str(TEMPLATE), no_input=True, output_dir=str(tmp_path),
extra_context=context))
@pytest.mark.parametrize("use_docker", [True, False])
def test_dockerfile_is_conditional(tmp_path, use_docker):
assert (bake(tmp_path, use_docker=use_docker) / "Dockerfile").exists() is use_docker
def test_workflow_is_copied_without_rendering(tmp_path):
workflow = (bake(tmp_path) / ".github/workflows/test.yml").read_text()
assert "${{ github.sha }}" in workflow
def test_invalid_name_fails_before_writing(tmp_path):
with pytest.raises(FailedHookException):
bake(tmp_path, project_name="9 Lives")
assert not any(tmp_path.iterdir())
Testing a project template with pytest extends this to running each generated project's own test suite and linters.
Conclusion
A template stays pleasant as it grows if its variables do most of the work: derive identifiers from the project name, offer choices with the recommended one first, use booleans for coarse switches, and phrase every question with __prompts__. Put small variations inside files with whitespace-trimmed Jinja, remove optional files in a post-generation hook driven by a single rules table, validate in the pre-generation hook, and copy files with their own {{ }} syntax without rendering. Then test the combinations, because nobody will try them all by hand.
Frequently asked questions
Can a variable depend on a choice made earlier?
Yes, as long as it comes later in cookiecutter.json: "test_command": "{{ 'pytest' if cookiecutter.cli_framework == 'typer' else 'pytest -p no:cacheprovider' }}". Hide it with a leading underscore if users should never change it.
How do I make a whole directory conditional?
The same way as a file: remove it in the post-generation hook with shutil.rmtree. Some templates instead name the directory with a Jinja expression that renders to an empty string when disabled, but explicit removal is easier to read and test.
Does Copier handle conditional files better?
Copier lets file and directory names render to an empty string to skip them, and supports when: conditions on questions so irrelevant questions are not asked — both genuine conveniences. See Copier vs Cookiecutter for CLI templates.
Why does my boolean always look true in the hook?
Because the hook compares strings: the rendered values are "True" and "False", and any non-empty string is truthy in Python. Compare against "True" explicitly, as the hook above does.