Best Practices for Cookiecutter Templates
July 6, 2026 · View on GitHub
Guide to best practices for creating maintainable, robust, and usable templates.
Variable Naming
Use consistent and descriptive conventions:
{
"project_name": "My Project",
"project_slug": "my_project",
"pkg_name": "myproject",
"repo_name": "my-project",
"module_name": "core",
"author_name": "Your Name",
"author_email": "you@example.com",
"github_username": "yourusername",
"version": "0.1.0",
"year": "2024"
}
Rules:
project_name→ human readable, with spaces and capitalization.project_slug→ snake_case, for directories and Python imports.repo_name→ kebab-case, for GitHub repo names.pkg_name→ no separators, for PyPI.- Derive automatically with Jinja2 in
cookiecutter.json.
Sensible Defaults
Each variable should have a reasonable default:
{
"version": "0.1.0",
"python_version": "3.11",
"license": ["MIT", "BSD-3-Clause", "Apache-2.0", "GPLv3", "proprietary"],
"use_docker": ["no", "yes"],
"use_ci": ["no", "github", "gitlab"]
}
- Defaults that work out-of-the-box.
- Limited and clear choices (no more than 6-7 options).
"no"before"yes"in binary choices (opt-in).
Validation in pre_gen_project.py
Always validate inputs before generating:
import re
import sys
PROJECT_SLUG_REGEX = r'^[_a-zA-Z][_a-zA-Z0-9]+$'
project_slug = '{{ cookiecutter.project_slug }}'
if not re.match(PROJECT_SLUG_REGEX, project_slug):
print(f'ERROR: "{project_slug}" is not a valid slug.')
sys.exit(1)
if len('{{ cookiecutter.author_name }}') < 2:
print('ERROR: author_name is too short.')
sys.exit(1)
Template Structure
Clear Organization
my-template/
├── cookiecutter.json
├── _extensions/ # Custom filters
├── _copy_without_render # Files without Jinja2
├── hooks/
│ ├── pre_gen_project.py
│ └── post_gen_project.py
├── {{ cookiecutter.project_name }}/
│ ├── README.md
│ ├── setup.py
│ ├── {{ cookiecutter.pkg_name }}/
│ │ ├── __init__.py
│ │ └── ...
│ ├── tests/
│ └── .gitignore
└── README.md # Template docs (not generated)
Template README
Include a README.md at the template root (outside {{ }}) explaining:
- What the template generates.
- Available variables.
- Requirements.
- Usage examples.
Template Testing
Manual Testing
# Generate with defaults
cookiecutter ./my-template --output-dir /tmp/test-output
# Generate non-interactive with specific values
cookiecutter --no-input ./my-template \
project_name="Test Project" \
author_name="Test" \
--output-dir /tmp/test-output
# Verify structure
find /tmp/test-output -type f | head -20
Automated Testing with pytest
# tests/test_template.py
import subprocess
import os
from pathlib import Path
def test_generate_template(tmp_path):
result = subprocess.run(
[
'cookiecutter', '--no-input',
'./my-template',
'project_name=Test Project',
f'--output-dir={tmp_path}',
],
capture_output=True, text=True,
)
assert result.returncode == 0
project_dir = tmp_path / 'test_project'
assert project_dir.exists()
assert (project_dir / 'README.md').exists()
assert (project_dir / 'setup.py').exists()
readme_content = (project_dir / 'README.md').read_text()
assert 'Test Project' in readme_content
Testing with cookiecutter-pytest
pip install cookiecutter-pytest
from cookiecutterpytest import generate_project
def test_project_generation():
project_dir = generate_project(
template_dir='./my-template',
context={'project_name': 'Test Project'},
)
assert os.path.exists(project_dir)
Template Versioning
Use Git tags for versioning:
git tag v1.0.0
git push origin v1.0.0
Users can specify a version:
cookiecutter https://github.com/user/my-template.git --checkout v1.0.0
Changelog
Maintain a CHANGELOG.md at the template root:
# Changelog
## v1.2.0 - 2024-01-15
- Added `use_docker` option
- Updated to Python 3.12
## v1.1.0 - 2023-10-01
- Added GitHub Actions support
Cross-Platform
Paths
# In hooks, use os.path or pathlib
from pathlib import Path
project_root = Path(__file__).parent.parent
System Commands
import sys
import subprocess
pip_executable = 'venv/Scripts/pip' if sys.platform == 'win32' else 'venv/bin/pip'
subprocess.run([pip_executable, 'install', '-r', 'requirements.txt'])
Line Endings
Add a .gitattributes in the template:
* text=auto
*.sh text eol=lf
*.bat text eol=crlf
_copy_without_render
Exclude files that contain {{ }} but are not Jinja2:
# _copy_without_render
*.html
*.vue
*.angular
docs/_build/
node_modules/
*.lock
package-lock.json
Security
- Do not run arbitrary code in hooks without clearly documenting it.
- Do not hardcode secrets in the template.
- Validate inputs in
pre_gen_project.py. - Use
.env.templateinstead of.envwith real values.
Maintainability
- DRY: Use derived variables in
cookiecutter.jsoninstead of asking for the same thing multiple times. - Modular hooks: Separate complex logic into importable modules.
- Document variables: List all variables in the template README.
- Tests: Automate output verification.
- Changelog: Document changes between versions.
Anti-Patterns
| Anti-Pattern | Solution |
|---|---|
Asking for project_slug manually | Derive with Jinja2: `"{{ cookiecutter.project_name |
| Complex conditionals in content | Move logic to post_gen_project.py |
| Not validating inputs | Use pre_gen_project.py with regex |
| Hardcoding OS paths | Use os.path / pathlib |
| Not versioning the template | Use Git tags and changelog |
| Too many variables (>15) | Group or use sensible defaults |