---
title: "0.2.2 Virtual environments and dependencies with uv"
description: "What a virtual environment is, how to create a project with uv init and uv add, what pyproject.toml and uv.lock store and why they pin backtest results."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/virtual-environments-dependencies-with-uv/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/virtual-environments-dependencies-with-uv.png"
---

# 0.2.2 · Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles

> A backtest depends on the code, on the data and on the libraries it runs with. This lesson creates the course project with uv, explains what each file stores and shows how to pin exact versions so that the same code gives the same result on any machine.

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- Module: 0.2 · Modern working environment
- Duration: 40 min
- Prerequisites: [0.2.1 · Installing Python with uv: versions and support cycle](https://pythonparatrading.com/en/python-trading-course/level-0/install-python-with-uv/)

## By the end of this lesson

- Create a project with `uv init`, add dependencies with `uv add` and run code with `uv run`.
- Explain what `pyproject.toml`, `uv.lock` and `.venv` store, and which of them are versioned in Git.
- Check from Python whether the code runs in a virtual environment and which version of each library is installed.
- Relate the lockfile to the reproducibility of a backtest and detect when a result has changed because of the environment.

## One Python per project

In lesson 0.2.1 you installed Python 3.14 with uv. That interpreter is shared: if you install pandas "into it", every
program that uses it will see the same version of pandas. That works until the day two projects need different
things:

- A system that trades live was tested with pandas 2.2 and you do not want to touch it until you have reviewed it
  carefully.
- The course project uses pandas 3.0, which changes the type of text columns and the semantics of copies
  (lesson 0.5.2).

With a single Python, upgrading pandas for the course also changes the live system, without anyone having tested it.
The solution is for each project to have its own set of libraries: a **virtual environment**.

A virtual environment is a folder (by convention, `.venv` inside the project) with three things: a link to the base
interpreter, a `site-packages` folder where the project's libraries are installed and a configuration file. It does
not copy the whole of Python: it weighs as much as the libraries do. When you run code "inside" the environment,
Python looks for packages in that folder first.

You can tell from the code itself whether you are inside one. `sys.prefix` is the folder of the active environment;
`sys.base_prefix`, that of the Python it was created from. Outside a virtual environment, the two match. The code in
this course runs with `uv run` inside a project, so here they differ:

```python
import sys
from pathlib import Path

print("Virtual environment:", sys.prefix != sys.base_prefix)
print("Environment folder:", Path(sys.prefix).name)
```
```output
Virtual environment: True
Environment folder: .venv
```

The project's libraries live inside that folder. The path relative to the environment is the same on Linux and macOS
(on Windows it is `Lib/site-packages`):

```python
import pandas as pd

ruta = Path(pd.__file__).relative_to(sys.prefix)
print(ruta.parent)
```
```output
lib/python3.14/site-packages/pandas
```

Python has a module to create environments by hand (`python -m venv .venv`) and pip to install into them. It works,
but it forces you to remember what was installed, in what order and with which versions. uv does all of that from two
text files.

## A project with uv

The course project is created with `uv init`. The `--name ppt` option gives the package the name you used in
lesson 0.1.3 (`src/ppt/`); without it, it would be named after the folder:

```bash
uv init ppt-curso --name ppt --python 3.14
cd ppt-curso
uv add pandas numpy          # project dependencies
uv add --dev pytest          # for development only (tests)
uv run python -c "import pandas; print(pandas.__version__)"
```

`uv init` creates the minimal structure and, if you are not already inside a repository, initializes a Git one
(lesson 0.2.5). The first `uv add` resolves the versions, writes the lockfile and creates the environment. The
result:

| File | What it is | To Git? |
|---|---|---|
| `pyproject.toml` | What the project declares: name, Python version, dependencies | Yes |
| `.python-version` | The Python version uv will use in this folder (`3.14`) | Yes |
| `uv.lock` | Exact versions and hashes of every package, direct and indirect | Yes |
| `.venv/` | The virtual environment: the installed libraries | No |
| `src/ppt/__init__.py` | Your package | Yes |
| `.gitignore` | What Git should ignore; it already includes `.venv` | Yes |

From here on you will almost never type plain `python`. `uv run` checks that the environment matches the lock (and
updates it if needed) before running: `uv run python script.py`, `uv run pytest`, `uv run jupyter lab`. There is no
need to "activate" the environment.

<aside class="callout" data-kind="aviso">

Do not mix `pip install` with a uv project. What pip installs does not end up in `pyproject.toml` or in `uv.lock`: it
works on your machine and fails on anyone else's. Everything the project needs goes in with `uv add`.

</aside>

## pyproject.toml: what you ask for

`pyproject.toml` is the standard configuration file of a Python project. It is in TOML format, which Python reads
without external libraries since version 3.11 with the `tomllib` module. This is, trimmed, the one `uv init` leaves
after the two `uv add` commands:

```python
import tomllib

texto = """
[project]
name = "ppt"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
    "numpy>=2.5.4",
    "pandas>=3.0.6",
]

[dependency-groups]
dev = ["pytest>=9.1.1"]
"""
proyecto = tomllib.loads(texto)
print(proyecto["project"]["requires-python"])
print(proyecto["project"]["dependencies"])
print(proyecto["dependency-groups"]["dev"])
```
```output
>=3.14
['numpy>=2.5.4', 'pandas>=3.0.6']
['pytest>=9.1.1']
```

Notice the `>=`. `uv add pandas` does not pin the version: it writes the one it has just installed as a **minimum**.
The project declares "I need at least this", which is reasonable for a list of requirements: it does not stop someone
else from using a version with later fixes.

Checking whether what is installed meets what was asked for is easy with `importlib.metadata`, the module that reads
the metadata of installed packages. To compare versions, the same tuple idea from lesson 0.2.1 is used:

```python
from importlib.metadata import version

def como_tupla(v: str) -> tuple[int, ...]:
    return tuple(int(parte) for parte in v.split("."))

for requisito in proyecto["project"]["dependencies"]:
    nombre, minima = requisito.split(">=")
    instalada = version(nombre)
    cumple = como_tupla(instalada) >= como_tupla(minima)
    print(f"{nombre:<6} wants >= {minima}  installed {instalada}"
          f"  {'ok' if cumple else 'NO'}")
```
```output
numpy  wants >= 2.5.4  installed 2.5.4  ok
pandas wants >= 3.0.6  installed 3.0.6  ok
```

`como_tupla` only understands versions made of numbers separated by dots. There are versions such as `2.9.0.post0`
(the one of `python-dateutil` that pandas uses) that would break it; that is what the `packaging` library, the one uv
and pip use, is for. Here it is enough for numpy and pandas.

## uv.lock: what was resolved

A range is not enough to reproduce a result. `pandas>=3.0.6` accepts 3.0.6 today and 3.1 six months from now. On top
of that, pandas has its own dependencies, also with ranges. The installed metadata shows them:

```python
from importlib.metadata import requires

for req in requires("pandas"):
    if "extra ==" not in req:
        print(req)
```
```output
numpy>=1.26.0; python_version < "3.14"
numpy>=2.3.3; python_version >= "3.14"
python-dateutil>=2.8.2
tzdata; sys_platform == "win32"
tzdata; sys_platform == "emscripten"
```

The lines that are not printed are the optional dependencies (*extras*: for Excel, for plotting…), which are only
installed if requested. What remains says: NumPy 2.3.3 or higher on Python 3.14, `python-dateutil` 2.8.2 or higher and
`tzdata` only on Windows and in the browser (*emscripten*). Each of them may in turn have dependencies of its own.

Finding a set of versions that satisfies all the ranges at once is the job of the **resolver**. uv does it once and
saves the solution in `uv.lock`. From that moment on, installing is no longer resolving, but copying:

```mermaid
flowchart TB
  P[pyproject.toml] --> R[uv resolver]
  R --> L[uv.lock]
  L --> S[uv sync]
  S --> V[.venv]
```

`uv add` and `uv lock` walk the whole path; `uv sync` and `uv run` start from the lock. The lockfile is also TOML and
has one entry per package. This is the one for pandas in the project the course code was run with, trimmed (without
the list of downloadable files and with the hash abbreviated):

```toml title="uv.lock (excerpt)"
[[package]]
name = "pandas"
version = "3.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "numpy" },
    { name = "python-dateutil" },
    { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/…/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7…", … }
```

The version is no longer a range but an exact number, and each downloadable file carries its **hash**: a
cryptographic fingerprint of the content. If the file that arrives from the server does not have that exact
fingerprint, uv refuses to install it. It is the same technique used to check that a data file has not changed:

```python
import hashlib

datos = Path("sint_diario.csv").read_bytes()
print(hashlib.sha256(datos).hexdigest()[:16])

alterado = datos.replace(b"139.56", b"139.57", 1)
print(hashlib.sha256(alterado).hexdigest()[:16])
```
```output
f7cf610c97e34f7f
ec6f2c6ad27ed154
```

A one-cent difference in the close of December 29, 2025 produces a fingerprint with nothing in common with the
original. You will use this idea again to version market data in level 1.

The lock is **universal**: it includes the packages for every platform, with their condition (*marker*). The course's
lock started with 13 direct dependencies and 37 packages in total (the figure in the header shows how many each one
pulled in; many repeat, like NumPy); each level adds its own, and the fingerprint further down counts today's. `uv sync`
installs on each machine only the ones that apply to it: on Linux, neither `tzdata` nor `colorama`, which are for
Windows.

<aside class="callout" data-kind="nota">

To upgrade on purpose: `uv lock --upgrade-package pandas` (pandas only) or `uv lock --upgrade` (everything). The lock
changes, you review it, run the tests and commit the lock with a message that says what was upgraded. It never
upgrades on its own.

</aside>

On another machine —a colleague's laptop, the server where your system will trade, the continuous integration service
that runs the tests— the project is rebuilt with two commands:

```bash
git clone https://github.com/<user>/ppt-curso.git
cd ppt-curso
uv sync --locked      # installs exactly what uv.lock says
uv run pytest
```

`--locked` makes uv fail if `uv.lock` is not up to date with `pyproject.toml`, instead of silently resolving again. It
is the right option on a server: if someone added a dependency and forgot to commit the lock, you would rather have an
error at deploy time than a system trading with versions nobody has tested. uv also downloads the Python version in
`.python-version` if the machine does not have it.

## Same versions, same results

Why so much care with versions? Because libraries change, and with them the figures of a backtest can change.
Sometimes the change is a documented improvement (pandas 3.0 changed the default type for text, lesson 0.5.2); other
times, a default value nobody looks at. An example with two libraries the course uses every day: the annualized
volatility of SINT computed with pandas and with NumPy.

```python
import numpy as np

precios = pd.read_csv("sint_diario.csv", parse_dates=["fecha"],
                      index_col="fecha")
rent = precios["cierre"].pct_change().dropna()

def vol_anual(r) -> float:
    return float(r.std() * 252 ** 0.5)

print(f"pandas, 5 years: {vol_anual(rent):.3%}")
print(f"NumPy,  5 years: {vol_anual(rent.to_numpy()):.3%}")
dic = rent.loc["2025-12"]
print(f"pandas, Dec 2025: {vol_anual(dic):.2%}")
print(f"NumPy,  Dec 2025: {vol_anual(dic.to_numpy()):.2%}")
```
```output
pandas, 5 years: 18.096%
NumPy,  5 years: 18.089%
pandas, Dec 2025: 13.97%
NumPy,  Dec 2025: 13.67%
```

The same method, `.std()`, gives different numbers: pandas divides by $n-1$ (sample standard deviation) and NumPy by
$n$ (population), unless asked otherwise with `ddof`. With five years of data the difference is negligible; with a
one-month window, like the one a system that scales its size to recent volatility would use, it changes the position
size. If an upgrade changed a default value like that, the backtest would give a different figure without a single
line of code changing.

That is why a result travels with its environment. In lesson 0.2.1 you saved the versions of Python, NumPy and
pandas; with `importlib.metadata` the whole environment can be summarized in a short fingerprint:

```python
from importlib.metadata import distributions

paquetes = sorted(f"{d.metadata['Name'].lower()}=={d.version}"
                  for d in distributions())
huella = hashlib.sha256("\n".join(paquetes).encode()).hexdigest()
print(len(paquetes), "packages installed")
print("Fingerprint:", huella[:12])
```
```output
163 packages installed
Fingerprint: e758dd2c21d8
```

If you save that fingerprint next to the Sharpe and the equity curve of each backtest, you will know at a glance
whether two results came from the same environment. In your project, the `uv.lock` versioned in Git does that job
itself: the commit that produced a result identifies the code and the environment at the same time.

Four common mistakes break that guarantee without anyone noticing:

- **Running plain `python`** outside `uv run`: depending on the terminal, it may be the system Python, with other
  libraries. If a script fails with `ModuleNotFoundError` and it worked in the notebook, start here.
- **Installing with `pip`** inside the project: the package never reaches `pyproject.toml` or `uv.lock`.
- **Editing `pyproject.toml` by hand and not locking again**: the lock falls out of date. `uv lock` (or any
  `uv run`) brings it up to date; `uv sync --locked` detects it.
- **Not committing `uv.lock`**: the repository stores what you ask for, but not what was installed.

## Exercises

1. Create the course project with the commands from the lesson, add `pandas`, `numpy` and, as a development
   dependency, `pytest`. Open `uv.lock` in an editor and count how many `[[package]]` entries it has. Why are there
   more than three?

**Solution.**

It will have about a dozen: the project itself, numpy, pandas, python-dateutil, six and tzdata (those of pandas), and
pytest with its own (iniconfig, packaging, pluggy, pygments and colorama, the latter only for Windows). There are more
than three because the lock also pins the indirect dependencies, which are the ones that most often change without
anyone noticing. The exact number depends on the versions on the day you do it.

2. Write a function `comprobar_exactas(fijadas)` that receives a dictionary `{"pandas": "3.0.6", "numpy": "2.5.4"}`
   with the versions a backtest was run with and returns a list of the libraries whose installed version does not
   match. Try it with a made-up version to see that it detects it.

**Solution.**

```python
def comprobar_exactas(fijadas: dict[str, str]) -> list[str]:
    distintas = []
    for nombre, esperada in fijadas.items():
        actual = version(nombre)
        if actual != esperada:
            distintas.append(f"{nombre}: {esperada} -> {actual}")
    return distintas

print(comprobar_exactas({"pandas": "3.0.6", "numpy": "2.5.4"}))
print(comprobar_exactas({"pandas": "2.2.3", "numpy": "2.5.4"}))
```
```output
[]
['pandas: 2.2.3 -> 3.0.6']
```

An empty list means the environment matches. In a live system, a check like this at startup avoids trading with
libraries the strategy has not been tested on.

3. A colleague hands you a notebook that "works" and a list of imports, but neither `pyproject.toml` nor a lockfile.
   What steps do you follow to turn it into a reproducible project with uv?

**Solution.**

`uv init` in a new folder; `uv add` for each library the notebook imports (if you know the versions it worked with,
pin them: `uv add "pandas==2.2.3"`); copy the notebook; run it from start to finish with `uv run jupyter lab` or
`uv run --with jupyter jupyter lab` and compare the results with theirs; if they match, commit `pyproject.toml`,
`uv.lock`, `.python-version` and the notebook. If they do not match, the difference lies in the versions or in the
notebook's hidden state (lesson 0.2.3).

## Self-assessment

1. Your `pyproject.toml` says `pandas>=3.0.6`. Which version of pandas gets installed on the computer of someone else who clones your repository and runs `uv sync`?

   - A. The latest one that exists that day, because `>=` sets no ceiling
   - B. The one pinned in `uv.lock`, if the lockfile is in the repository
   - C. Exactly 3.0.6, because it is the minimum
   - D. Whatever is installed in the system Python

   *Answer B: The one pinned in `uv.lock`, if the lockfile is in the repository.* `pyproject.toml` declares what the project accepts (a range); `uv.lock` stores the exact version that was resolved. `uv sync` installs what the lock says. Without a lock in the repository, each machine would resolve the range on its own and could end up with different versions.

2. Which file or folder is NOT pushed to Git?

   - A. `pyproject.toml`
   - B. `uv.lock`
   - C. `.python-version`
   - D. `.venv/`

   *Answer D: `.venv/`.* `.venv/` is the result of installing: it weighs hundreds of megabytes, contains binaries for your operating system and is regenerated with `uv sync` from the other three. `uv init` already adds it to `.gitignore`.

3. In a cell, `sys.prefix != sys.base_prefix` returns `False`. What does it mean?

   - A. The code is running with the base Python, outside any virtual environment
   - B. pandas is not installed
   - C. The project has no `pyproject.toml`
   - D. The Python version is old

   *Answer A: The code is running with the base Python, outside any virtual environment.* Inside a virtual environment, `sys.prefix` points to the environment's folder and `sys.base_prefix` to the Python it was created from. If they match, you are in the base Python: any `pip install` would end up there.

4. A backtest gave a Sharpe of 0.84 in March and 0.81 in October, with the same code and the same data. What do you check first?

   - A. That the strategy has stopped working
   - B. Whether the environment has changed: compare `uv.lock` (or the versions saved with the result) between the two runs
   - C. The speed of the computer
   - D. Nothing: it is normal random noise

   *Answer B: Whether the environment has changed: compare `uv.lock` (or the versions saved with the result) between the two runs.* With the same code and the same data, a deterministic result only changes if the environment changes. If the versions are identical, the next suspect is an unset random seed.

## Key points

- A virtual environment is a folder (`.venv`) with its own libraries and a link to a base Python. Each project gets its own.
- `pyproject.toml` declares what you ask for (ranges); `uv.lock` pins what was resolved (exact versions and hashes of every package, including indirect ones).
- `uv add` writes to both files; `uv sync` rebuilds `.venv` from the lock; `uv run` runs inside the environment.
- `pyproject.toml`, `uv.lock` and `.python-version` go to Git; `.venv/` never does.
- Same code, same data and same lock = same result. A change of version or of a default value can move the figures of a backtest.

## Resources

### Research

- [The Working Environment - 1](https://pythonparatrading.com/en/docker-for-trading/) · Research-0013 · 4 min

### Documentation

- [uv: working on projects (official documentation)](https://docs.astral.sh/uv/guides/projects/) · docs.astral.sh
- [uv: locking and syncing](https://docs.astral.sh/uv/concepts/projects/sync/) · docs.astral.sh
- [Python Packaging User Guide: pyproject.toml specification](https://packaging.python.org/en/latest/specifications/pyproject-toml/) · packaging.python.org
- [Python documentation: venv, creation of virtual environments](https://docs.python.org/3/library/venv.html) · docs.python.org
- [Python documentation: importlib.metadata](https://docs.python.org/3/library/importlib.metadata.html) · docs.python.org

Previous lesson: [0.2.1 · Installing Python with uv: versions and support cycle](https://pythonparatrading.com/en/python-trading-course/level-0/install-python-with-uv/)

Next lesson: [0.2.3 · Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)](https://pythonparatrading.com/en/python-trading-course/level-0/code-editors-jupyter-marimo-notebooks/)
