Module 3: Reproducing A Ci Failure Locally

4. Pinned dependencies vs ranges (`==` vs `>=`)

Description

This is the core lesson of the module, because it attacks the queen of the causes of "CI red, local green": a dependency that on your machine has one version and in CI has another. By the end you'll understand exactly why the two machines end up with different versions of the same library —even though both "install from the same requirements.txt"— and you'll have the tool that prevents it: pinning, fixing the exact version with ==.

You're going to dissect the requirements.txt file, the difference between == (pin, exact version), >= (range, "this or newer") and putting nothing (the worst of all), and the concept of a lockfile —the exact snapshot of all installed versions that pip freeze gives you—. And not in the abstract: you're going to see, with a real venv I actually ran, how a pytz>=2022.1 on a fresh install resolves to the latest available version, and how the same Reservo test passes with the old version and fails with the new one. It's the root cause of the module's failure, demonstrated step by step.

Connection to the module. Lesson 3 gave you the map of the environment's layers; this one goes down to the noisiest layer, the dependencies, and to the tool that controls it. It's the basis for lesson 5 (setting up a clean venv with those exact versions) and for lesson 7 (the complete reproduction method, where extracting the versions from the CI log is the first step). All on Reservo's requirements.txt and its pytz dependency.

The analogy: "bring milk" vs "bring this exact milk"

You send someone to the supermarket with a note. If the note says "bring milk", they're going to bring some milk —whatever's there, whatever's on sale, the brand they like—. Two people with the same note, on two different days, bring different milks, and neither is wrong: the note allowed any. If the recipe you were going to cook specifically needed whole milk and they brought lactose-free, the dish comes out different, and it's not the fault of whoever went to the store: it's the fault of the ambiguous note.

If instead the note says "bring whole milk brand X, 1-liter carton, barcode 7501...", anyone who goes, any day, brings exactly the same milk. The note leaves no margin. The dish comes out identical every time.

requirements.txt is that note, and pip is who goes to the supermarket (PyPI, Python's package repository). A line like pytz or pytz>=2022.1 is "bring milk": pip brings some version that meets it —usually the newest available that day—. A line like pytz==2026.3.post1 is "bring this exact milk": pip brings that version and no other. When your note is ambiguous, your machine (which went to the store a year ago) and CI (which goes today) bring different versions, and your suite —the recipe— comes out different on each one. Pinning is writing the note without ambiguity.

Said directly:

A requirements.txt with ranges (>=) or no version lets two installations bring different versions of the same library; pinning with == fixes the exact version, so any installation —your machine, CI, a teammate's— gets the same. Reproducibility starts with an unambiguous note.

Anatomy of requirements.txt

requirements.txt is a plain-text file, one dependency per line, that tells pip what to install. It's used with pip install -r requirements.txt. Its simplest form is a list of names:

pytz
pytest

But each line can carry a version specifier attached to the name, and there's the whole game. The ones you'll see daily:

  • No specifierpytz. "Bring whichever" (in practice, the latest available). Maximum ambiguity.
  • Exact pinpytz==2026.3.post1. "Bring exactly this." Maximum reproducibility. The operator is ==.
  • Range with minimumpytz>=2022.1. "Bring this version or any newer." Ambiguous upward: today it brings one, tomorrow it can bring another newer.
  • Bounded rangepytz>=2022.1,<2027.0. "At least this, but below 2027." Less ambiguous, but still leaves margin within the range.
  • Compatiblepytz~=2026.3. The ~= operator means "this version or a compatible newer one within the same stretch" (roughly, "the last number can go up but not the major one"). A middle ground.

These operators are part of a Python standard (the version specifiers specification, formerly known as PEP 440), so they work the same with any tool in the ecosystem. The file also admits comments (lines that start with #) and other things we don't need here.

Reservo's requirements.txt, as the dev who added the local-time function left it, had this line —and in it is the seed of the failure—:

# Reservo's requirements.txt (with the reproducibility bug)
pytz>=2022.1

pytz>=2022.1 says "bring pytz, version 2022.1 or newer". It seems reasonable —"at least 2022.1, to have the recent fixes"—, but it's an ambiguous note: it doesn't say which newer one. And that's where the two kitchens split.

Why the same file installs different versions

Here's the heart of the matter, and it's subtler than it seems, because the requirements.txt is the same on both machines. How can they install different versions from the same file? Because of the moment each one installed and what they already had.

On your machine. You installed Reservo's dependencies a year ago, when the latest pytz was 2022.1. You ran pip install -r requirements.txt, pip saw pytz>=2022.1, and since the newest that day was 2022.1, it installed that. Since then you haven't installed anything again: pytz 2022.1 is still there, sedimented. Every time you run the tests, you use that 2022.1.

In CI. The runner is ephemeral: each run starts without any pytz. It runs pip install -r requirements.txt, pip sees pytz>=2022.1, goes to PyPI today and looks for the newest that meets "≥ 2022.1". Today the newest is 2026.3.post1, so it installs that. CI doesn't have your sediment; it starts from scratch and grabs the latest.

Result: the same requirements.txt, two different versions2022.1 on your machine, 2026.3.post1 in CI—, just because one installed a year ago and the other installs today, and the note (>=) allowed both. If the line had said pytz==2022.1, both would have had exactly 2022.1 and there'd be no gap. If it had said pytz==2026.3.post1, both would have had 2026.3.post1. The pin eliminates the margin; the range leaves it open, and "the moment of installing" slips through that margin.

This is exactly the mechanism that broke Reservo's test. Your machine's pytz 2022.1 still believes Mexico City has daylight saving time; CI's pytz 2026.3.post1 already knows it doesn't. The >= let each machine grab different time-zone data, and with it the local-time test passed on one and failed on the other.

Worked example: see it happen in a real venv

Don't take my word for it; watch it. I set up a clean venv with Reservo's requirements.txt as-is (pytz>=2022.1) and observed which version pip resolves today, on a fresh install —exactly what CI does—.

What to expect. You create the environment, install from the requirements.txt with the range, and ask which version ended up:

$ python3.14 -m venv fresh-venv
$ fresh-venv/bin/python -m pip install -r requirements.txt
$ fresh-venv/bin/python -c "import pytz; print('pytz resolved to:', pytz.__version__)"
pytz resolved to: 2026.3.post1

There's the mechanism, laid bare: the requirements.txt said pytz>=2022.1, and a fresh install resolved to 2026.3.post1 —the newest available—, not the 2022.1 you have sedimented. That version jump, which the range allowed silently, is the gap.

Now run Reservo's test in two environments that differ only in the version of pytz. First, the one that replicates your machine (with the old 2022.1):

$ old-venv/bin/python -c "import pytz; print(pytz.__version__)"
2022.1
$ old-venv/bin/python -m pytest test_localtime.py -q
1 passed in 0.02s

And the one that replicates CI (with the fresh 2026.3.post1):

$ fresh-venv/bin/python -c "import pytz; print(pytz.__version__)"
2026.3.post1
$ fresh-venv/bin/python -m pytest test_localtime.py -q
F                                                                        [100%]
=================================== FAILURES ===================================
____________________ test_summer_booking_starts_at_16_local ____________________

>       assert local_start_hour(a_booking(), "America/Mexico_City") == 16
E       AssertionError: assert 15 == 16

test_localtime.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_localtime.py::test_summer_booking_starts_at_16_local - assert 15 == 16
1 failed in 0.04s

Green with the old, red with the new. The only variable that changed between the two runs is the version number of a library, and that number was determined by when it was installed, because the note (>=) allowed it. This isn't an artificial lab case: it's the mechanism by which thousands of suites break "without anyone touching anything". No one touched the code; something —time— moved the version the range left loose.

The lockfile: the snapshot of what really got installed

If the problem is that >= leaves the version to the chance of the moment, the underlying solution is to freeze the exact versions that worked and use them everywhere. That frozen snapshot is called a lockfile, and the simplest tool to get it is pip freeze: it lists every dependency installed in the current environment, with its exact version, in name==version format —ready to paste into a requirements.txt—.

What to expect. In the venv that already has everything installed:

$ python -m pip freeze
iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
pytest==9.1.1
pytz==2026.3.post1

Look at what it gives you, and what that means. Not only does pytz appear with its exact version; all the environment's libraries appear, including the ones you didn't ask for directly —iniconfig, packaging, pluggy are dependencies of pytest, which were installed on their own—. A lockfile captures the complete tree, not just your direct dependencies. That's its virtue: if you save this snapshot and reinstall it on another machine, you get exactly the same set of versions, down to the indirect ones. The kitchen is cloned.

The typical flow to make a project reproducible is: you install and work until the suite is green, you run pip freeze to capture the versions that work, and you save that output as the requirements.txt (or as a separate requirements.lock). From then on, pip install -r in any kitchen —your machine, CI, a teammate's— reproduces the same environment. The ambiguous >= became a bunch of exact ==, and the dependency gap disappears.

An honest note about scope: pip freeze is the simplest and most direct form of a lockfile, and enough for what this module needs. Newer tools (like pip-tools, Poetry, or uv) generate more complete lockfiles, which also fix hashes and separate direct from indirect dependencies. The principle is the same in all: freeze exact versions so the installation is deterministic. Here we stick with pip freeze and requirements.txt, which is the minimum that closes the module's gap.

Pin, range, and no version: when each one

If pinning with == gives reproducibility, why does >= even exist? Because there's a real tension between two good things: reproducibility (that the install is always the same) and freshness (receiving fixes and improvements from libraries without editing the file by hand). An honest look at the three modes:

  • No version (pytz) — almost never a good idea for a project that runs in CI. Maximum freshness, zero reproducibility: each install can bring something different, and it exposes you to a new version breaking your suite without warning. It's the most ambiguous note possible.
  • Range (pytz>=2022.1) — comfortable but treacherous, exactly for what you saw: it lets the moment of installing decide the version, so your local and CI diverge. It has its place when you publish a library others consume (you want to be flexible with your users' versions), but for the application you run in CI it's a source of ghost failures.
  • Exact pin (pytz==2026.3.post1) — the default choice for an application with CI. Maximum reproducibility: all kitchens get the same. The cost is that updating requires editing the file (or regenerating the lockfile) on purpose —which, viewed properly, is a virtue: you upgrade a version when you decide and run the tests, not when the chance of the moment decides for you—.

The practical rule for an app: pin everything (use a pip freeze lockfile), and update the versions deliberately and controlled —you change the pin, run the suite, if it's still green you push the change—. That way you manage freshness yourself, with your tests' net underneath, instead of leaving it at the mercy of when the install ran. Module 4's version matrix is the other side of this: instead of preventing versions from changing, it tests your suite against several on purpose, so you know in advance which ones you work with.

Deep dive: "without anyone touching anything", explained

There's a phrase repeated in every team when this failure appears: "but no one touched anything, how did it break?". It's literally true —the code didn't change, the test didn't change, the requirements.txt didn't change— and still the build went from green to red. The deep dive that closes the module is understanding that this phrase hides an error of assumption: it believes "nothing changed" because it only looks at the repository. But the environment of a CI run isn't determined only by the repository; it's determined by the repository plus the state of the world at the moment of installing. And the world did change: between one run and another, a new version of pytz was published.

Look at it as a function. The result of your suite is result = f(code, environment). You control code (it's in git, versioned, with history). But environment, when you have a >=, isn't fully under your control: one part of it —which version the range resolves to— is decided by when the install runs, which is an external variable that doesn't live in your repo. So even though code doesn't change, environment can change on its own, and with it the result. "No one touched anything" is true about code and false about environment. The failure didn't come from nowhere; it came from the one input you left unfixed.

This reframes what pinning really does. Pinning isn't "being careful with versions"; it's moving the environment variable from outside your control to inside git. With pytz==2026.3.post1 in a versioned file, the version is no longer decided by the moment of installing: it's decided by a line you wrote, which has history, which is reviewed in a pull request, which changes only when someone makes a deliberate commit. The environment becomes as versioned and auditable as the code. And then "no one touched anything" recovers its honest meaning: if really no one touched the code or the pins, the result can't change —because there's no longer any loose input for the world to slip through—. Reproducibility, at bottom, is that: leaving no input of f outside version control.

Common mistakes

Believing "the same requirements.txt" guarantees the same environment. What happens: since CI and your machine install from the same file, you assume they end up identical, and you rule out dependencies as the failure's cause. Why it happens: it's intuitive that "same file → same result". How to spot it: if the requirements.txt has >= or lines with no version, "the same file" guarantees nothing. How to fix it: remember that a range resolves depending on when you install; compare the real versions with pip freeze on each machine, not the file's lines. Two different pip freeze with the same requirements.txt is the proof of the crime.

Pinning only your direct dependencies and forgetting the indirect ones. What happens: you fix pytz==... and pytest==... in your requirements.txt, but not the libraries those drag in (pluggy, packaging, etc.), and an update of an indirect one breaks something. Why it happens: the indirect ones are invisible in your file; you didn't even write them. How to spot it: if your requirements.txt has fewer lines than the output of pip freeze, you have unpinned indirect dependencies. How to fix it: use pip freeze to capture the complete tree as a lockfile, not just your direct dependencies. A deterministic environment fixes everything that gets installed, not just what you asked for.

Updating the pin "by eye" without running the suite. What happens: you bump pytz==2022.1 to pytz==2026.3.post1 to "stay current", commit, and it turns out the new version breaks a test —you find out in CI—. Why it happens: it feels safe to bump a version, and you forget a new version can change behavior. How to spot it: if you changed a pin and didn't run pytest locally before pushing, you skipped the safety net. How to fix it: every pin change is a potential behavior change; treat it as such. Change the pin, run the complete suite in a clean venv, and only if it's still green, push it. That's the meaning of pinning: that updates pass through your tests, not by surprise.

Exercises

Exercise 1 — Read the note. For each line of requirements.txt, say which version a fresh install would install today and whether it's reproducible (would two installs on different dates give the same?). Assume the published versions of pytz are 2022.1, 2024.2, and 2026.3.post1. (a) pytz. (b) pytz==2024.2. (c) pytz>=2022.1. (d) pytz>=2022.1,<2025.0.

See solution
  • (a) pytz: installs the newest available → 2026.3.post1 today. Not reproducible: tomorrow, if a 2027.x comes out, it'd install that.
  • (b) pytz==2024.2: installs exactly 2024.2. Reproducible: any install, any day, gives 2024.2.
  • (c) pytz>=2022.1: installs the newest that's ≥ 2022.1 → 2026.3.post1 today. Not reproducible: the "newest" changes over time.
  • (d) pytz>=2022.1,<2025.0: installs the newest that's ≥ 2022.1 and < 2025.0 → 2024.2 (the 2026.3.post1 is excluded by the <2025.0). Almost reproducible: today it gives 2024.2, and would keep giving it as long as no version between 2024.2 and 2025.0 is published; the <2025.0 ceiling reduces the margin but doesn't fully eliminate it.

Exercise 2 — From range to lock. You have a venv where Reservo's suite is green, with this pip freeze:

iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
pytest==9.1.1
pytz==2022.1

Your current requirements.txt says only pytz>=2022.1 and pytest. Write the requirements.txt that would make this environment reproducible, and explain why you'd include more lines than you had.

See solution

The reproducible requirements.txt is, literally, the output of pip freeze (the lockfile):

iniconfig==2.3.0
packaging==26.2
pluggy==1.6.0
pytest==9.1.1
pytz==2022.1

Why more lines than the original two: the old requirements.txt only listed your direct dependencies (pytz and pytest). But pytest drags in indirect dependencies —iniconfig, packaging, pluggy— that were installed on their own and that can also change behavior between versions. A lockfile fixes them all, so another machine gets the identical complete tree, not just pytz and pytest. Also, they were pinned with == (exact version) instead of >=, so the "when it's installed" can no longer move any version. With this file, any pip install -r reproduces the environment where the suite was green.

Exercise 3 — Explain the divergence. A teammate insists: "I don't get it, we both have pytz>=2022.1 in the same requirements.txt, how is it possible that I end up with 2022.1 and CI with 2026.3.post1?" Explain the mechanism to them in three or four sentences, without jargon.

See solution

A possible explanation: "pytz>=2022.1 doesn't say one version; it says 'the 2022.1 or any newer'. When you installed, a year ago, the newest that existed was 2022.1, so you ended up with that —and it's still there, you never updated it—. CI, on the other hand, installs from scratch on every run: today it goes to PyPI, looks for 'the newest that's ≥ 2022.1', and today that's 2026.3.post1, so it installs that. The same file, but each installed at a different moment, and the >= let 'the moment' choose the version. If instead of >= we put == with an exact version, we'd both have the same regardless of when we installed."

Summary and next step

In this lesson you attacked the queen of the causes of "CI red, local green": a dependency with different versions on each machine. You dissected requirements.txt and its specifiers —no version (maximum ambiguity), == (pin, reproducible), >= (range, ambiguous upward), bounded ranges and ~=— and understood the exact mechanism of the divergence: the same requirements.txt with a >= installs different versions depending on when it runs, because your machine installed a year ago (and sedimented 2022.1) and CI installs today (and grabs 2026.3.post1). You saw it happen in a real venv: pytz>=2022.1 resolved to 2026.3.post1 on a fresh install, and the same Reservo test passed with the old and failed with the new.

You met the lockfile —the exact snapshot of all installed versions, which pip freeze gives you ready to paste into requirements.txt, including the indirect ones— and the rule for an application with CI: pin everything and update deliberately, with your tests' net underneath, instead of leaving the version at the mercy of the install moment.

Before moving on you should be able to: explain why >= makes CI and local diverge even though the file is the same; write an exact pin and a range; generate a lockfile with pip freeze and say why it includes more lines than your direct dependencies; and choose between pin and range depending on whether it's an app or a library.

What's next is putting the pin into practice in the tool that reproduces CI's environment inside your machine. Lesson 5 sets up a clean venv —an empty kitchen, without sediment— and installs in it the exact versions CI used with pip install -r requirements.txt, to reproduce the red at will. It's where this lesson's pin becomes a real reproduction.

Resources