Fix: Cookiecutter Not Working — Template Errors, Variable Substitution, and Hook Failures
Part of: Python Errors
Quick Answer
How to fix Cookiecutter errors — cookiecutter.json not found, variable substitution failed Jinja2, pre/post-generation hooks failed, no_input mode missing values, private repo authentication, and copier vs cookiecutter.
The Error
You try to generate a project from a template and it fails:
$ cookiecutter https://github.com/me/my-template
A valid repository for "https://github.com/me/my-template" could not be foundOr template variables don’t substitute:
# {{ cookiecutter.project_name }}/main.py
app_name = "{{ cookiecutter.project_name }}" # Renders literally instead of substitutingOr pre-generation hooks fail with no clear error:
$ cookiecutter my-template
ERROR: Stopping generation because pre_gen_project hook script didn't exit successfully
# No further detailsOr the generated directory has a literal {{ cookiecutter.project_slug }} folder name:
my-template/
└── {{ cookiecutter.project_slug }}/ ← Literal name, not substituted
└── main.pyOr --no-input mode skips required variables and breaks:
$ cookiecutter --no-input my-template
# Uses defaults — but a required variable had no default, breaks silentlyCookiecutter is the standard Python project scaffolder — point it at a template (local dir or git URL), answer prompts, and get a generated project. It’s used by Django, Flask, FastAPI, and most Python framework starter kits. The Jinja2-based templating is powerful but template authors hit specific issues around variable scoping, hook execution, and the difference between development and use. This guide covers each.
Why This Happens
Cookiecutter renders a template directory using Jinja2 syntax. The template root must contain cookiecutter.json (defining variables) and at least one directory named with a Jinja2 expression (typically {{ cookiecutter.project_slug }}). Cookiecutter prompts the user for variable values, then renders every file and directory name through Jinja2.
The dual nature — template directory containing literal Jinja2 syntax that becomes a real project after rendering — confuses both template users and template authors.
Fix 1: Installing and Basic Use
pip install cookiecutter
# Or via pipx (recommended for global use)
pipx install cookiecutterGenerate from a template:
# From a git URL
cookiecutter https://github.com/cookiecutter/cookiecutter-django
# From a local directory
cookiecutter ./my-template/
# From a specific branch or tag
cookiecutter https://github.com/me/my-template --checkout v2.0
# Skip prompts (use defaults from cookiecutter.json)
cookiecutter ./my-template/ --no-input
# Override specific values
cookiecutter ./my-template/ project_name="My Project" author_name="Alice"Common Mistake: Pointing cookiecutter at a non-template repo. The repo must contain cookiecutter.json at the root — without it, cookiecutter fails with “valid repository could not be found.” Always verify the repo has a cookiecutter.json before trying to use it as a template.
Generate without prompts in CI:
cookiecutter my-template/ --no-input \
project_name="My App" \
project_slug=my_app \
author_name="CI Bot"Fix 2: cookiecutter.json — Variables and Defaults
{
"project_name": "My Awesome Project",
"project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_').replace('-', '_') }}",
"author_name": "Your Name",
"email": "[email protected]",
"version": "0.1.0",
"license": ["MIT", "BSD-3-Clause", "Apache-2.0"],
"use_docker": ["yes", "no"],
"_copy_without_render": [
"*.html",
"frontend/templates/*"
]
}Variable types:
| Type | Example | Meaning |
|---|---|---|
| String | "My Project" | Free-text input, default value |
| Choice list | ["MIT", "BSD"] | First value is default; user picks from list |
| Derived | "{{ cookiecutter.x }}_suffix" | Computed from other vars |
| Boolean (yes/no) | ["yes", "no"] | Choice list with yes/no |
Derived variables use Jinja2 — useful for computed defaults:
{
"project_name": "My Project",
"project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_') }}",
"package_name": "{{ cookiecutter.project_slug }}",
"year": "2025"
}When the user types “Hello World” for project_name, project_slug auto-computes to hello_world.
Private variables (start with _) don’t prompt the user:
{
"project_name": "...",
"_copy_without_render": ["docs/*.html"],
"_extensions": ["jinja2_time.TimeExtension"]
}_copy_without_render lists patterns NOT rendered through Jinja2 — useful for files that contain {{ syntax for other tools (HTML templates, Jinja files in the generated project).
Common Mistake: Forgetting _copy_without_render for HTML or Jinja templates in the generated project. Cookiecutter tries to render them and fails on syntax conflicts. List them in _copy_without_render to copy verbatim.
Fix 3: Template Directory Structure
my-template/
├── cookiecutter.json
├── hooks/
│ ├── pre_gen_project.py # Optional: runs BEFORE generation
│ └── post_gen_project.py # Optional: runs AFTER generation
└── {{ cookiecutter.project_slug }}/ # The actual template directory
├── README.md
├── pyproject.toml
├── {{ cookiecutter.package_name }}/
│ ├── __init__.py
│ └── main.py
└── tests/
└── test_main.pyThe top-level template directory MUST use Jinja2 syntax for its name:
my-template/
├── cookiecutter.json
└── {{ cookiecutter.project_slug }}/ ← Must be a Jinja2 expression
└── ...Without this, cookiecutter generates a literal {{ cookiecutter.project_slug }} directory.
Pro Tip: Always name your top-level template dir {{ cookiecutter.project_slug }} (not {{ cookiecutter.project_name }}). Names contain spaces and special chars; slugs are filesystem-safe. Using project_name produces directories like My Project/ with a space — works on Linux/macOS but trips up many build tools.
File names also support Jinja2:
{{ cookiecutter.project_slug }}/
├── {{ cookiecutter.package_name }}.py # File name templated
└── {% if cookiecutter.use_docker == 'yes' %}Dockerfile{% endif %}Conditional files (file generated only if condition is true) use {% if %} in the name. If the expression evaluates to empty, no file is created.
Fix 4: Jinja2 Templating in Files
Inside files, use standard Jinja2:
# {{ cookiecutter.project_slug }}/main.py
"""{{ cookiecutter.project_name }}
Authored by {{ cookiecutter.author_name }} <{{ cookiecutter.email }}>
"""
VERSION = "{{ cookiecutter.version }}"
{% if cookiecutter.use_docker == "yes" %}
import os
DOCKER_MODE = True
{% else %}
DOCKER_MODE = False
{% endif %}
def main():
print(f"Hello from {VERSION}")
if __name__ == "__main__":
main()Common Jinja2 patterns:
{# This is a Jinja2 comment, doesn't appear in output #}
{# Conditionals #}
{% if cookiecutter.framework == "fastapi" %}
import fastapi
{% endif %}
{# Loops #}
{% for dep in cookiecutter.dependencies.split(",") %}
{{ dep.strip() }}
{% endfor %}
{# String manipulation #}
{{ cookiecutter.project_name | upper }}
{{ cookiecutter.project_name | replace(" ", "-") }}
{{ cookiecutter.project_name | length }}Common Mistake: Forgetting that Cookiecutter renders every text file as Jinja2. If your template contains a file with literal {{ or {% (e.g., a Vue.js template, Django template, GitHub Actions workflow with ${{ }}), the render fails or produces wrong output. Either:
- Add the path to
_copy_without_renderin cookiecutter.json - Escape:
{{ "{{" }}produces literal{{ - Use
{% raw %}...{% endraw %}blocks for sections that should pass through
{# Escape for GitHub Actions syntax #}
{% raw %}
on:
push:
branches: [main]
env:
VERSION: ${{ github.sha }}
{% endraw %}Fix 5: Pre and Post-Generation Hooks
Pre-generation hook runs before rendering — validate inputs, transform variables:
# hooks/pre_gen_project.py
import re
import sys
PROJECT_SLUG = "{{ cookiecutter.project_slug }}"
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", PROJECT_SLUG):
print(f"ERROR: '{PROJECT_SLUG}' is not a valid Python module name")
sys.exit(1)Exit non-zero to abort generation.
Post-generation hook runs after rendering — initialize git, install deps, remove unwanted files:
# hooks/post_gen_project.py
import os
import shutil
import subprocess
USE_DOCKER = "{{ cookiecutter.use_docker }}" == "yes"
if not USE_DOCKER:
if os.path.exists("Dockerfile"):
os.remove("Dockerfile")
if os.path.exists(".dockerignore"):
os.remove(".dockerignore")
# Initialize git
subprocess.run(["git", "init"], check=True)
subprocess.run(["git", "add", "."], check=True)
subprocess.run(["git", "commit", "-m", "Initial commit"], check=True)
print("Project created! Next steps:")
print(" cd {{ cookiecutter.project_slug }}")
print(" pip install -e .")Hooks run in the generated directory for post-gen and the template directory for pre-gen. Their working directory matters.
Common Mistake: Using cookiecutter syntax {{ }} in hook scripts and being confused when it works — yes, hook scripts are also rendered through Jinja2 before execution. You can use {{ cookiecutter.use_docker }} directly in hooks/post_gen_project.py — cookiecutter substitutes the value before running the script. Don’t escape it; it’s intended.
Fix 6: Choice Variables and Conditional File Removal
{
"project_name": "My App",
"framework": ["fastapi", "flask", "django"],
"include_docs": ["yes", "no"]
}Use the choice in templates:
# Inside a file
{% if cookiecutter.framework == "fastapi" %}
import fastapi
{% elif cookiecutter.framework == "flask" %}
import flask
{% endif %}Conditional directories — directory name evaluates to empty string when not needed:
{{ cookiecutter.project_slug }}/
├── {% if cookiecutter.include_docs == 'yes' %}docs{% endif %}/
└── src/When include_docs == "no", the docs/ directory is named "" (empty) — cookiecutter skips creating it.
Cleaner pattern with post-gen hook:
# hooks/post_gen_project.py
import shutil
if "{{ cookiecutter.include_docs }}" == "no":
shutil.rmtree("docs")
if "{{ cookiecutter.framework }}" != "django":
shutil.rmtree("django_settings", ignore_errors=True)Easier to maintain than complex Jinja2 expressions in file names.
Fix 7: Template Inheritance and Extensions
For complex templates, use extensions:
{
"_extensions": [
"jinja2_time.TimeExtension",
"slugify.slugify"
]
}pip install jinja2-timeThen in templates:
{# Current year #}
{% now 'utc', '%Y' %}
{# Slugify a string #}
{{ cookiecutter.project_name | slugify }}Reusable templates — split into a base + variants. Two main approaches:
- Multiple cookiecutter templates sharing common files via symlinks or git submodules
- Copier (alternative tool) — supports template inheritance natively
For more advanced templating with versioning and updates after generation, see Copier — it supports updating already-generated projects when the template changes, which cookiecutter doesn’t.
Fix 8: Private Repos and Authentication
# SSH
cookiecutter [email protected]:me/private-template.git
# HTTPS with token
cookiecutter https://oauth2:[email protected]/me/private-template.git
# Cached templates
cookiecutter cookiecutter-pypackage # Use abbreviation if in config~/.cookiecutterrc for abbreviations and defaults:
default_context:
full_name: "Your Name"
email: "[email protected]"
github_username: "yourname"
cookiecutters_dir: "~/.cookiecutters/"
replay_dir: "~/.cookiecutter_replay/"
abbreviations:
pypackage: https://github.com/audreyfeldroy/cookiecutter-pypackage.git
django: https://github.com/cookiecutter/cookiecutter-django.git
gh: https://github.com/{0}.gitThen:
cookiecutter pypackage # Resolves to the full URL
cookiecutter gh:me/my-template # Custom abbreviationdefault_context values pre-fill prompts — useful for personal info you set once.
Cache management:
# Templates cached at ~/.cookiecutters/
ls ~/.cookiecutters/
# Force re-download (skip cache)
cookiecutter my-template --no-cacheProduction Incident Lens — Onboarding Is the Blast Radius
A broken Cookiecutter template doesn’t take down production directly. The blast radius is something subtler: developer onboarding velocity. The day a template fails is the day every new contributor stalls. The first PR a new hire submits is usually “fix the template” — which sounds harmless until you count the lost hours across a team.
Incident pattern — the silent template rot. The template worked when it was written. Eighteen months later, the pinned Python version is EOL, the default linter has been renamed, a dependency was yanked from PyPI, or a post-gen hook calls a git flag that changed default behavior. No one notices until a new contributor runs cookiecutter and watches the install fail. The contributor either gives up, asks Slack, or hand-edits the generated project — all three are bad outcomes.
Mitigations:
- CI the template on every push. Bake the template with default answers, then run the generated project’s lint and tests. If the template breaks, the PR fails. The CI cost is one minute; the alternative is a stranger debugging your scaffolding at 9am.
- Pin transitive dependencies in the template. A template that says
requests(unpinned) will work for years and then break. A template that saysrequests>=2.31,<3.0will keep working until v3 ships, and then break loudly enough to fix. - Date the template. Add a
_last_verified: "2026-05"private variable. Quarterly, re-run the template and confirm it still bakes. If the date drifts more than six months, schedule the audit. - Document the assumed environment. Some templates assume macOS shell behavior, Python 3.12, or pre-installed
pre-commit. Write these assumptions into the template README. Otherwise the next contributor on Windows or 3.10 hits a wall and you spend an hour on Slack triaging.
Real failure — the post-gen hook that silently corrupts the project. The hook runs git init, but the user already has a parent .git directory because they ran cookiecutter inside a monorepo. git init creates a nested repo, commits unrelated files, and the contributor is left with a repo state they cannot reason about. The fix is to detect the existing .git before initializing — but the template never did that check, so every contributor on the monorepo team rediscovers the bug.
# hooks/post_gen_project.py — defensive pattern
import os
import subprocess
# Don't init git if we're inside an existing repo
result = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
capture_output=True,
text=True,
)
if result.returncode != 0:
subprocess.run(["git", "init"], check=True)
else:
print("Skipping git init — already inside a repo")Common Mistake: Treating post-gen hooks as “fire and forget.” Each hook is code that runs on every new contributor’s machine. Treat it like production code — error handling, environment checks, idempotency. A hook that crashes on a fresh Windows install is a hook that locks out half your potential contributors.
For templates that need to update existing projects (Cookiecutter doesn’t support this directly), see Copier not working — Copier is the right tool when “regenerate all child projects” isn’t acceptable.
Still Not Working?
Cookiecutter vs Copier vs Yeoman
- Cookiecutter — Python-native, Jinja2 templating, simple, mature. Best for one-shot project generation.
- Copier — Python-native too, supports template updates after generation. Best for templates that evolve.
- Yeoman — JavaScript-based, large ecosystem. Best for JS/frontend projects.
For Python projects, Cookiecutter is the dominant standard. Copier is gaining traction for templates that need to support “update existing project to latest template version.”
Testing Templates
pip install pytest-cookies# tests/test_template.py
def test_default(cookies):
result = cookies.bake(extra_context={"project_name": "Test Project"})
assert result.exit_code == 0
assert result.project_path.is_dir()
assert (result.project_path / "README.md").exists()
def test_docker_option(cookies):
result = cookies.bake(extra_context={"use_docker": "yes"})
assert (result.project_path / "Dockerfile").exists()Run these tests on every push to the template repo. The cost is small, and the alternative is a broken template that a new contributor discovers at 9am on their first day.
CI for Template Repos
# .github/workflows/test.yml
name: Test Template
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- run: pip install cookiecutter pytest pytest-cookies
- run: pytest tests/
# Then bake the template and run the generated project's tests
- run: |
cookiecutter . --no-input --output-dir generated/
cd generated/*/
pip install -e .
pytestThis catches templates that bake successfully but produce broken projects.
Common Generated Project Setups
Common post-gen patterns include initializing git, creating a virtualenv, installing deps. For uv-based project setup, see uv not working. For Hatch-based packaging in generated projects, see Hatch not working.
Cookiecutter Templates Worth Knowing
- cookiecutter-pypackage — Standard Python package
- cookiecutter-django — Django web app
- cookiecutter-data-science — Data science project structure
- cookiecutter-flask — Flask web app
- cookiecutter-pytorch — PyTorch ML project
Most teams clone one of these as a starting point, strip out what they don’t need, then commit the result as their internal template. Tracking the upstream after a fork is the hard part — see the section on cruft/copier below.
Combining with pre-commit Hooks
Generated projects often include .pre-commit-config.yaml. The post-gen hook can install hooks automatically:
# hooks/post_gen_project.py
import subprocess
subprocess.run(["git", "init"], check=True)
subprocess.run(["pre-commit", "install"], check=False)
# check=False — don't fail if pre-commit isn't installed globallyFor pre-commit setup patterns that work in generated projects, see pre-commit not working.
Updating Generated Projects
Cookiecutter doesn’t support updates — once generated, the project is independent. For long-lived projects that need to track template updates, use Copier (mentioned above) or maintain a cruft overlay:
pip install cruft
# Generate and track
cruft create https://github.com/me/my-template
# Later, pull template updates
cd generated-project/
cruft updatecruft is built on cookiecutter — same templates work, with the addition of update tracking via a .cruft.json file.
Replay Mode
cookiecutter saves all user inputs to ~/.cookiecutter_replay/ after each generation. Re-run with the same inputs:
cookiecutter my-template --replayUseful for re-generating after fixing the template — same context, no need to re-enter every prompt.
”valid repository could not be found” but the URL Is Correct
The repo exists, you can clone it manually, but cookiecutter rejects it. Common causes:
- The repo is private and your SSH agent doesn’t have the key loaded. Run
ssh-add -land add the key if missing. - The repo lacks
cookiecutter.jsonat the root. Cookiecutter checks for this file specifically — it’s not just looking for any git repo. - A proxy is rewriting the clone URL. Try
git clone <url>directly first. If git fails too, the network is the problem, not cookiecutter. - The default branch was renamed (
master→main) and your~/.cookiecutterrcpins a checkout that no longer exists. Update the config or pass--checkout main.
Choice Variable Default Doesn’t Show in Prompt
{ "framework": ["fastapi", "flask", "django"] }The first item in the list is the default — there’s no separate default field for choices. To make Django the default, reorder: ["django", "fastapi", "flask"]. The prompt shows the list with 1 - django as the first option; pressing Enter accepts it.
This trips up template authors used to other config systems where defaults are declared explicitly. Cookiecutter’s convention is “first item wins."
"OSError: dest path already exists” on Re-Run
You bake into the same output directory twice — cookiecutter refuses to overwrite by default. Two paths:
# Overwrite — destroys local changes in the target dir
cookiecutter my-template --overwrite-if-exists
# Or output to a new directory each time
cookiecutter my-template --output-dir ./generations/$(date +%s)--overwrite-if-exists is dangerous on a project a developer has already modified — it stomps their work. Use it only in CI or when you know the target is disposable. For “I want to update this project from the latest template,” cookiecutter does not support that workflow — use copier or cruft instead.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Copier Not Working — Template Updates, Question Conditions, and Migrations
How to fix Copier errors — copier.yml not found, conditional questions not appearing, update breaks generated project, migrations between versions, Jinja vs YAML escaping, and answers file conflict.
Fix: joblib Not Working — Parallel Backends, Memory Cache, and Pickling Errors
How to fix joblib errors — Parallel n_jobs slower than expected, Memory cache miss, backend loky vs threading vs multiprocessing, pickling lambda not supported, dump load file size, and pytest interference.
Fix: Marshmallow Not Working — Schema Errors, Load vs Dump, and Field Validation
How to fix Marshmallow errors — Schema not validated on dump, ValidationError messages format, unknown field handling, missing vs default, post_load object construction, and Marshmallow 3 to 4 migration.
Fix: Pipenv Not Working — Lock File Generation, Shell Activation, and Dependency Resolution
How to fix Pipenv errors — pipenv lock takes forever, Pipfile.lock not generated, shell activation broken, no virtualenv created, dependency conflict, and migration to uv or Poetry.