What brokeUpdated 3 October 2026
Green locally, red in CI: an undeclared transitive dependency
A Python backend relied on tzlocal arriving through Celery. The local image already had it, but a clean install in CI did not, and pytest failed on an import. The fix was one explicitly declared dependency.
The short version
What went wrongThe backend relied on tzlocal arriving via Celery without declaring it; the local image had it, but a clean CI install did not and pytest failed on import.
How we found itCI failed with ModuleNotFoundError for tzlocal, and the fix commit traced it to Celery importing it at module load.
The rule nowDeclare any package needed at import time explicitly with a comment saying why, and let a clean CI install judge whether the dependency list is complete.
This post documents something Claude got wrong. The claim rests on a commit or a file, not on memory. All of Claude's mistakes
This one is small. The tests passed on our machines and failed in CI. The cause was a package that nobody had declared, which got into the environment as a dependency of something else.
What happened
On 2026-06-16 a CI run on one of our Python backends failed in a pytest import, with this error:
ModuleNotFoundError: No module named 'tzlocal'
The fix commit the next morning (2026-06-17) explains the chain. Translated from the Norwegian commit message: Celery imports tzlocal at module load time, in celery.utils.time. A fresh pip install in CI did not pull tzlocal in, so the import failed. tzlocal is “normally transitive via celery”, meaning it usually comes along as one of Celery’s own dependencies. Our project never asked for it by name.
The backend’s dependency list included Celery (with the Redis extra), plus Alembic, pgvector, Redis, structlog, the OpenAI client and others. tzlocal was not on it. Most of the time that didn’t matter, because installing Celery usually brought tzlocal along.
Why it was green locally
Again from the commit message: “The backend image already had it, therefore green locally but red in CI’s clean install.”
That sentence sums up the whole failure. The local environment was a built image that already contained tzlocal, so any import that needed it worked. CI ran a fresh pip install, and on that run the install did not pull tzlocal in.
So the local setup was not lying, exactly. It was holding on to a package the project had never asked for. A test suite that passes in an environment like that tells you the code works there. It does not tell you the project’s declared dependencies are enough.
What we don’t know
The commit records the symptom and the fix. It does not explain why the clean install left tzlocal out that time, when Celery normally brings it in. We could guess (a change in an upstream package, a resolver decision, a version combination), but the material doesn’t say, so we won’t.
Henning writes no code himself, so the dependency list that left tzlocal undeclared was Claude’s. The commit does not say when the gap came in.
How it was found
CI found it, which is what CI is for. The failure was hard to miss: an import error, not a flaky assertion deep inside a test. The error named the missing module.
The commit names the source: Celery’s time utilities, which load tzlocal as soon as the module is imported. That is why the failure hit at import time.
The fix
The fix was one added line in the backend’s dependency list: tzlocal, with a minimum version of 5.2. Next to it is a short comment explaining the reason: Celery imports it at module load, it is normally transitive, CI hit a run where it was not pulled in, and so it is now declared explicitly “for deterministic builds”.
Two notes on that.
First, the commit message calls this “pinned”, but the change sets a lower bound, not an exact version. That is enough to guarantee tzlocal gets installed, which was the problem. It does not freeze the version.
Second, the comment in the dependency file matters as much as the line itself. The comment records why it is there.
Total change: one dependency line plus a four-line comment, committed the morning after the failed run.
What this says about local environments
A built image or a long-lived virtual environment builds up packages over time. Some come from the declared dependencies, some from their dependencies, some from earlier versions of the project. As long as everything a project uses is still in there, tests pass, whether or not the project declares those packages.
A clean install in CI has nothing to build up. It contains exactly what the dependency list leads the installer to resolve on that day. That makes it the honest test of whether the dependency list is complete. Here, it wasn’t.
The same pattern applies to any package your code, or a library you call, imports at runtime that you never listed yourself.
The rule
Declare dependencies explicitly. If our code, or a library we rely on, needs a package at import time, it goes on our own dependency list with a comment saying why, even if it usually arrives transitively. Let a clean CI install be the judge: it shows what local environments hide.
Corrections
- Attribution corrected: an earlier version said the material did not show who wrote the code and did not put the mistake on Claude. Henning writes no code himself; the code was written by Claude.