Skip to content

Fix: Copier Not Working — Template Updates, Question Conditions, and Migrations

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

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.

The Error

You install Copier and try to use a template — fails:

$ copier copy https://github.com/me/my-template ./output
TemplateNotFoundError: copier.yml not found in template

Or a conditional question never appears:

# copier.yml
use_docker:
  type: bool
  default: no

docker_image:
  type: str
  when: "{{ use_docker == true }}"   # Never asked

Or copier update breaks the generated project:

$ copier update
# Merges template changes — but conflicts in your custom code go undetected

Or migrations between template versions don’t run:

_migrations:
  - version: 2.0
    before:
      - rm -rf old_dir
$ copier update
# Updates files but `old_dir` is still there

Or YAML/Jinja conflicts in the template:

# copier.yml
project_name:
  default: "{{ cookiecutter.project_name }}"   # Wrong — cookiecutter syntax

Copier is the modern Python templating tool — built to fix Cookiecutter’s biggest limitation (no template updates after generation). With Copier, generated projects keep a .copier-answers.yml file that tracks the template version and answers; copier update pulls in template changes while preserving local modifications. The tradeoff is more complex configuration (copier.yml is denser than Cookiecutter’s flat JSON) and a steeper learning curve. This guide covers each common issue.

Why This Happens

Copier uses YAML for configuration (instead of Cookiecutter’s JSON), supports conditional questions, has a migrations system for version bumps, and most importantly, can update existing projects when the template changes. The cost of this power: more concepts (questions, conditionals, migrations, tasks, answers files) and more places for things to go wrong.

The update workflow requires a .copier-answers.yml in the generated project — without it, Copier doesn’t know what to update.

Fix 1: Installing and Basic Use

pip install copier
# Or via pipx (recommended for global use)
pipx install copier

Copy a template:

# From git
copier copy https://github.com/copier-org/autopretty ./my-project

# From local directory
copier copy ./my-template ./my-project

# Specific version
copier copy --vcs-ref v1.0 https://github.com/me/template ./my-project

# Skip prompts (use defaults)
copier copy --defaults ./my-template ./my-project

# Pass answers as JSON
copier copy --data '{"project_name": "Test"}' ./my-template ./my-project

Update a generated project:

cd my-project
copier update
# Pulls latest template version, applies updates, prompts for conflicts

Common Mistake: Using cookiecutter commands with copier (or vice versa). The CLIs are different:

CookiecutterCopier
cookiecutter ./templatecopier copy ./template ./output
cookiecutter.jsoncopier.yml
{{ cookiecutter.X }}{{ X }} (no namespace)
No update supportcopier update
{{ cookiecutter.project_slug }} dir{{ project_slug }} dir

If you’re migrating from Cookiecutter, you can’t just rename the config file — the variable references and tool commands all change.

Fix 2: copier.yml Structure

# copier.yml
_min_copier_version: "9.0"     # Required Copier version
_subdirectory: "template"       # Template files live in subdirectory
_templates_suffix: ".jinja"     # Or "" for raw files

# Questions (everything not starting with _ is a question)
project_name:
  type: str
  help: "Name of the project"
  default: "My Project"

project_slug:
  type: str
  default: "{{ project_name.lower().replace(' ', '_') }}"

description:
  type: str
  help: "Brief description"
  default: ""
  multiline: true

python_version:
  type: str
  default: "3.12"
  choices:
    - "3.10"
    - "3.11"
    - "3.12"

use_docker:
  type: bool
  default: false

framework:
  type: str
  default: "fastapi"
  choices:
    fastapi: "FastAPI"
    flask: "Flask"
    django: "Django"

dependencies:
  type: list
  default:
    - requests
    - pydantic

# Conditional question
docker_image_name:
  type: str
  default: "{{ project_slug }}:latest"
  when: "{{ use_docker }}"

Recommended layout:

my-template/
├── copier.yml
├── README.md       # Documentation for the template itself
└── template/       # Subdirectory listed in _subdirectory
    ├── README.md   # Template README — for generated project
    ├── pyproject.toml.jinja
    └── {{ project_slug }}/
        ├── __init__.py
        └── main.py.jinja

_subdirectory: "template" separates template files from template metadata — cleaner than mixing both at the root.

_templates_suffix: ".jinja" means only files ending in .jinja are rendered through Jinja2; other files are copied verbatim. This avoids the “everything is a template, watch out for stray {{ ” problem from Cookiecutter.

Pro Tip: Use the .jinja suffix and _subdirectory pattern from day one. They make templates explicit and prevent the most common Copier issues — files that should be rendered but aren’t (forgot the suffix), and files that shouldn’t be rendered but are (no suffix system at all).

Fix 3: Question Types and Conditional Logic

# String
name:
  type: str
  default: "Alice"
  help: "Your name"

# Integer
age:
  type: int
  default: 30
  validator: "{% if age < 0 %}Age must be positive{% endif %}"

# Boolean
verbose:
  type: bool
  default: false

# Choice
license:
  type: str
  default: "MIT"
  choices:
    - "MIT"
    - "Apache-2.0"
    - "GPL-3.0"

# Choice with labels and values
framework:
  type: str
  default: "fastapi"
  choices:
    "FastAPI (modern, async)": fastapi
    "Flask (lightweight)": flask
    "Django (batteries included)": django

# Multi-select (list)
features:
  type: list
  default: []
  choices:
    - "Authentication"
    - "Database"
    - "Background jobs"

# Secret (input hidden)
api_key:
  type: str
  secret: true
  help: "Your secret API key"

Conditional questions with when:

use_docker:
  type: bool
  default: false

docker_base_image:
  type: str
  default: "python:3.12-slim"
  when: "{{ use_docker }}"   # Only asked if use_docker is true

docker_registry:
  type: str
  default: "docker.io"
  when: "{{ use_docker }}"

Validators prevent invalid input:

project_slug:
  type: str
  default: "{{ project_name.lower().replace(' ', '_') }}"
  validator: >-
    {% if not project_slug.replace('_', '').isalnum() %}
    project_slug must be alphanumeric (underscores allowed)
    {% endif %}

The validator runs after the user submits — if non-empty string, it’s the error message; if empty, validation passes.

Common Mistake: Forgetting that when expressions are Jinja2, not Python. Use Jinja2’s templating: "{{ use_docker }}" (renders the boolean), not use_docker (raw Python). For more complex conditions: "{{ framework in ['fastapi', 'flask'] }}".

Fix 4: Template Files and Naming

File names support Jinja2 when they end in .jinja or you use a different convention:

template/
├── pyproject.toml.jinja            # Rendered, suffix stripped
├── README.md                        # Copied verbatim (no .jinja suffix)
├── {{ project_slug }}.jinja/        # Directory name templated, contents rendered
│   ├── __init__.py.jinja
│   └── main.py.jinja
└── {% if use_docker %}Dockerfile.jinja{% endif %}

Conditional files — wrap the filename in {% if %}:

template/
├── {% if use_docker %}Dockerfile{% endif %}.jinja
├── {% if use_docker %}.dockerignore{% endif %}.jinja
└── {% if framework == 'django' %}manage.py{% endif %}.jinja

If the condition is false, the filename becomes empty (or evaluates to “.jinja”) — Copier skips creating it.

The _exclude and _skip_if_exists patterns:

# copier.yml
_exclude:
  - "*.pyc"
  - "__pycache__"
  - ".git"
  - "{% if not use_docker %}Dockerfile{% endif %}"
  - "{% if not use_docker %}.dockerignore{% endif %}"

_skip_if_exists:
  - "README.md"          # Don't overwrite existing READMEs
  - ".env"
  - "config/local.py"

_exclude removes files from the copy; _skip_if_exists preserves existing files during copy or update.

Pro Tip: Use _skip_if_exists for user-customizable files (config files, env templates, README). Users edit these after generation; without _skip_if_exists, copier update would clobber their customizations.

Fix 5: Update Workflow

After generating a project, Copier writes .copier-answers.yml:

# .copier-answers.yml — auto-generated
_commit: v1.0.0
_src_path: https://github.com/me/template
project_name: My App
project_slug: my_app
framework: fastapi
use_docker: true

This file is what makes updates possible — Copier knows which template, which version, and what answers were used.

Update workflow:

cd generated-project/

# Pull latest template, re-render with same answers
copier update

# Update to a specific version
copier update --vcs-ref v2.0

# Force update without prompts
copier update --defaults --force

Conflict handling — Copier uses three-way merge:

  1. Read .copier-answers.yml to know the old template version
  2. Render template at the old version with the answers → “ancestor”
  3. Render template at the new version with the answers → “incoming”
  4. Diff incoming vs ancestor and apply changes to the user’s current files

If a user modified a file that the template also changed, conflicts appear in the file (<<<<<<<, =======, >>>>>>> markers, like git merge conflicts).

Common Mistake: Running copier update on a project without .copier-answers.yml — Copier doesn’t know the source template. The error is clear but newcomers expect copier update to “just figure it out.” Always run copier copy first (which creates the answers file), then copier update thereafter.

Fix 6: Migrations Between Template Versions

When template versions introduce breaking changes (renamed files, removed config), add migration tasks:

# copier.yml
_migrations:
  - version: 2.0.0
    before:
      - "rm -rf old_directory"
      - "mv config.yaml config/main.yaml"
    after:
      - "echo 'Migrated to v2.0'"

  - version: 3.0.0
    before:
      - "python migrate_to_v3.py"

Migrations run when the template version crosses the threshold during copier update:

# Project was on v1.5
copier update --vcs-ref v3.0
# Runs v2.0 migration, then v3.0 migration in order

before runs before files are updated; after runs after. Useful for cleanup that must happen before the new file layout exists, or initialization that needs the new files in place.

Common Mistake: Migration commands assume Unix shell. On Windows, rm/mv don’t exist. For cross-platform migrations, use Python scripts:

_migrations:
  - version: 2.0.0
    before:
      - python scripts/migrate_v2.py
# scripts/migrate_v2.py
import os
import shutil

if os.path.exists("old_directory"):
    shutil.rmtree("old_directory")

Fix 7: Tasks (Post-Generation Hooks)

Like Cookiecutter’s hooks, Copier supports tasks that run after generation:

_tasks:
  - "git init"
  - "pre-commit install"
  - "python -m pip install -e ."

  # Conditional task
  - command: "docker build -t {{ project_slug }} ."
    when: "{{ use_docker }}"

  # Task with description
  - command: "{{ _copier_python }} -m pytest"
    when: "{{ run_tests_after_gen }}"

_copier_python is a special variable pointing to the Python interpreter Copier is running with. Useful for portable Python invocation.

Common Mistake: Tasks fail silently when the command isn’t found. git init requires git to be installed; pre-commit install requires pre-commit to be available. Wrap in shell conditionals or use Python scripts for graceful handling:

_tasks:
  - command: "git init && git add . && git commit -m 'Initial' || true"

The || true swallows the error if git isn’t installed.

Wrap every task in defensive checks. The task list is code that runs on every contributor’s machine on the day they generate the project — a brittle task is a Slack ticket.

Fix 8: Excluding Files from Updates

Some files should be created once and never touched by updates:

# copier.yml
_skip_if_exists:
  - ".env"
  - "src/{{ project_slug }}/_user_config.py"
  - "README.md"

These are skipped when files already exist — copier update won’t overwrite them.

For files Copier should NEVER touch (not even on initial copy):

_exclude:
  - "*.pyc"
  - ".git/**"
  - "docs/generated/**"
  - ".venv/**"

_exclude patterns are matched against the template files; matching files are skipped during both copy and update.

Production Incident Lens — Template Updates Have Downstream Blast Radius

The reason teams pick Copier over Cookiecutter is copier update. The reason teams later regret picking Copier is also copier update. Every child project pinned to your template inherits whatever you push to the main branch — a careless template change can spread across dozens of services in a single update wave.

Incident pattern — the migration that wasn’t. You ship v2.0 of the template. The change removes an old config file and renames a directory. You add a _migrations block, run copier update in your own test project, see clean diff, merge. Two days later, three downstream teams report broken builds. The cause: their projects were on v1.3 (not v1.9), and the migration only ran the “before” command in v2.0 — they were missing intermediate migrations the template author assumed they had. Their old directory layout never got cleaned up; the new files landed on top of stale state.

Mitigation: chain migrations explicitly.

_migrations:
  - version: 1.5.0
    before: ["python scripts/migrate_v1_5.py"]
  - version: 2.0.0
    before: ["python scripts/migrate_v2.py"]
  - version: 3.0.0
    before: ["python scripts/migrate_v3.py"]

Each migration must be idempotent — running it twice on the same project must produce the same result. Otherwise a project that already migrated once (manually) and is now pulling the update will hit duplicate-action failures.

Incident pattern — the answers file that drifted. A contributor on a downstream project edited .copier-answers.yml by hand to change framework: flaskframework: fastapi, then ran copier update. Copier rendered fastapi files on top of an existing flask project; the result was a folder with both app.py (flask) and main.py (fastapi), neither runnable. The team spent half a day untangling it.

Pro Tip: Treat .copier-answers.yml as generated state, not configuration. To change an answer, regenerate the project into a new directory and merge what you actually changed, rather than editing the answers file in place. The exception is purely additive answers (a new question added in a later template version that didn’t exist when you originally baked).

Blast radius checklist before bumping the template major version:

  1. List every downstream project pinned to the template. If you don’t know, the template needs a registry — a users.md listing every consumer.
  2. For each, confirm which template version they are currently on.
  3. Write migrations for every intermediate version, not just the latest one.
  4. Pilot the update on a low-traffic, easily-reverted project before bulk-updating.
  5. Document the migration in the template release notes — a v2.0 → v3.0 migration guide saves every downstream team from rediscovering the same fix.

Common Mistake: Renaming a question key without a migration. The downstream project’s answers file still has the old key, so on update Copier prompts for the new key (treats it as missing) and the user inadvertently changes the answer. Use migrations to rename keys in .copier-answers.yml before the new template runs.

For long-running shared templates where downstream regenerate is too expensive to coordinate, see Cookiecutter not working — sometimes the simpler “regenerate from scratch” workflow wins because it forces explicit intent at each upgrade.

Still Not Working?

Copier vs Cookiecutter vs Cruft

  • Copier — Updates supported, YAML config, conditional questions. Best for templates that evolve.
  • Cookiecutter — Simpler, more templates available, no update support.
  • Cruft — Cookiecutter + update tracking. Use if you want to stay on Cookiecutter but need updates.

For new templates that you expect to maintain over time, Copier is the better choice. For one-shot generators or migrating an existing Cookiecutter template, sticking with Cookiecutter (or moving to Cruft) is easier.

Testing Copier Templates

# tests/test_template.py
import pytest
from copier import run_copy

def test_default(tmp_path):
    result = run_copy(
        "./",
        str(tmp_path / "generated"),
        defaults=True,
        unsafe=True,   # Allow tasks to run in tests
    )
    assert (tmp_path / "generated" / "pyproject.toml").exists()

def test_custom_values(tmp_path):
    run_copy(
        "./",
        str(tmp_path / "generated"),
        data={"project_name": "Custom"},
        defaults=True,
        unsafe=True,
    )
    pyproject = (tmp_path / "generated" / "pyproject.toml").read_text()
    assert 'name = "Custom"' in pyproject

unsafe=True is required because tasks (post-gen commands) are gated behind a confirmation in the CLI; tests need to bypass that gate. Without it, every test that exercises a task silently skips it and you ship a broken template.

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 copier pytest
      - run: pytest tests/
      # Test full generation
      - run: |
          copier copy --defaults . generated/
          cd generated/
          pip install -e .
          pytest

Combining with uv / Hatch

Generated projects often use modern Python packaging. For uv-based project setup in templates, see uv not working. For Hatch-based packaging, see Hatch not working.

Versioned Template Releases

Tag your template repo with semver. Users can pin to a specific version:

copier copy --vcs-ref v1.2.0 https://github.com/me/template ./output

In CI for the template, run integration tests at every tag — guarantees the template generates a working project at each release.

Tag bump = audit obligation. Every minor version of a template that downstream projects use is a change that could ripple. Treat tagging like a library release: bump notes, migration guide, manual smoke test of the bake.

Multi-Template Projects

For projects that combine multiple templates (e.g., backend + frontend + infrastructure), Copier supports separate _subdirectory per template:

copier copy ./backend-template ./my-project --data '{"project_name": "App"}'
copier copy ./frontend-template ./my-project --data '{"project_name": "App"}'
copier copy ./infra-template ./my-project --data '{"project_name": "App"}'

Each copier copy writes a separate .copier-answers.yml.<suffix>copier update --answers-file .copier-answers.yml.backend updates just one template.

Combining with pre-commit

Add .pre-commit-config.yaml.jinja to your template; the post-gen task installs hooks. For pre-commit setup details, see pre-commit not working.

copier update Produces Massive Diffs You Don’t Expect

You ran copier update and the diff touches files you never knew were part of the template — whitespace changes, EOL changes, files that look unchanged from your perspective. Common causes:

  • The template repo’s .gitattributes was changed and Copier now renders with different line endings.
  • A previous contributor hand-edited files Copier owns. The update is re-rendering them to the template’s canonical version, surfacing every drift.
  • _templates_suffix was changed in the template (e.g., """.jinja"). Every file is now classified differently.

Before merging the update, inspect each unexpected change. If a file is full of drift you don’t care about, add it to _skip_if_exists. If the drift represents lost local customizations, decide whether to upstream them to the template or keep them out of Copier’s reach.

Conditional Question Asked Anyway

You set when: "{{ use_docker }}" and Copier still prompts for the docker-specific question. Two common causes:

  • use_docker was answered "no" (a string) instead of false (a bool). In Jinja, "no" is truthy. Use type: bool on the question and Copier coerces correctly.
  • The when clause references a question that has not been answered yet because it appears later in copier.yml. Questions are processed top-to-bottom — list the controlling boolean before any conditional questions that depend on it.

_min_copier_version Mismatch Blocks Generation

You bump _min_copier_version: "9.0" in the template; a contributor on Copier 8.x can’t generate it. The error is clear but the friction is real — you’ve just told a chunk of your audience to upgrade before they can use the template. Consider whether the new version actually requires features beyond 8.x, or whether 8.0 would still suffice. Newer is not better if it locks out users.

For containerized template usage where the Copier version is pinned, document the bump in your release notes and pin the same version in any wrapper images.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles