Skip to content

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

FixDevs ·

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.

For pre-commit hook patterns in generated projects, see pre-commit not working.

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.

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. See Cookiecutter not working.
  • 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

For pytest fixture patterns with file system testing, see pytest fixture not found.

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.

For Django-specific templates that often use Copier or Cookiecutter, see Django migration conflict.

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.

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