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.template instead of .env with real values.

Maintainability

  1. DRY: Use derived variables in cookiecutter.json instead of asking for the same thing multiple times.
  2. Modular hooks: Separate complex logic into importable modules.
  3. Document variables: List all variables in the template README.
  4. Tests: Automate output verification.
  5. Changelog: Document changes between versions.

Anti-Patterns

Anti-PatternSolution
Asking for project_slug manuallyDerive with Jinja2: `"{{ cookiecutter.project_name
Complex conditionals in contentMove logic to post_gen_project.py
Not validating inputsUse pre_gen_project.py with regex
Hardcoding OS pathsUse os.path / pathlib
Not versioning the templateUse Git tags and changelog
Too many variables (>15)Group or use sensible defaults

Next Steps