Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

Python Copier

A library for rendering project templates with Jinja2, supporting prompts, hooks, and template updates.

Python Copier

A library for rendering project templates with Jinja2, supporting prompts, hooks, and template updates.

Overview

Copier is a powerful project templating tool that generates projects from templates stored in git repositories or local directories. It supports interactive prompts, pre/post-generation hooks, and crucially, the ability to update existing projects when templates change. This makes it ideal for maintaining consistency across multiple projects and organisations.

Template RepositoryCopierUser AnswersGenerated ProjectUpdate with NewTemplate VersionTemplate RepositoryCopierUser AnswersGenerated ProjectUpdate with NewTemplate Version

Project Templating Basics

Key Concepts

Copier templates use Jinja2 syntax with special markers to distinguish template code from generated content. Templates can be stored in git repositories (recommended) or local directories.

Template Structure

my-template/
├── copier.yml              # Configuration and prompts
├── {{ project_name }}/     # Templated directory name
│   ├── __init__.py
│   └── main.py.jinja       # .jinja extension for templated files
├── README.md.jinja
├── pyproject.toml.jinja
└── .copier-answers.yml.jinja  # Tracks answers in generated project

Basic copier.yml

# Template metadata
_min_copier_version: "9.0.0"
_subdirectory: template      # Optional: template files in subdirectory

# Questions/variables
project_name:
  type: str
  help: What is your project name?

author_name:
  type: str
  help: Who is the author?
  default: "Unknown"

Running Copier

# Generate from git repository
copier copy gh:user/template-repo ./my-project

# Generate from local template
copier copy ./my-template ./my-project

# Non-interactive with defaults
copier copy --defaults gh:user/template ./my-project

# Use specific template version/tag
copier copy --vcs-ref v1.2.0 gh:user/template ./my-project

Jinja2 Templating

{# In pyproject.toml.jinja #}
[project]
name = "{{ project_name }}"
version = "{{ version | default('0.1.0') }}"
authors = [
    { name = "{{ author_name }}", email = "{{ author_email }}" }
]

{% if include_cli %}
[project.scripts]
{{ project_name }} = "{{ project_name }}.cli:main"
{% endif %}

Variable Definitions and Prompts

Prompt Types

# String input
project_name:
  type: str
  help: Project name (lowercase, hyphens allowed)
  validator: "{% if not project_name | regex_search('^[a-z][a-z0-9-]*$') %}Invalid format{% endif %}"

# Integer input
port:
  type: int
  default: 8000
  help: Default port number

# Float input
version:
  type: float
  default: 1.0

# Boolean (yes/no)
include_tests:
  type: bool
  default: true
  help: Include test configuration?

# Single choice
python_version:
  type: str
  choices:
    - "3.10"
    - "3.11"
    - "3.12"
  default: "3.11"

# Multiple choice
features:
  type: str
  multiselect: true
  choices:
    cli: Command-line interface
    api: REST API
    database: Database support
  default:
    - api

Advanced Prompt Features

# Conditional prompts (only shown if condition met)
database_url:
  type: str
  when: "{{ 'database' in features }}"
  default: "postgresql://localhost/{{ project_name }}"

# Computed values (not prompted)
package_name:
  type: str
  default: "{{ project_name | replace('-', '_') }}"
  when: false  # Never prompt, always compute

# Secret input (hidden)
api_key:
  type: str
  secret: true
  help: Enter your API key

# JSON type for complex data
extra_config:
  type: json
  default: {}
  help: Additional configuration (JSON format)

# YAML type
metadata:
  type: yaml
  default:
    tags: []
    category: general

Validation

project_name:
  type: str
  validator: |
    {% if project_name | length < 3 %}
    Name must be at least 3 characters
    {% elif project_name | length > 50 %}
    Name must be less than 50 characters
    {% elif not project_name | regex_search('^[a-z][a-z0-9_-]*$') %}
    Name must start with letter, contain only lowercase, numbers, hyphens, underscores
    {% endif %}

email:
  type: str
  validator: "{% if '@' not in email %}Invalid email address{% endif %}"

Hooks and Pre/Post Processing

Hooks are Python scripts that run before or after template generation.

Start CopyPre-copy TasksPrompt for Answerspre_copy.py HookRender Templatespost_copy.py HookCompleteStart Updatepre_update.py HookApply Changespost_update.py HookCompleteStart CopyPre-copy TasksPrompt for Answerspre_copy.py HookRender Templatespost_copy.py HookCompleteStart Updatepre_update.py HookApply Changespost_update.py HookComplete

Hook Structure

my-template/
├── copier.yml
├── extensions/              # Custom Jinja2 extensions
│   └── my_extension.py
├── tasks/                   # Alternative hook location
│   ├── pre_copy.py
│   └── post_copy.py
└── template/
    └── ...

Configuring Hooks in copier.yml

_tasks:
  # Run after generation
  - "python scripts/post_generate.py"
  - "{{ _copier_python }} -m pip install -e ."

# Or use dedicated hook files
_jinja_extensions:
  - copier_templates_extensions.TemplateExtensionLoader
  - jinja2_time.TimeExtension

Pre-copy Hook Example

#!/usr/bin/env python3
"""pre_copy.py - Runs before template rendering."""
import os
import sys
from pathlib import Path

def main():
    # Access answers via environment variables
    project_name = os.environ.get("PROJECT_NAME", "")

    # Validate external dependencies
    try:
        import git
    except ImportError:
        print("Error: GitPython required. Install with: pip install gitpython")
        sys.exit(1)

    # Check if destination already exists
    dest = Path(os.environ.get("_copier_conf_dst_path", "."))
    if (dest / ".git").exists():
        print("Warning: Destination already contains a git repository")

    print(f"Pre-copy checks passed for {project_name}")

if __name__ == "__main__":
    main()

Post-copy Hook Example

#!/usr/bin/env python3
"""post_copy.py - Runs after template rendering."""
import os
import subprocess
from pathlib import Path

def main():
    project_name = os.environ.get("PROJECT_NAME", "project")
    include_git = os.environ.get("INCLUDE_GIT", "true").lower() == "true"

    project_dir = Path.cwd()

    # Initialise git repository
    if include_git and not (project_dir / ".git").exists():
        subprocess.run(["git", "init"], check=True)
        subprocess.run(["git", "add", "."], check=True)
        subprocess.run(
            ["git", "commit", "-m", "Initial commit from template"],
            check=True
        )
        print("Git repository initialised")

    # Create virtual environment
    if not (project_dir / ".venv").exists():
        subprocess.run(["python", "-m", "venv", ".venv"], check=True)
        print("Virtual environment created")

    # Install dependencies
    pip_path = project_dir / ".venv" / "bin" / "pip"
    if (project_dir / "requirements.txt").exists():
        subprocess.run([str(pip_path), "install", "-r", "requirements.txt"])

    print(f"\nProject {project_name} created successfully!")
    print(f"Next steps:")
    print(f"  cd {project_name}")
    print(f"  source .venv/bin/activate")
    print(f"  pip install -e .[dev]")

if __name__ == "__main__":
    main()

Migration Hooks (for Updates)

#!/usr/bin/env python3
"""pre_update.py - Prepare for template update."""
import json
import os
from pathlib import Path

def main():
    # Backup custom configurations
    project_dir = Path.cwd()
    custom_config = project_dir / "custom_config.json"

    if custom_config.exists():
        backup = project_dir / ".copier_backup" / "custom_config.json"
        backup.parent.mkdir(exist_ok=True)
        backup.write_text(custom_config.read_text())
        print("Backed up custom configuration")

if __name__ == "__main__":
    main()

Updating Templates

One of Copier's most powerful features is updating existing projects when templates change.

How Updates Work

ProjectTemplateCopierUserProjectTemplateCopierUsercopier updateRead .copier-answers.ymlFetch latest versionCalculate diffShow conflicts (if any)Resolve conflictsApply changesUpdate .copier-answers.ymlProjectTemplateCopierUserProjectTemplateCopierUsercopier updateRead .copier-answers.ymlFetch latest versionCalculate diffShow conflicts (if any)Resolve conflictsApply changesUpdate .copier-answers.yml

Update Commands

# Update to latest template version
copier update

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

# Preview changes without applying
copier update --pretend

# Skip prompts, use existing answers
copier update --defaults

# Force update even with conflicts
copier update --conflict rej  # Create .rej files for conflicts

# Trust template hooks
copier update --trust

The .copier-answers.yml File

# .copier-answers.yml.jinja (in template)
_commit: {{ _copier_conf.vcs_ref_hash }}
_src_path: {{ _copier_conf.src_path }}
project_name: {{ project_name }}
author_name: {{ author_name }}
python_version: {{ python_version }}
features: {{ features | tojson }}

Generated in project:

# .copier-answers.yml (in generated project)
_commit: abc123def456
_src_path: gh:user/template-repo
project_name: my-project
author_name: Jane Smith
python_version: "3.11"
features: ["api", "database"]

Handling Conflicts

# copier.yml - Configure conflict handling
_conflict: inline  # Options: inline, rej

# Inline shows conflicts in file:
# <<<<<<< YOUR CHANGES
# custom code here
# =======
# template code here
# >>>>>>> TEMPLATE

Migrations Between Versions

# copier.yml
_migrations:
  - version: "2.0.0"
    before:
      - "python migrations/v2_pre.py"
    after:
      - "python migrations/v2_post.py"

Common Use Cases for Project Scaffolding

Python Package Template

# copier.yml for Python package
_min_copier_version: "9.0.0"

project_name:
  type: str
  help: Package name
  validator: "{% if not project_name | regex_search('^[a-z][a-z0-9_-]*$') %}Invalid name{% endif %}"

description:
  type: str
  help: Short description

author_name:
  type: str

author_email:
  type: str
  validator: "{% if '@' not in author_email %}Invalid email{% endif %}"

license:
  type: str
  choices:
    - MIT
    - Apache-2.0
    - GPL-3.0
    - BSD-3-Clause
  default: MIT

python_version:
  type: str
  choices: ["3.9", "3.10", "3.11", "3.12"]
  default: "3.11"

use_src_layout:
  type: bool
  default: true
  help: Use src/ layout?

include_cli:
  type: bool
  default: false

include_docker:
  type: bool
  default: false

ci_provider:
  type: str
  choices:
    none: No CI
    github: GitHub Actions
    gitlab: GitLab CI
  default: github

FastAPI Service Template

# copier.yml for FastAPI service
service_name:
  type: str
  help: Service name (lowercase, hyphens)

database:
  type: str
  choices:
    none: No database
    postgresql: PostgreSQL
    mysql: MySQL
    sqlite: SQLite (development only)
  default: postgresql

orm:
  type: str
  when: "{{ database != 'none' }}"
  choices:
    - sqlalchemy
    - tortoise
  default: sqlalchemy

include_auth:
  type: bool
  default: true
  help: Include JWT authentication?

include_redis:
  type: bool
  default: false
  help: Include Redis for caching?

container_registry:
  type: str
  default: "ghcr.io/{{ github_org }}"

Microservice Template

# copier.yml for microservice
service_name:
  type: str

team_name:
  type: str
  help: Owning team name

service_type:
  type: str
  choices:
    api: REST API Service
    worker: Background Worker
    gateway: API Gateway
  default: api

ports:
  type: json
  default:
    http: 8080
    metrics: 9090

health_endpoint:
  type: str
  default: /health

kubernetes_namespace:
  type: str
  default: "{{ team_name }}-{{ service_name }}"

Integration with CI/CD Pipelines

GitHub Actions Workflow

# .github/workflows/template-test.yml.jinja
name: Test Template

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test-generation:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: {{ "${{ matrix.python-version }}" }}

      - name: Install Copier
        run: pip install copier

      - name: Generate test project
        run: |
          copier copy --defaults \
            --data project_name=test-project \
            --data author_name="CI Test" \
            . ../test-output

      - name: Validate generated project
        working-directory: ../test-output
        run: |
          python -m py_compile **/*.py
          {% if include_tests %}
          pip install -e .[dev]
          pytest
          {% endif %}

Template Testing Script

#!/usr/bin/env python3
"""test_template.py - Test template generation."""
import subprocess
import tempfile
from pathlib import Path

TEST_CASES = [
    {
        "name": "minimal",
        "data": {
            "project_name": "test-minimal",
            "author_name": "Test User",
        }
    },
    {
        "name": "full-features",
        "data": {
            "project_name": "test-full",
            "author_name": "Test User",
            "include_cli": True,
            "include_docker": True,
            "features": ["api", "database", "cli"]
        }
    }
]

def test_template():
    template_dir = Path(__file__).parent.parent

    for case in TEST_CASES:
        with tempfile.TemporaryDirectory() as tmpdir:
            output_dir = Path(tmpdir) / case["name"]

            # Build copier command
            cmd = ["copier", "copy", "--defaults", "--trust"]
            for key, value in case["data"].items():
                if isinstance(value, bool):
                    value = str(value).lower()
                elif isinstance(value, list):
                    value = ",".join(value)
                cmd.extend(["--data", f"{key}={value}"])

            cmd.extend([str(template_dir), str(output_dir)])

            # Generate project
            result = subprocess.run(cmd, capture_output=True, text=True)
            assert result.returncode == 0, f"Failed: {result.stderr}"

            # Validate structure
            assert (output_dir / "pyproject.toml").exists()
            assert (output_dir / ".copier-answers.yml").exists()

            print(f"Test case '{case['name']}' passed")

if __name__ == "__main__":
    test_template()

GitLab CI Template Testing

# .gitlab-ci.yml
stages:
  - test
  - release

test-template:
  stage: test
  image: python:3.11
  script:
    - pip install copier pytest
    - python tests/test_template.py
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

release-template:
  stage: release
  script:
    - git tag "v${CI_COMMIT_SHORT_SHA}"
    - git push origin "v${CI_COMMIT_SHORT_SHA}"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual

Best Practices for Template Management

Template Organisation

Repository Structuretemplate-repo/copier.ymltemplate/extensions/tests/docs/project filescustom Jinja filterstemplate testsRepository Structuretemplate-repo/copier.ymltemplate/extensions/tests/docs/project filescustom Jinja filterstemplate tests

Version Control Strategy

# copier.yml
_min_copier_version: "9.0.0"

# Use semantic versioning for templates
# Tag releases: v1.0.0, v1.1.0, v2.0.0

# Document breaking changes
_message_after_copy: |
  Project generated successfully!

  {% if _copier_conf.vcs_ref %}
  Template version: {{ _copier_conf.vcs_ref }}
  {% endif %}

  Run 'copier update' to get template updates.

Sensible Defaults

# Provide good defaults that work for most cases
project_name:
  type: str
  default: "{{ _copier_conf.dst_path | basename }}"

author_email:
  type: str
  default: "{{ author_name | lower | replace(' ', '.') }}@example.com"

# Use computed values to reduce prompts
package_name:
  type: str
  default: "{{ project_name | replace('-', '_') }}"
  when: false

Template Documentation

# copier.yml - Add help text to all prompts
project_name:
  type: str
  help: |
    Project name used in pyproject.toml and imports.
    Must be lowercase with hyphens or underscores.
    Examples: my-project, data_pipeline

# Comprehensive after-copy message
_message_after_copy: |
  ## Next Steps

  1. Create virtual environment:
     python -m venv .venv && source .venv/bin/activate

  2. Install dependencies:
     pip install -e .[dev]

  3. Run tests:
     pytest

  ## Updating

  To update when template changes:
     copier update

Conditional File Generation

# copier.yml
_exclude:
  - "*.pyc"
  - "__pycache__"
  - ".git"
  # Conditionally exclude files
  - "{% if not include_docker %}Dockerfile{% endif %}"
  - "{% if not include_docker %}docker-compose.yml{% endif %}"
  - "{% if ci_provider != 'github' %}.github{% endif %}"
  - "{% if ci_provider != 'gitlab' %}.gitlab-ci.yml{% endif %}"

Modular Templates and Composition

Template Inheritance

Copier does not have a built-in _inherit directive. The supported approach is template composition: apply multiple independent templates in sequence, or use _subdirectory to share a base layout. True inheritance (a child template extending a parent) is a requested feature, not yet implemented.

# base-template/copier.yml — standalone base template
_min_copier_version: "9.0.0"

project_name:
  type: str
author_name:
  type: str

To add child-specific questions, apply a second template on top (see Multi-template Composition below).

Multi-template Composition

#!/usr/bin/env python3
"""compose_templates.py - Apply multiple templates."""
import subprocess
from pathlib import Path

def compose_project(
    output_dir: Path,
    templates: list[dict],
    common_data: dict
):
    """Apply multiple templates to create a composed project."""

    for i, template in enumerate(templates):
        # Merge common data with template-specific data
        data = {**common_data, **template.get("data", {})}

        cmd = ["copier", "copy", "--trust"]

        # First template creates, subsequent ones overwrite
        if i > 0:
            cmd.append("--overwrite")

        for key, value in data.items():
            cmd.extend(["--data", f"{key}={value}"])

        cmd.extend([template["src"], str(output_dir)])
        subprocess.run(cmd, check=True)

# Example usage
compose_project(
    output_dir=Path("./my-service"),
    templates=[
        {"src": "gh:org/python-base", "data": {}},
        {"src": "gh:org/fastapi-addon", "data": {"port": 8080}},
        {"src": "gh:org/kubernetes-addon", "data": {"replicas": 3}},
    ],
    common_data={
        "project_name": "my-service",
        "author_name": "Team Name"
    }
)

Shared Extensions

# extensions/custom_filters.py
"""Custom Jinja2 filters for templates."""
import re
from jinja2.ext import Extension

class CustomFilters(Extension):
    def __init__(self, environment):
        super().__init__(environment)
        environment.filters["to_pascal"] = self.to_pascal_case
        environment.filters["to_snake"] = self.to_snake_case
        environment.filters["pluralise"] = self.pluralise

    @staticmethod
    def to_pascal_case(value: str) -> str:
        """Convert to PascalCase."""
        return "".join(word.title() for word in re.split(r"[-_\s]+", value))

    @staticmethod
    def to_snake_case(value: str) -> str:
        """Convert to snake_case."""
        s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", value)
        return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()

    @staticmethod
    def pluralise(value: str) -> str:
        """Simple English pluralisation."""
        if value.endswith("y"):
            return value[:-1] + "ies"
        elif value.endswith(("s", "x", "z", "ch", "sh")):
            return value + "es"
        return value + "s"
# copier.yml - Use custom extensions
_jinja_extensions:
  - extensions.custom_filters.CustomFilters

# Usage in templates
# {{ project_name | to_pascal }}  -> MyProject
# {{ "user" | pluralise }}        -> users

Quick Reference

Commands

Command Description
copier copy <src> <dst> Generate project from template
copier update Update project to latest template
copier update --pretend Preview update changes
copier recopy Regenerate project (discards changes)
copier copy --trust Trust template hooks
copier copy --defaults Use defaults, skip prompts
copier copy --data key=value Pass answer via command line
copier copy --vcs-ref v1.0 Use specific template version

Variable Types

Type Description Example
str Text input name: {type: str}
int Integer port: {type: int, default: 8080}
float Decimal version: {type: float}
bool Yes/No include_tests: {type: bool}
json JSON data config: {type: json}
yaml YAML data metadata: {type: yaml}

Special Variables

Variable Description
_copier_conf.src_path Template source path
_copier_conf.dst_path Destination path
_copier_conf.vcs_ref Template version/tag
_copier_conf.vcs_ref_hash Template commit hash
_copier_python Path to Python running Copier

copier.yml Settings

Setting Description
_min_copier_version Minimum Copier version
_subdirectory Template files subdirectory
_exclude Patterns to exclude
_skip_if_exists Don't overwrite these files
_tasks Post-generation commands
_jinja_extensions Custom Jinja2 extensions
_migrations Version migration scripts
_message_after_copy Message shown after generation

Common Issues and Solutions

Issue Cause Solution
Template not found Invalid git URL or path Verify URL format: gh:user/repo or full URL
Permission denied on hooks Hook scripts not executable Add chmod +x in hook or use python script.py
Jinja2 syntax error Template syntax issue Check for unescaped { in non-template files
Answers file not found Running update in wrong directory Ensure .copier-answers.yml exists in project
Conflict during update Local changes conflict with template Use --conflict rej or manually resolve
Variable undefined Missing or misspelled variable Check copier.yml for variable definition
Hook import error Missing dependency in hook Install dependencies or add to hook requirements
Version constraint failed Copier version too old Upgrade: uv tool upgrade copier

Debugging Templates

# Verbose output
copier --verbose copy ./template ./output

# Check answers file
cat .copier-answers.yml

# Test template rendering
copier copy --pretend --defaults ./template ./test-output

# Validate copier.yml
python -c "import yaml; yaml.safe_load(open('copier.yml'))"

Escaping Jinja2 in Generated Files

{# When generating files that themselves use Jinja2 #}
{% raw %}
{{ variable }}  {# This will appear literally in output #}
{% endraw %}

{# Or use different delimiters in copier.yml #}
{# _envops:
     block_start_string: "{%"
     block_end_string: "%}"
     variable_start_string: "[["
     variable_end_string: "]]"
#}

Fixing Update Conflicts

# Option 1: Keep your changes (reject template changes)
copier update --conflict rej
# Then manually apply .rej files

# Option 2: Preview and decide
copier update --pretend
# Review changes, then apply selectively

# Option 3: Recopy and reapply changes
copier recopy
git diff HEAD  # See what changed
# Manually restore your customisations