What Actually Happens When You Set Up a Python Project
I learned the hard way that copying a template from GitHub without understanding what it does is how you end up with a dependency hell that takes three days to untangle. A Python template is just a structured collection of files - a Makefile, a requirements.txt or pyproject.toml, maybe a tox.ini, some CI config, and a directory layout that everyone on your team agrees to follow. It saves time when you need to spin up a new project, but it also ships baggage you did not ask for. The reference guide most people find online covers the surface stuff: create a virtual environment, install dependencies, run the linter. What nobody mentions upfront is that almost every template assumes a certain workflow that may not match your actual deployment target, and that assumption breaks something later when you try to containerize the app or move it to a managed service.
Reference Guide For Python Template
This section walks through the parts that matter, not just the ones that look impressive in a README. I am going to show you the setup first, then explain what each piece does, because understanding the why after seeing the how usually sticks better than reading definitions first. Start by deciding whether you need a template at all. If you only build one or two small scripts a year, a template is overhead. Templates pay for themselves when you are creating three or more projects in a quarter and you want consistent testing, linting, and packaging. My rule of thumb is simple: if you find yourself recreating the same directory structure, pytest configuration, and pre-commit hooks more than twice, write a template. The actual setup process usually looks like this. You create a base directory with the essential files, initialize a pyproject.toml with your packaging metadata, add a .pre-commit-config.yaml for code quality checks, set up a pytest.ini or [tool.pytest.ini_options] section, and pin your dependencies. I use Poetry for dependency management because it handles virtual environments and lock files in one tool, but pip-tools or uv work just as well depending on your team's preferences.
Here is what a minimal pyproject.toml looks like when you actually intend to publish it: [build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.backends._legacy:_Backend" [project]
name = "my-project"
version = "0.1.0"
description = "A brief description of what this does"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"requests>=2.28.0",
"pydantic>=2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pre-commit>=3.0.0",
"ruff>=0.1.0",
] The optional dependencies section is where most people make a mistake. They put development tools in the main dependencies list, which means anyone installing your package pulls in pytest and pre-commit unnecessarily. Keep dev-only tools under optional-dependencies with a clear label, and document that developers should install with [dev] or [dev,test] to get the testing extras. I ran into a specific problem last year that took me two days to resolve. I was using a template that configured pre-commit hooks to run mypy, ruff, and black on every commit. The template worked fine on macOS and Linux, but on Windows machines in the team, the mypy hook failed silently because the path separators were wrong in the hook configuration. The hooks would pass on one developer's machine and fail on another's with no obvious error message. The fix was to use absolute paths in the hook definitions or switch to a hook that uses virtualenv internally instead of relying on system Python paths.
Get the Full Details

This is the kind of edge case that never makes it into the documentation because the template author probably only tested on their own machine.
Testing Configuration That Actually Works
pytest is the standard, but configuring it properly requires more than adding pytest to your dependencies. Most templates include a basic pytest.ini or pyproject.toml section that works for simple cases but falls apart when you need conditional logic or environment-specific behavior. Here is a configuration I have been using successfully for about two years: [tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_functions = ["test_*"]
addopts = ["--strict-markers", "--strict-config", "-ra", "--cov=src", "--cov-report=term-missing", "--cov-report=xml"]
markers = [
"slow: marks tests as slow",
"integration: marks tests as integration tests",
"e2e: marks end-to-end tests",
]
The strict-markers option is important because it catches typos in marker names. Without it, a misspelled @pytest.mark.integration becomes just another unknown marker and your test selection logic breaks silently. I have seen this cause entire test suites to skip tests that were supposed to run in CI, and the pipeline passed because pytest does not fail by default when markers are misconfigured. For coverage reporting, the configuration above generates both terminal output and XML reports. The XML report is what you feed to services like Coveralls or Codecov. The term-missing option shows you exactly which lines are not covered, which is more useful than a percentage number when you are trying to decide whether a gap matters. One thing that catches people off guard: pytest discovers tests differently than you might expect. If you have a file named test_utils.py and a function named helper_test, pytest will try to run it even though it is not a test function. The python_functions configuration prevents this by only matching functions that start with test_. This is a small detail that prevents weird failures when you have utility files with test-like names.
Linting and Code Quality Tools
The Python ecosystem has shifted from flake8 and black to tools like ruff and pyright. Ruff is fast because it is written in Rust, and it replaces multiple tools including flake8, isort, and pyflakes in a single pass. For type checking, pyright or mypy are the main options, and they serve different purposes. Mypy is stricter and catches more errors at the cost of configuration complexity. Pyright is faster and easier to set up but may miss some edge cases that mypy catches. Here is a ruff configuration that I find works well for most projects: [tool.ruff]
line-length = 100
target-version = "py310"
select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM", "RUF"]

[tool.ruff.per-file-ignores]
"tests/" = ["ARG001", "S101"] The per-file-ignores section is critical for test files. Tests often have unused arguments because they test edge cases, and assert statements in tests are expected. Ignoring those rules globally would let them slip into production code. The test directory exceptions keep the rules strict where they matter while avoiding noise in places where the patterns are intentional. I learned about a ruff limitation that surprised me. Ruff does not always respect typing.Literal annotations when they are imported from a shared module. If you define a Literal type in one file and import it elsewhere, ruff may flag it as an unused import in the source file even though it is used in consuming files. The workaround is to add a noqa comment or restructure the imports so the Literal is defined in the same file where it is used for validation purposes.
Pre-commit Hooks: The Good and the Painful
Pre-commit hooks prevent bad code from reaching your repository, but they also slow down commits and create friction when the configuration is not consistent across developer environments. I have seen teams abandon pre-commit entirely because the hooks took too long to run or failed inconsistently between machines. A reasonable pre-commit configuration looks like this: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.1.6
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.5.1
hooks:
- id: mypy
additional_dependencies: [types-requests, types-PyYAML] - repo: https://github.com/PyCQA/bandit
rev: 1.7.6
hooks:
- id: bandit
args: ["-c", "pyproject.toml"]
additional_dependencies: [bandit[toml]] The additional_dependencies in the mypy hook is where people usually trip up. Mypy does not include type stubs for popular packages by default, so you need to specify them here. Without types-requests, mypy will complain about the requests library even though the code works fine at runtime. This mismatch between runtime and static analysis is a common source of confusion.
Bandit scanning takes time, especially on larger codebases. If your commits are taking more than 30 seconds, you have a few options: exclude certain directories from scanning, use the --skip option to ignore known-safe patterns, or run bandit only in CI instead of on every commit. I recommend running it on every commit during development but excluding the tests directory and any vendored code, because security scanners often flag false positives in third-party code you did not write.

Directory Structure That Makes Sense
There is no single correct directory structure for Python projects, but there are patterns that reduce friction. The src layout keeps your source code separate from tests and other artifacts, which prevents accidental imports from the repository root. Here is a structure that works for most applications: my-project/
src/
my_project/
__init__.py
main.py
utils.py
tests/
__init__.py
test_main.py
conftest.py
pyproject.toml
.pre-commit-config.yaml
README.md The src layout requires you to install your package in development mode with pip install -e . or poetry install before imports work correctly. This is the tradeoff: extra setup step versus protection against importing code from the wrong location. I prefer the protection because I have seen too many production bugs caused by importing a local module instead of the installed package.
Conftest.py belongs in the tests directory root, not in individual test files. It is where you put shared fixtures and plugin configuration that multiple test files need. Moving it to the root ensures pytest discovers it automatically, and putting it in individual files creates import order dependencies that break when you reorganize your test structure.
Dependency Management and Lock Files
Poetry creates a poetry.lock file that pins every transitive dependency to exact versions. This is valuable for reproducibility but causes problems when you want to update a single package. Running poetry update requests will update requests and all its dependencies to the latest compatible versions, which may introduce breaking changes in other packages. The workaround I use is poetry update requests --dry-run first to see what would change, then poetry update requests --no-sync if I want to be conservative, or poetry lock --no-update to refresh the lock file without changing installed packages. This gives me visibility into what an update will do before it actually happens. For projects that need rapid iteration, Poetry can feel slow because it resolves the entire dependency graph on every install. If you are building small scripts or working on a tight deadline, uv or pip-tools may be more appropriate. They do not provide the same level of deterministic reproducibility, but they are significantly faster for development workflows.
One limitation of Poetry that is worth mentioning: it does not handle optional dependencies gracefully when you are publishing to a private index. If your package has dev, test, and production extras, Poetry sometimes fails to resolve the correct dependency set when installing from a private registry. The workaround is to publish separate wheels for each extra group or to use a fallback to pip for installations from private indexes.
CI/CD Configuration Basics
A minimal CI pipeline should run your tests, lint your code, and build your package. Everything else is optional but recommended. Here is a GitHub Actions workflow that covers the essentials: name: CI
on: [push, pull_request] jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }} - name: Install dependencies
run: |
pip install poetry
poetry install --with dev
- name: Run linter
run: poetry run ruff check src/ - name: Run tests
run: poetry run pytest --cov=src --cov-report=xml - name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
The strategy matrix ensures you test against multiple Python versions, which catches compatibility issues early. Without this, a package might work on Python 3.11 but fail on 3.12 due to a subtle typing change or deprecated behavior that was removed. I encountered a problem where the CI pipeline passed locally but failed in GitHub Actions because the coverage report was empty. The issue was that the test files were not being discovered correctly due to the src layout. The fix was to add PYTHONPATH=src to the test step so pytest could find the source code, or to configure pytest to recognize the src layout explicitly in pyproject.toml.

When Templates Fail You
No template covers every scenario. I have worked with projects where the template assumed Flask was the web framework, but the project ended up using FastAPI. I have seen templates that configured Celery for async tasks when the project needed aiohttp for HTTP concurrency. The template still worked, but it added complexity and dependencies that had to be removed or ignored. The best approach is to treat any template as a starting point, not a finished solution. Strip out what you do not need, modify what does not fit, and add what your specific project requires. A template that you understand and have adapted to your needs is more valuable than a complete template you do not understand. One thing I wish more template authors documented: the exact Python version support policy. Some templates claim to support Python 3.8+ but actually require 3.10+ due to type annotation features. This mismatch causes installation failures that are difficult to debug because the error messages point to syntax issues rather than version incompatibilities. Always check the actual minimum version in the pyproject.toml or setup.cfg, not what the README says.
Templates are tools, not solutions. They save time on repetitive setup, but they cannot replace understanding what each configuration file does in your specific context. The investment in learning why your template is configured the way it is pays off when something breaks and you need to fix it without spending hours tracing through configuration layers.