Setting Up a Reproducible Python Backend with uv and a src-Based Package Layout
Hello, and welcome. This first module establishes the operational foundation for the backend service you will build throughout the course: a repository whose Python version, dependencies, and import behavior are explicit rather than dependent on a developer’s machine state.
In this lesson, you will create a small backend-oriented project called webhook-ingestion. It will use uv to manage the environment and dependencies, pyproject.toml to express project intent, uv.lock to capture the resolved dependency graph, and a src/ layout to ensure you test and run the installed package rather than accidentally importing local files.
Reproducibility is more than “it runs on my laptop”
A Python backend has several inputs that affect what actually runs:
- The Python interpreter version.
- Your direct dependencies, such as FastAPI.
- Their transitive dependencies.
- The application package itself.
- The command used to build, test, or start the service.
A reproducible repository makes these inputs visible and controlled.
For this course, the important artifacts are:
| Artifact | Role | Commit to Git? |
|---|---|---|
pyproject.toml | Declares the project, supported Python versions, direct dependencies, scripts, and build configuration. | Yes |
uv.lock | Records the exact resolved versions of direct and transitive dependencies. | Yes |
.python-version | States the Python version uv should use for the project. | Yes |
.venv/ | A local, generated virtual environment containing installed packages. | No |
src/webhook_ingestion/ | The installable application package. | Yes |
Think of pyproject.toml as the declared contract and uv.lock as the resolved evidence of that contract at a particular point in time. The virtual environment is only a local build output. It must be disposable.
Before creating the project, read the relevant official uv guidance.
Creating projects | uv - Astral Docs
Read the official uv documentation from Astral to see why an application project is initialized as a packaged project with source code under src/.
In the “Creating projects” section, read from the layout rationale. Focus on the distinction between application and library templates, and on why uv prefers a build system and dedicated source directory. Then read the “Applications” subsection, including the application template. Examine the generated directory tree and note that an application can have both an installable package and a command-line entry point.
Why package a backend that will never be published?
A service may be deployed only as a Docker image and never uploaded to PyPI. It should still be a proper Python package.
A packaged application gives you a reliable rule: Python imports the code that the project installation exposes, not whichever similarly named directory happens to be in the current working directory. That distinction prevents a subtle but common failure:
- Tests run successfully in the repository root because Python sees a top-level local package.
- The build configuration omits that package or points to the wrong directory.
- The deployed image installs the project and then fails with
ModuleNotFoundError.
The src/ layout makes this mistake harder to hide. Since src is not normally on Python’s import path when you run a command at the repository root, the package must be installed correctly for imports to work.

The project name and Python import name have related but distinct conventions:
Distribution / project name: webhook-ingestion
Python import package: webhook_ingestion
CLI command: webhook-ingestion
Hyphens are normal in distribution and command names. Python identifiers cannot contain hyphens, so imports use underscores.
Create the project with uv
First, confirm that uv is available:
uv --version
Create a workspace and initialize the application:
mkdir backend-labs
cd backend-labs
uv init webhook-ingestion
cd webhook-ingestion
uv init creates an application project by default. Its generated files may vary slightly by uv version, but you should see a structure broadly like this:
webhook-ingestion/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── webhook_ingestion/
└── __init__.py
The generated application template includes a package and build configuration. Keep those rather than replacing the project with a single root-level main.py file.
Choose the project Python version
For this course, target Python 3.12:
uv python pin 3.12
This updates .python-version. Now inspect pyproject.toml and ensure its [project] metadata includes a compatible Python requirement:
[project]
name = "webhook-ingestion"
requires-python = ">=3.12"
The two settings have different purposes:
.python-versiontells uv which interpreter line to use for this project locally.requires-pythondeclares the Python versions your package supports.
For a service, choose a deliberately narrow support policy. Supporting every Python version is rarely useful; it expands your testing and operational surface. Here, the project supports Python 3.12 and later, while the repository’s normal local interpreter remains Python 3.12.
Add a minimal application entry point
The generated project may already contain a main() function in src/webhook_ingestion/__init__.py. Replace its contents, or add the following if it is missing:
def main() -> None:
print("webhook-ingestion service bootstrap")
Your generated pyproject.toml should also contain a script entry resembling this:
[project.scripts]
webhook-ingestion = "webhook_ingestion:main"
Do not copy a build-system version constraint from a blog post or another repository. Keep the [build-system] section produced by your current uv installation. It tells Python tooling how to build and install this project.
At this point, run the command through uv:
uv run webhook-ingestion
Expected output:
webhook-ingestion service bootstrap
uv run ensures there is a suitable project environment before launching the command. You do not need to activate .venv manually.
Add a runtime dependency and create the lockfile
The service will later expose HTTP endpoints, so add FastAPI now as a runtime dependency:
uv add fastapi
This one command performs several coherent changes:
- Adds FastAPI to
dependenciesinpyproject.toml. - Resolves FastAPI and all its transitive dependencies.
- Creates or updates
uv.lock. - Synchronizes the project environment, normally at
.venv/.
Run the command again:
uv run webhook-ingestion
Then inspect the dependency graph:
uv tree
FastAPI is a direct dependency. The packages below it are transitive dependencies: they are required to make FastAPI work, but your project did not explicitly request them.
Now study how uv separates project declaration, environment management, and dependency locking.
Read Astral’s official explanation of the project root, managed virtual environment, and universal uv.lock file. These are the three mechanisms behind the workflow you have just used.
In the “The pyproject.toml” subsection, begin with the project root rule, then finish the subsection. Identify which metadata belongs in pyproject.toml. Next, read all of “The project environment.” Pay particular attention to the environment lifecycle: use uv add for project dependencies and avoid manually changing the managed environment with uv pip install. Finally, read all of “The lockfile,” focusing on why commit the lockfile. Note that uv manages this file, so it should be reviewed in code review but not edited by hand.
A useful mental model is:
| File or directory | Meaning | Typical change mechanism |
|---|---|---|
pyproject.toml | “This service directly needs FastAPI.” | uv add, uv remove, intentional metadata edits |
uv.lock | “This is the resolved dependency set uv selected.” | uv add, uv remove, uv lock |
.venv/ | “This machine currently has these dependencies installed.” | uv sync or uv run |
src/ | “This is our application code.” | Normal development |
The lockfile is universal: it records dependency resolution across relevant operating-system, architecture, and Python-version markers. When a dependency genuinely differs by platform, the lockfile can contain the appropriate alternatives rather than forcing every developer to maintain a separate lockfile.
Treat the virtual environment as a rebuildable artifact
The .venv directory is useful to your editor and local execution, but it must never become the source of truth. Confirm it is ignored by Git:
git check-ignore .venv
If Git does not report that .venv is ignored, add this line to .gitignore:
.venv/
Now perform a controlled rebuild. This is a practical test of reproducibility, not an academic exercise.
On Linux or macOS:
rm -rf .venv
uv sync --locked
uv run webhook-ingestion
In PowerShell:
Remove-Item -Recurse -Force .venv
uv sync --locked
uv run webhook-ingestion
uv sync --locked requires the existing lockfile to be usable and prevents uv from silently changing it. This is the mode you will want in CI and in image builds: a pipeline should fail if someone changed pyproject.toml without updating uv.lock.
After syncing, verify that the import resolves through the installed project environment:
uv run python -c "import webhook_ingestion; print(webhook_ingestion.__file__)"
The path will normally point into your source tree because uv installs the local project in editable form for development. The important fact is that uv has installed the project according to its packaging configuration; the src/ layout means that a missing or malformed installation is less likely to be masked by the current directory.
Make the repository shareable
Commit the declaration and lock evidence, but not derived local state.
If this directory is not already a Git repository, initialize it:
git init
Then stage the project:
git add .gitignore .python-version README.md pyproject.toml uv.lock src
git status
Before committing, check these conditions:
pyproject.tomldeclareswebhook-ingestion, a Python requirement, FastAPI, a script entry point, and the generated build system.uv.lockis staged..python-versionis staged.src/webhook_ingestion/__init__.pyis staged..venv/is not staged.uv run webhook-ingestionworks without manually activating an environment.- Deleting
.venv/and runninguv sync --lockedrebuilds the environment successfully.
Commit when the status is correct:
git commit -m "Initialize reproducible webhook ingestion project"
When reviewing AI-generated setup changes, use the same checklist. Be cautious if generated code:
- adds a
requirements.txtwhile also using uv, without a clear compatibility need; - asks developers to run
pip installinside an uv-managed project; - commits
.venv, downloaded wheels, or__pycache__; - deletes
uv.lockto “fix” dependency issues; - puts the main package at repository root while claiming a
srclayout; - changes
pyproject.tomldependencies but does not updateuv.lock.
These are not merely stylistic issues. They weaken the ability to reproduce builds, debug incidents, and reason about what dependency set reached production.
Key takeaways
You now have a backend-project foundation with four explicit controls:
pyproject.tomlstates what the project is and what it directly depends on.uv.lockrecords the exact resolved dependency graph and belongs in version control.uv runanduv synccreate and maintain the local environment without manual activation.- A
src/webhook_ingestion/package layout ensures that the application is installed and imported as a package, reducing packaging and deployment surprises.
In the next lesson, you will turn this repository into a quality-gated project by configuring Ruff, a static type checker, and pytest so that style, type, and behavioral checks can run consistently through uv.
Can't find a good explanation? Sign up and we'll make it for you
Sign up