Create your own
Lesson illustration

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:

  1. The Python interpreter version.
  2. Your direct dependencies, such as FastAPI.
  3. Their transitive dependencies.
  4. The application package itself.
  5. 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:

ArtifactRoleCommit to Git?
pyproject.tomlDeclares the project, supported Python versions, direct dependencies, scripts, and build configuration.Yes
uv.lockRecords the exact resolved versions of direct and transitive dependencies.Yes
.python-versionStates 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.

A conceptual hierarchy of Python packaging: a standalone `.py` module is the smallest unit; source and wheel distributions provide progressively more complete forms for installing and distributing a package. For this backend service, the important point is that the code under `src/` is treated as an installable application package rather than as loose scripts.

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-version tells uv which interpreter line to use for this project locally.
  • requires-python declares 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:

  1. Adds FastAPI to dependencies in pyproject.toml.
  2. Resolves FastAPI and all its transitive dependencies.
  3. Creates or updates uv.lock.
  4. 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.

Structure and files | uv

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 directoryMeaningTypical 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.toml declares webhook-ingestion, a Python requirement, FastAPI, a script entry point, and the generated build system.
  • uv.lock is staged.
  • .python-version is staged.
  • src/webhook_ingestion/__init__.py is staged.
  • .venv/ is not staged.
  • uv run webhook-ingestion works without manually activating an environment.
  • Deleting .venv/ and running uv sync --locked rebuilds 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.txt while also using uv, without a clear compatibility need;
  • asks developers to run pip install inside an uv-managed project;
  • commits .venv, downloaded wheels, or __pycache__;
  • deletes uv.lock to “fix” dependency issues;
  • puts the main package at repository root while claiming a src layout;
  • changes pyproject.toml dependencies but does not update uv.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.toml states what the project is and what it directly depends on.
  • uv.lock records the exact resolved dependency graph and belongs in version control.
  • uv run and uv sync create 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