A project template pays for itself on day one: every new internal CLI starts with the same layout, the same Ruff and pytest configuration, the same CI workflow. The trouble starts on day ninety, when the template gains a better release workflow and a security fix to its pre-commit configuration — and the twelve CLIs generated from it do not. With Cookiecutter, generation is one-way; each project drifts from the moment it is created. Copier was designed for the opposite: it records which template version and which answers produced a project, and copier update replays the template's changes since then onto the project, merging them with the project's own edits. This guide sets up a template so updates work, runs an update including the conflict case, and automates update pull requests across many projects. It belongs to the project scaffolding topic; Copier vs Cookiecutter for CLI templates explains the wider comparison.
Prerequisites
- Copier 9 (
uv tool install copier), and git. - A CLI template in its own git repository; the examples use a minimal one.
How an update works
Copier treats an update as a three-way merge. It regenerates the project from the old template version with the recorded answers, regenerates it from the new version with the same answers, computes the difference between the two, and applies that difference to the current project. Changes you made in the project are kept; changes the template made are applied; when both touched the same lines, you get a conflict to resolve.
That design has two requirements. The template must be a git repository with tags, so Copier can check out the old and new versions. And the project must contain the answers file, which records the template source, the version used and every answer.
The recipe
1. Make the template updatable
# copier.yml
_subdirectory: template
_answers_file: .copier-answers.yml
project_name:
type: str
help: Name of the CLI (as published)
package_name:
type: str
default: "{{ project_name | lower | replace('-', '_') }}"
python_min:
type: str
default: "3.11"
choices: ["3.10", "3.11", "3.12"]
The template's files live under template/, so repository files such as the template's own README and CI do not get copied into projects. One file in the template writes the answers back into every generated project:
{# template/{{_copier_conf.answers_file}}.jinja #}
{{ _copier_answers|to_nice_yaml -}}
Tag releases of the template with semantic versions — v1.0.0, v1.1.0 — exactly as you would a library. Copier updates to the newest tag by default, so untagged commits are invisible to updates, which gives you a natural "release" step for template changes.
2. Generate a project
copier copy --trust gh:acme/cli-template acme-deploy
Use a URL or an absolute path as the source; the answers file records it, and copier update needs to find it again from inside the project. The generated .copier-answers.yml looks like this and must be committed:
_commit: v1.0.0
_src_path: gh:acme/cli-template
package_name: acme_deploy
project_name: acme-deploy
python_min: '3.11'
3. Update
cd acme-deploy
git status --short # must be clean: copier refuses to update a dirty tree
copier update --trust # newest tag; asks only about new questions
git diff # review what the template changed
When the template's change and the project's own edits do not overlap, the update applies cleanly and the answers file's _commit moves to the new tag. When they do overlap, Copier writes inline conflict markers, just like a git merge:
[project]
name = "acme-deploy"
requires-python = ">=3.11"
<<<<<<< before updating
# my change
=======
[tool.ruff]
line-length = 100
>>>>>>> after updating
Resolve them as you would any merge conflict, run the tests, and commit. git status shows conflicted files as unmerged, so they are hard to miss. If you prefer separate files, copier update --conflict rej writes .rej files next to the originals instead.
4. Change an answer
Answers are not frozen. copier update --data python_min=3.12 (or re-answering when prompted with --ask python_min) regenerates with the new value and applies the resulting differences, so bumping the minimum Python version across a project's template-managed files is a single update.
Automating updates across many CLIs
With a dozen projects, nobody runs copier update by hand. A scheduled workflow in each generated project can do it and open a pull request:
# .github/workflows/template-update.yml (in each generated project)
name: template-update
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
- run: |
git config user.name "template-bot"
git config user.email "template-bot@users.noreply.github.com"
uvx copier update --trust --defaults --conflict rej
- uses: peter-evans/create-pull-request@v7
with:
branch: template-update
title: "Update from project template"
body: "Automated `copier update`. Check for `.rej` files before merging."
--defaults accepts default answers for any new questions, so the job never waits for input; --conflict rej keeps conflicts out of the code so the pull request's diff stays readable, with .rej files flagging what needs a human. Renovate also understands Copier answer files and can open the same pull requests across an organisation from one configuration.
Migrations between template versions
Some template changes cannot be expressed as a diff: renaming a question, moving a file whose content the project has heavily edited, or deleting a tool configuration that projects should migrate away from. Copier supports migrations in copier.yml — commands that run before or after updating across a given template version:
_migrations:
- version: v2.0.0
before:
- "{{ _copier_python }} -c \"import pathlib; p = pathlib.Path('setup.cfg'); p.exists() and p.unlink()\""
- version: v2.0.0
after:
- "{{ _copier_python }} -m pip --version"
Migrations are code that runs in every project being updated, so they require --trust and deserve the same review as any script with write access to a repository. Keep them small, idempotent and safe to run on a project that has already been fixed by hand. For renamed questions, prefer keeping the old name with a new default for one major version, then removing it — answers files from old projects keep working during the transition. And always describe migrations in the template changelog: they are the part of an update most likely to surprise someone reviewing the pull request.
UX considerations
The users here are the maintainers of generated projects, who did not write the template:
- Write a template changelog. An update pull request is far easier to review when the template's release notes explain why the CI workflow changed.
- Keep template changes small and tagged often. Many small updates conflict less than one large one a year later.
- Separate "owned by the template" from "owned by the project". Files the project should customise freely (the README body, the command code) are better generated once and excluded from updates with
_skip_if_exists, so updates never fight with them. - Do not make every value a question. Each answer is a dimension the template must support forever; prefer conventions over options.
- Test the template, including updates from older tags, as in testing a project template with pytest.
Testing the behaviour
Copier exposes run_copy and run_update for Python, which makes it possible to test the update path of a template in its own CI: generate from the previous tag, make a typical local edit, update to the working tree, and check the result:
# tests/test_update.py
import subprocess
from pathlib import Path
from copier import run_copy, run_update
TEMPLATE = Path(__file__).resolve().parents[1]
def git(*args, cwd):
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True,
env={"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.com",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.com",
"PATH": "/usr/bin:/bin"})
def test_update_from_previous_release(tmp_path):
project = tmp_path / "proj"
run_copy(str(TEMPLATE), project, data={"project_name": "acme-deploy"},
vcs_ref="v1.0.0", defaults=True, unsafe=True, quiet=True)
git("init", "-q", "-b", "main", cwd=project)
git("add", "-A", cwd=project)
git("commit", "-qm", "generated", cwd=project)
run_update(project, defaults=True, unsafe=True, quiet=True, overwrite=True)
answers = (project / ".copier-answers.yml").read_text()
assert "_commit: v1.0.0" not in answers
assert "[tool.ruff]" in (project / "pyproject.toml").read_text()
Run it in the template's CI against the last two or three tags, so a template change that breaks updates for existing projects fails before it is released.
Conclusion
Copier turns a project template from a one-time starting point into a maintained dependency. Keep the template in git with semantic-version tags and an answers file template, generate projects from a URL, commit the answers file, and run copier update — by hand or from a weekly pull-request workflow — to replay template changes onto each project as a three-way merge. Small, tagged template releases with a changelog keep those updates easy to review.
Frequently asked questions
Can I update a project that was generated with Cookiecutter?
Not directly — Cookiecutter records no template version. You can migrate by recreating the project with Copier from the template version closest to the original (with copier copy --vcs-ref), committing the answers file, and from then on using copier update. The first update will be noisy; later ones will not.
What if the template renames a file?
Copier applies the rename as part of the diff, so the project's copy moves too — including any local edits, if they do not conflict. Mention renames in the template changelog, because they are the changes reviewers most often misread.
Why does copier update refuse to run?
The usual reasons: uncommitted changes (commit or stash first), a relative _src_path that cannot be resolved from the project directory, or a template without tags. copier update --vcs-ref HEAD can update to an untagged commit for testing, but releases should be tagged.
Do I need --trust?
Only if the template runs tasks or uses Jinja extensions — Copier refuses those from untrusted sources by default. For your own organisation's template, --trust is fine; for a third-party template, read its _tasks first, just as you would review post-generation hooks.