Skip to content

Building a project template

You start new projects the same way every time. Your personal dev stack. Your team’s standard service skeleton. Your agency’s client project baseline. Same layout, same tooling, same CI workflow — you copy the last one, search-and-replace the project name, and push. Three days later you notice pyproject.toml still says name = 'old-service' because the old name appeared in a comment you didn’t touch. Or the README still references the old author email. You’ve shipped the wrong metadata again.

Write that pattern down once as a diecut template. Test it locally, push it to GitHub, use it from anywhere.

Create a directory for your template. If you plan to store multiple templates in one repo, put this in a subdirectory.

templates/
python-pkg/
diecut.toml
template/

Everything under template/ becomes your generated project. Files ending in .die are rendered through the Tera template engine and have the suffix stripped. Everything else is copied as-is.

Create templates/python-pkg/diecut.toml:

[template]
name = "python-pkg"
[variables.project_name]
type = "string"
prompt = "Project name"
default = "my-package"
validation = '^[a-z][a-z0-9-]*$'
validation_message = "Must start with a letter. Only lowercase letters, numbers, hyphens."
[variables.project_slug]
type = "string"
computed = "{{ project_name | replace(from='-', to='_') }}"
[variables.author]
type = "string"
prompt = "Author name"
[variables.description]
type = "string"
prompt = "Short description"
default = ""
[variables.license]
type = "select"
prompt = "License"
choices = ["MIT", "Apache-2.0", "GPL-3.0"]
default = "MIT"

project_slug is computed — it’s derived from project_name with hyphens replaced by underscores. Python package names use underscores; project directory names conventionally use hyphens.

Without this, you’d have to ask for both separately and trust the user types them consistently. If they enter project_name = my-lib but project_slug = my_lib_utils by mistake, the import in test_package.py (from my_lib_utils import ...) won’t match the directory diecut creates (src/my_lib/). The computed variable eliminates that class of mismatch: one value is entered, the other is always derived from it.

Create templates/python-pkg/template/pyproject.toml.die:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "{{ project_name }}"
version = "0.1.0"
description = "{{ description }}"
authors = [{ name = "{{ author }}" }]
license = { text = "{{ license }}" }
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
dev = ["pytest", "ruff"]
[tool.ruff]
line-length = 100

Create templates/python-pkg/template/src/{{ project_slug }}/__init__.py.die:

"""{{ description }}"""
__version__ = "0.1.0"

The directory name {{ project_slug }} is also rendered by diecut — path components can contain Tera expressions.

Create templates/python-pkg/template/README.md.die:

# {{ project_name }}
{{ description }}
## Installation
```bash
pip install {{ project_name }}
```
## License
{{ license }}

Create templates/python-pkg/template/tests/test_package.py.die:

from {{ project_slug }} import __version__
def test_version():
assert __version__ == "0.1.0"

Your template directory now looks like this:

templates/python-pkg/
diecut.toml
template/
pyproject.toml.die
README.md.die
src/
{{ project_slug }}/
__init__.py.die
tests/
test_package.py.die

Before pushing anything, preview the output with --dry-run --verbose:

Terminal window
diecut new ./templates/python-pkg -o my-lib --dry-run --verbose

diecut prompts you normally, then prints each file it would write without touching the filesystem:

Project name [my-package]: my-lib
Author name: Jane Doe
Short description: A small utility library.
License [MIT]:
1. MIT
2. Apache-2.0
3. GPL-3.0
[dry-run] would write: my-lib/pyproject.toml
[dry-run] would write: my-lib/README.md
[dry-run] would write: my-lib/src/my_lib/__init__.py
[dry-run] would write: my-lib/tests/test_package.py

The thing to verify is that my_lib appears, not my-lib. If you’d written computed = '{{ project_name }}' without the replace filter, the dry-run would show src/my-lib/__init__.py — a directory name Python can’t import from. Catching that here costs nothing; catching it after pip install -e . fails costs you a confused ten minutes.

Once you’re satisfied, generate for real:

Terminal window
diecut new ./templates/python-pkg -o my-lib

The output directory:

my-lib/
pyproject.toml
README.md
src/
my_lib/
__init__.py
tests/
test_package.py
.diecut-answers.toml

.diecut-answers.toml records the variable values used. If a teammate asks which license you picked, or you want to scaffold a closely related second package with the same author and description, the answers are already there — no digging through pyproject.toml to reconstruct what you typed.

Commit the template directory to a GitHub repo. The structure can be a dedicated templates repo or a subdirectory inside an existing one:

Terminal window
git add templates/python-pkg
git commit -m "add python-pkg template"
git push

Now use it from any machine:

Terminal window
diecut new gh:yourname/templates/python-pkg -o my-lib

diecut fetches the repo, reads the template from the python-pkg subdirectory, and prompts as usual. Skip prompts entirely with --defaults, or override specific values inline:

Terminal window
diecut new gh:yourname/templates/python-pkg -o my-lib \
-d project_name=my-lib \
-d author="Jane Doe" \
--defaults

Without a template, starting a new project means copying the last one and replacing every trace of the old name — in comments, in pyproject.toml, in the README. You catch the ones your editor flags, and you miss the ones it won’t. The dry-run output earlier in this article shows exactly that distinction: my-lib and my_lib are two different strings, in two different contexts, and you have to find them both.

With a template, the prompts replace the search-and-replace session. You type the project name once; the template renders it everywhere it belongs — as a directory name, as a package identifier, as a heading in the README.

The template is also a specification: it records what a correct new project looks like — the build backend, the linter config, the test runner. When that spec changes — say you switch from setuptools to hatchling — you update the template once. Every project created after that gets the new baseline automatically.


To learn more about what you can do in a template, see Creating Templates. For all CLI options, see the Commands reference.