Skip to content

6.4. Templates

What is a code template?

A code template is a predefined, reusable project structure that acts as a blueprint for creating new projects. It standardizes foundational components like configuration files, directory layouts, and setup scripts for essential tools such as linters, formatters, and testing frameworks.

By establishing a consistent baseline, templates allow developers to customize project-specific details—like its name, description, or dependencies—while ensuring that engineering best practices are followed from the start.

For instance, the authors of this course provide the Cookiecutter MLOps Package, which scaffolds new MLOps projects based on the principles taught here. This section explains how to leverage and adapt such templates for your own work.

Why are code templates essential for MLOps?

In MLOps, where speed and reliability are critical, templates are indispensable for scaling operations efficiently. They offer several key advantages:

  • Standardize Best Practices: Enforce uniform architecture, tooling, and coding standards across all projects, making them easier to maintain and integrate.
  • Accelerate Development: Automate the repetitive setup process, allowing teams to bypass initial configuration and immediately focus on the core business problem.
  • Promote Focused Work: Separate the concerns of infrastructure and application logic. Template maintainers can focus on improving the foundational framework, while project developers concentrate on building features.

As AI/ML development increasingly resembles a factory assembly line, templates ensure that every new project is built quickly and to a high standard of quality.

What are the best tools for creating code templates?

Cookiecutter

Cookiecutter is the industry standard for scaffolding projects in the Python ecosystem. It uses a simple command-line interface to generate a new project from a template.

cookiecutter [template-directory-or-url]

The command uses a cookiecutter.json file within the template to prompt the user for variables, which are then injected into the project files.

Cruft

Cruft is an essential companion to Cookiecutter that manages updates. After a project is created, Cruft links it to the original template, allowing you to pull in improvements and bug fixes over time.

Initialize a new project with Cruft:

cruft create [template-repository-url]

Update the project with the latest template changes:

cruft update

How do you pass variables into a code template?

Cookiecutter uses the Jinja2 templating engine to embed variables directly into files and filenames. These variables are defined in the cookiecutter.json file, which acts as the template's public interface.

When you run cookiecutter, it reads this file, asks you for input for each variable, and uses your answers to render the final project files.

Example of a variable in a Python file:

# The placeholder "{{ cookiecutter.project_name }}" will be replaced
# with the value you provide during generation.
project_name = "{{ cookiecutter.project_name }}"

Example cookiecutter.json file:

This file defines the template's variables and their default values. Here is the real one from the Cookiecutter MLOps Package:

{
  "user": "fmind",
  "name": "MLOps Project",
  "repository": "{{cookiecutter.name.lower().replace(' ', '-')}}",
  "package": "{{cookiecutter.repository.replace('-', '_')}}",
  "version": "0.1.0",
  "year": "2026",
  "description": "A short description of the project.",
  "python_version": "3.14",
  "mlflow_version": "3.15.1",
  "_copy_without_render": ["cliff.toml"],
  "__prompts__": {
    "user": "GitHub User",
    "name": "Project Name",
    "repository": "GitHub Repository",
    "package": "Python Package",
    "version": "Project Version",
    "year": "Copyright Year",
    "description": "Project Description",
    "python_version": "Python Version",
    "mlflow_version": "MLflow Version"
  }
}

Three details in that file are worth studying:

  • Derived variables: repository and package are computed from name with Jinja expressions, so the user answers one question and the template fills three consistent values. Fewer prompts means fewer chances to answer inconsistently.
  • _copy_without_render: some files legitimately contain {{ ... }} that is not a cookiecutter variable. cliff.toml is a git-cliff configuration full of Tera templating; rendering it through Jinja would mangle it or fail outright. Listing it here copies the file verbatim. Any private key (a name starting with _) is excluded from prompting but kept in the rendered context.
  • __prompts__: this maps each variable to the human-readable question shown on the command line, so the user sees Copyright Year instead of the bare identifier year. It is pure ergonomics, and it costs one line per variable.

Every prompt must have a consumer

The template used to ask for a license and no longer does; it now asks for a year instead. That swap encodes a rule worth stealing:

A prompt that nothing consumes is worse than no prompt at all.

The old license variable was collected on every single generation and then referenced by exactly nothing — the generated LICENSE.txt was a fixed MIT text. Users answered a question that changed no output, which quietly teaches them that the answers do not matter. Meanwhile the copyright line in LICENSE.txt had no year at all, because no variable supplied one.

So when you add or review a variable, grep for it:

# Every prompt in cookiecutter.json must appear somewhere under the template directory
grep -r "cookiecutter.year" "{{cookiecutter.repository}}/"

If it returns nothing, either wire the variable up or delete the prompt.

How should you structure a Cookiecutter template?

A well-structured Cookiecutter template repository has two main components:

  1. The Template Directory: A single directory whose name contains a variable, like {{cookiecutter.repository}}. Everything inside this directory—files, subdirectories, and their content—will be rendered into the new project.
  2. The Harness: Everything at the repository root that controls or validates generation but never ships to the generated project. In the cookiecutter-mlops-package template this includes:
    • cookiecutter.json: Defines the variables, their defaults, and their prompts.
    • tests/: A pytest-cookies suite that bakes the template and runs the generated project's own gate.
    • mise.toml, lefthook.yml, dprint.jsonc, trivy.yaml: The harness's own tooling, which mirrors the tooling it generates.
    • hooks/: Optional Python scripts that run before or after generation. This template does not need them; reach for a hook only when a value cannot be expressed as a variable.

A template repository therefore has two layers, and both need maintaining: the project you generate, and the harness that generates it. Keeping their configuration files identical apart from the cookiecutter variables is the cheapest way to stop the two from drifting apart.

Initialize this template package:

cookiecutter gh:fmind/cookiecutter-mlops-package

For advanced techniques, refer to the Advanced Usage section of the Cookiecutter documentation.

What should a good code template include and exclude?

A template should provide project scaffolding, not a finished application. The goal is to give developers a head start without imposing a rigid implementation.

What to Include (The Scaffolding):

  • Task Automation: A mise.toml task file (or Makefile) to automate common commands like install, format, check, and test.
  • Linters & Formatters: Configurations for tools like Ruff (Python) and dprint (config and markup) to enforce code quality.
  • Testing Frameworks: Setup for pytest to enable immediate testing.
  • Project Metadata: A pyproject.toml file to manage dependencies and project settings.
  • CI/CD Pipelines: Basic workflow files for services like GitHub Actions.

What to Exclude (Project-Specific Logic):

  • Source Code: Avoid including specific application logic or architectural patterns. The template should be agnostic to how a developer chooses to solve their problem.
  • Tests: Do not include tests tied to a specific implementation.

How do you keep a project synchronized with its template?

To prevent "project drift" and ensure your project benefits from the latest template improvements, always initialize it with Cruft.

When the template is updated, run the following command inside your project directory:

cruft update

Cruft will fetch the latest changes, compare them to your project, and create a pull request with the proposed updates, using Git to manage any merge conflicts.

How can you demonstrate a template's usage?

The best way to illustrate a template's power and flexibility is to create one or more reference implementations. These are fully functional demo repositories generated from the template.

Reference implementations serve multiple purposes: - Provide a Live Demo: Show a practical, real-world application of the template. - Act as Documentation: Serve as a clear example for developers to follow. - Serve as a Testbed: Use the demo repository to develop and validate new features before backporting them to the template.

What is the best way to improve a code template?

The most effective way to evolve a template is through an iterative refinement loop, often called "dogfooding" (i.e., eating your own dog food).

  1. Generate: Create a new project from your template.
  2. Implement: Build a feature or fix a bug in the generated project.
  3. Backport: Once the changes are validated, move them back into the template itself.

This feedback loop ensures that your template remains practical, robust, and aligned with real-world needs.

How can you automatically test a code template?

Automated testing is critical to ensure a template doesn't break as it evolves. With pytest-cookies, you can write a test that generates a project and verifies the output.

# Test that the project generates successfully
def test_bake_project(cookies):
    result = cookies.bake(extra_context={"name": "MLOps 123"})

    assert result.exit_code == 0
    assert result.exception is None
    assert result.project_path.name == "mlops-123"
    assert result.project_path.is_dir()

Generating without an exception only proves that Jinja rendered. What you actually want to know is whether the generated project works, so pair pytest-cookies with pytest-shell-utilities and run the generated project's own gate inside it:

COMMANDS = [
    "mise trust -y",
    "mise install -y",
    "git init",
    "mise run clean",
    "mise run install",
    # The generated project's own gate: format, check, test, and build in one task.
    "mise run all",
    "mise run docs",
    "mise run project",
    "mise run build:image",
    "mise run mlflow:doctor",
]

shell = Subprocess(cwd=result.project_path)
for command in COMMANDS:
    result = shell.run(*command.split())
    assert result.returncode == 0, f"Command failed: {command}"

Two failure modes made this list what it is, and both are easy to reproduce in your own template:

  • A gate that skips the expensive task proves nothing. The bake test used to run every task except mise run test, so the template's own suite and coverage threshold had never actually executed in CI. Run the single canonical task (mise run all) rather than a hand-picked subset, and you cannot forget one.
  • The outer shell's PATH leaks into the subprocess. The generated project sets run_auto_install = false, so without an explicit mise install -y the tool-dependent checks silently reuse whatever binaries the developer's own machine happens to have. It looks green locally and fails on a clean runner. Install the toolchain inside the generated project, explicitly.

Test the contract, not just the code

Some template bugs are invisible to any test that only runs commands. The MLOps template once shipped a CI workflow whose job was named check, alongside a branch ruleset that required the status-check context checks. Both files were individually valid, the generated project's tests all passed—and every pull request in every generated project was blocked forever on a check that no workflow could ever report.

The lesson generalizes: whenever two generated files refer to each other by a string (a job name and a required status check, a package name and an entrypoint, a service name and a hostname), that coupling is a contract your template owns. Assert it, or at minimum record it in a comment on both sides, as the template now does:

jobs:
  checks: # name kept as "checks" to satisfy the repository's required-status-check ruleset

Also make any setup task the template tells users to run idempotent. mise run install:rulesets originally POSTed the ruleset, creating a duplicate on every run; it now looks the ruleset up by name and PUTs over it, so re-running is a no-op.

How do you run automated tasks after generation?

Cookiecutter hooks are Python or shell scripts that execute automatically before or after project generation. They are perfect for cleanup tasks or conditional logic.

A common use case is removing files that are not needed based on the user's choices during setup. Reach for a hook only when the behavior cannot be expressed as a variable — the MLOps template needs none.

Example post_gen_project.py hook script:

This script removes a requirements.txt file if the user chose a package manager other than pip.

import os

# A list of files to remove based on template variable conditions
REMOVE_PATHS = [
    "{% if cookiecutter.packaging != 'pip' %}requirements.txt{% endif %}",
]

for path in REMOVE_PATHS:
    path = path.strip()
    if path and os.path.exists(path):
        if os.path.isfile(path):
            os.unlink(path)
        else:
            os.rmdir(path)

What is the difference between using a template and forking a repository?

Although they seem similar, templates and forks serve fundamentally different purposes.

  • Template: Use a template to start many new, independent projects from a shared baseline. Each new project is a distinct entity and does not share history with the template. The goal is standardization.
  • Fork: Create a fork to make a single, related copy of an existing repository. A fork is typically used to propose changes back to the original project (the "upstream") or as a starting point for a closely related but distinct project. The goal is contribution or parallel development.

Additional Resources