Python pip — Container ImportError from Global Packages
Container exited with ImportError: cannot import name 'Session' from 'requests' due to global packages.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Virtual environments isolate each project's dependencies in a private site-packages directory — create one per project, no exceptions
- pip install fetches packages into the active environment — always activate a venv first or you're installing into the global Python and asking for trouble
- requirements.txt pins dependencies for reproducibility; pip freeze captures everything including transitive deps, which is right for apps but wrong for libraries
- pyproject.toml is the modern standard replacing setup.py — it's declarative static data, not executable code, which makes it faster and safer for tooling to parse
- Always test on TestPyPI before publishing to real PyPI — you cannot overwrite or delete a published version, ever
- Use >= ranges for library dependencies, exact pins for application deployments — mixing these up causes dependency conflicts for your users
Imagine your kitchen has a spice rack. Every dish you cook needs different spices — some recipes need chili, others need cinnamon. If you threw every spice into one giant pile, recipes would clash and ruin each other. Python packaging is your way of giving each project its own private spice rack. pip is the delivery service that fetches exactly the spices you need, and a virtual environment is the isolated kitchen counter where only that project's spices live. The requirements.txt is your shopping list — hand it to pip and it fetches exactly what you need. pyproject.toml is the recipe card for your dish itself, the one you hand to other chefs so they can recreate it exactly.
Every Python project you build beyond a single script will eventually depend on code someone else wrote — a web framework, a data parser, a testing library. The moment you install that code, you're participating in Python's packaging ecosystem, whether you realize it or not. Get it wrong and you end up with the classic 'it works on my machine' problem, broken deployments, and version conflicts that take hours to debug and are genuinely difficult to explain to a product manager.
The good news is that Python's packaging ecosystem has matured significantly. The chaos of setup.py, setup.cfg, and MANIFEST.in is giving way to pyproject.toml. pip has better dependency resolution than it did even a few years ago. Virtual environments are a two-command operation. The tools have caught up to the problems they were designed to solve.
This article covers the four things you actually need to know: how virtual environments work and why they're non-negotiable, how to manage dependencies reproducibly with requirements.txt and pip-compile, how to structure a project with pyproject.toml, and how to publish a package to PyPI without making an irreversible mistake. Every section comes from real production experience — I'll tell you what trips people up and why, not just what the commands are.
Why Python pip Can Leak Global Packages Into Your Container
Python pip is the default package installer for Python, pulling libraries from the Python Package Index (PyPI) and placing them into site-packages directories. Its core mechanic is dependency resolution: it reads a requirements file or CLI arguments, resolves version constraints, downloads wheels or source distributions, and installs them into a target environment. The critical detail is that pip installs into whatever Python interpreter it's bound to — system Python, a virtual environment, or a container image layer — and it does not isolate packages from the global site-packages unless explicitly told to.
In practice, pip's behavior depends on the environment's Python path. When you run pip install inside a Docker container without first creating a virtual environment, packages land in /usr/local/lib/python3.x/site-packages. If your container image inherits a base image that already has packages there (e.g., from apt-installed python3-requests), pip's installation can silently shadow or conflict with those global packages. The result: an ImportError that only appears in production because the container's global site-packages differ from your local virtualenv. Pip has no built-in guard against this — it's your responsibility to control the target.
Use pip with virtual environments (or pip install --target) whenever you need reproducible, isolated dependencies. In containerized deployments, always create a fresh virtual environment inside the image and activate it before installing. This prevents the 'works on my machine' class of bugs where a global package version mismatch causes runtime failures. Pip is a tool, not a policy — the isolation is up to you.
pip install outside a virtualenv modifies the system Python's site-packages — this is often unintentional and can break OS-level tools that depend on specific package versions.python:3.11-slim which pre-installs urllib3 via apt. Their requirements.txt pinned urllib3<2. Pip installed the older version, but the apt version remained in the same site-packages directory, causing a ModuleNotFoundError: No module named 'urllib3.util.ssl_' at startup.ImportError on a submodule that exists in one version but not the other, with no clear trace because both versions are present.Why Virtual Environments Are Non-Negotiable
When you install a package globally with pip, it lands in your system Python installation. That sounds fine until Project A needs requests==2.28 and Project B needs requests==2.31. Python can only hold one version of a package at a time in a given Python installation, so one of your projects will always be broken. Virtual environments solve this by creating a completely isolated Python interpreter and its own package directory for each project. Every project gets its own private spice rack, and they never interfere with each other.
Creating one is a single command, but understanding what it actually does changes how you work. A virtual environment is just a folder containing a Python binary, a pip binary, and a site-packages directory. When you activate it, your shell's PATH is temporarily updated so that typing 'python' points to that isolated binary instead of your system one. When you deactivate, the PATH change is reversed. Nothing magical — just smart path management. The packages you installed in the venv are still there in the folder; they're just not on your active PATH anymore.
This also means you can have as many virtual environments as you want without them interfering with each other or with your system Python. Some developers create one per project (the most common approach), some create per-branch environments for complex multi-branch work. The cost is two commands and a few hundred megabytes of disk. The cost of not using them is dependency hell, broken deployments, and the specific kind of frustration that comes from code that works on your machine and nowhere else.
In 2026, you also have the option of uv — a Rust-based package manager and virtual environment tool from Astral — which creates environments and installs packages significantly faster than the traditional pip + venv combination. Whether you use python -m venv or uv venv, the conceptual model is identical.
Pinning Dependencies with requirements.txt — The Right Way
A requirements.txt file is a plain-text manifest of every package your project needs to run. pip reads it line by line and installs everything specified. This is what makes your project reproducible across different machines, CI pipelines, teammates' laptops, and production servers — anyone who runs pip install -r requirements.txt gets an environment that behaves identically to yours.
There is a critical distinction that trips up most developers early on: direct dependencies versus transitive dependencies. Direct dependencies are packages you explicitly import in your code — requests, flask, pytest. Transitive dependencies are packages that your dependencies depend on — certifi, urllib3, click. When you run pip freeze, you get every single installed package at exact versions, including all the transitive ones. This is complete and deterministic, which is exactly what you want for application deployments. But it's too restrictive for libraries you publish for others to use, because pinning transitive dependencies to exact versions will almost certainly conflict with your users' other packages.
The professional workflow that solves both problems: create a requirements.in with loose version constraints for your direct dependencies only, then use pip-compile from the pip-tools package to resolve the full dependency graph and generate a deterministic requirements.txt with every transitive dependency pinned and annotated with which direct dependency caused it. This gives you a human-editable source of intent (requirements.in) and a machine-generated lockfile (requirements.txt). Commit both. CI installs from the pinned file. When you need to update dependencies, you edit requirements.in and re-run pip-compile — the diff shows you exactly what changed and why.
pyproject.toml — The Modern Way to Define a Python Package
For years, Python packaging was a mess of setup.py, setup.cfg, and MANIFEST.in files with inconsistent behaviour and no clear standard. PEP 517 and PEP 518 introduced pyproject.toml as the single unified configuration file for Python projects. Every modern project — whether you're publishing to PyPI or just organising an internal monorepo — should use it. If you're still using setup.py for a new project in 2026, you're taking on unnecessary legacy complexity.
The key insight that makes pyproject.toml important is what it changes architecturally: it separates 'build system requirements' (the tools needed to build your package) from 'project metadata' (what your package actually is). This lets pip know how to build your package before it has even installed your build tool. Previously, setup.py required setuptools to already be installed — a genuine chicken-and-egg problem. With pyproject.toml, pip reads the [build-system] table, creates an isolated build environment, installs the build tools declared there, and then builds. No pre-installed tools required.
The [project] table is where you declare your package name, version, dependencies, and entry points. Entry points are particularly useful in practice — they let you define command-line scripts that get installed alongside your package, so users can run your-tool directly in their terminal rather than typing python -m your_module.cli every time. If you've ever installed a package and gained a new CLI command, that's an entry point.
For build backends, the main options in 2026 are hatchling (part of Hatch, modern and fast), setuptools (the legacy standard, still widely used), flit (minimal, good for pure Python packages), and maturin (for Rust extensions). The choice of backend doesn't affect your package's users — they never see it. It only affects how your package gets built during python -m build.
Publishing Your Package to PyPI — From Local to the World
Publishing to PyPI (Python Package Index) feels intimidating until you understand it's just two commands after a one-time setup. PyPI is the repository pip queries by default whenever you run pip install. Publishing there means anyone in the world can install your package with pip install your-package-name — no other configuration needed.
The build process produces two distribution formats: a wheel (.whl) — a pre-built binary that installs in milliseconds — and a source distribution (.tar.gz) that gets built on the user's machine. The wheel is the fast path that handles the vast majority of installations. The source distribution is the fallback for unusual platforms or architectures where no pre-built wheel is available. Always publish both.
Before publishing to the real PyPI, test on TestPyPI first. It lives at test.pypi.org, behaves identically to the real PyPI, and is completely separate — packages published there don't appear on the real PyPI and vice versa. You can upload, install, and verify the complete experience without any risk. This step is not optional: you cannot overwrite or delete a published version on the real PyPI. If you publish 1.0.0 with a broken README or a missing file, that version is permanently broken and you'll need to publish 1.0.1 to fix it.
For production-quality publishing workflows, use a GitHub Actions pipeline that triggers on version tag pushes. Store your PyPI API token as a GitHub Secret, use Trusted Publishers (PyPI's OIDC integration) to avoid token management entirely, and build in a clean environment every time. This removes the 'I published from my half-configured dev machine' class of problems entirely.
pip vs pip3 vs pip2 — Why Your Container Just Broke at 3 AM
You type pip install requests and it works on your laptop. Your CI pipeline uses pip3 and also works. Then your colleague runs the same command on a legacy image and gets a wall of red. Welcome to Python's packaging identity crisis.
Python 2 is dead. But its ghosts live in enterprise Docker images pinned to Ubuntu 16.04. The binary pip might point to Python 2.7 or Python 3 depending on how Python was installed. pip3 always targets Python 3. pip2 targets Python 2 — and shouldn't exist in any image you deploy today.
The trap: inside a virtual environment, pip is symlinked to the Python version that created the venv. Outside a venv, you're at the mercy of your distro's symlink game. Alpine images ship pip for Python 3. Debian Buster defaults pip to Python 2 if both are installed.
Never rely on pip alone. Always verify with pip --version. Or better: standardise on python -m pip for every invocation. That guarantees you're using the pip associated with the Python interpreter you think you're using. No symlink roulette.
RUN pip install outside a venv and the base image ships both Python 2 and 3, you are installing into Python 2 silently. Pin your base image version and always use python -m pip in your Dockerfiles.python -m pip instead of bare pip to avoid Python version mismatch.Installing Packages from GitHub — The Dependency Hell You Code Yourself Into
Your startup's internal library isn't on PyPI. It lives on a private GitHub repo. Someone says 'just pip install from git'. Two months later, you can't reproduce the build and your CTO is asking why staging is broken.
pip can install directly from git URLs. That's not the problem. The problem is that pip install git+https://... pulls the latest commit from the default branch unless you pin a refspec. No tag? No commit hash? You just introduced a floating dependency into your production pipeline. Every deployment becomes a Schrödinger's build.
The fix is brutal but simple: always pin to a commit hash or a tag. If the repo doesn't tag releases, you need to have a hard conversation with the maintainers. Also: never put credentials in the URL. Use SSH keys or your CI's deploy token.
Another footgun: pip caches git clones in ~/.cache/pip. If your CI runner shares a home directory across builds (don't), you'll get stale clones. Always do pip install --no-cache-dir git+... in CI to force a fresh clone.
UV vs. Poetry — Why Your Next Project Shouldn't Start with Poetry
Poetry was the savior from pip’s dependency hell. Now it’s the bottleneck. UV is a Rust-based drop-in replacement that installs packages 10-100x faster and resolves dependencies without melting your laptop. Poetry still works. UV makes you forget what pip install ever felt like.
The core difference: Poetry treats your project as a package by default. UV treats speed and correctness as the default. UV supports PEP 517/518 builds natively, has zero config for monorepos, and doesn’t force a pyproject.toml lock-in dance. Poetry’s lock file is fine. UV’s resolver is nuclear. If you’re starting a new project today and you aren’t running on a Raspberry Pi from 2015, pick UV.
Production reality: UV integrates with existing requirements.txt and pyproject.toml files. Poetry doesn’t play well with others. UV is what Poetry would be if it had been rewritten by people who debug production outages.
Can I Use UV With Existing pip requirements.txt Files? — Yes, And It's Stupid Easy
Short answer: yes. Long answer: you should have switched yesterday. UV reads requirements.txt as a native format. No conversion, no uv init ceremony. If your CI/CD pipeline is still crying over pip install -r requirements.txt taking 90 seconds, drop in UV and watch it finish in 4.
Here’s how it works: replace pip install -r requirements.txt with uv pip install -r requirements.txt. That’s it. UV respects the same --no-cache, --target, and --constraint flags. Your Dockerfile? Change one line. Your Makefile? One grep. Your soul? Restored.
Why this matters in production: UV’s resolver is parallelized and stateless. It doesn’t re-download wheels it already verified. If your requirements.txt has torch, tensorflow, or any binary-heavy package, UV will save you 3-5 minutes per build. That’s not nice. That’s do-or-die for deploy latency.
--system flag to uv pip install if you want to bypass venv in containers. Otherwise, UV defaults to insisting on a virtual environment — a good habit you can break deliberately.uv pip install -r requirements.txt works exactly like pip, only faster. No migration pain, just time saved.UV vs. Conda — Why Your Python Environment Manager Might Be the Bottleneck
Conda is a full ecosystem manager — it handles Python, R, C libraries, and binary packages. That breadth comes with a cost: slow dependency resolution and bloated environments. UV, built in Rust, focuses exclusively on Python packages. It resolves dependencies 10–100x faster than Conda. Conda excels when you need non-Python binaries like CUDA drivers or OpenCV with system-level dependencies. UV dominates when you control your Python stack — CI pipelines, Docker images, or development environments. The tradeoff is clear: Conda for cross-language chaos, UV for Python purity. If your project doesn't need Conda's compilers or channels, UV removes the slowness without sacrificing lockfile guarantees. Switching from Conda to UV cuts environment setup from minutes to seconds. Your container builds will thank you.
What UV Gives You That Poetry and Conda Don't — Raw Speed and Zero Magic
Poetry adds lockfile management and dependency resolution with a promise of determinism, but hides too much behind magic — you fight it when you need pip-compatibility or custom index URLs. Conda solves cross-language dependencies but is painfully slow and forces you into its channel ecosystem. UV breaks both bottlenecks. It's pip-compatible out of the box — your existing requirements.txt and pyproject.toml work without changes. Resolution happens in milliseconds, not minutes. UV also avoids Poetry's lockfile formatting battles and Conda's environment sprawl. The killer advantage: UV is a single binary with no Python runtime dependency to install itself. Download it, run it. No more 'pip install poetry' bootstrapping or Conda init scripts. For teams shipping containers or CI workflows, UV removes the friction Poetry and Conda introduce.
pyproject.toml: Modern Python Packaging with PEP 517/518/621
The pyproject.toml file is the modern standard for defining Python package metadata and build system requirements, as specified by PEP 517, PEP 518, and PEP 621. Unlike the older setup.py approach, pyproject.toml provides a declarative, tool-agnostic configuration that eliminates the need for imperative build scripts. This is especially important when using tools like uv or pip, as it ensures consistent builds across environments.
To use pyproject.toml, create a file in your project root with the following structure:
```toml [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta"
[project] name = "my-package" version = "0.1.0" description = "A sample package" authors = [{name = "Your Name", email = "you@example.com"}] dependencies = [ "requests>=2.28.0", "click>=8.0.0", ]
[project.optional-dependencies] dev = [ "pytest>=7.0.0", "black>=22.0.0", ] ```
This file replaces both setup.py and requirements.txt for defining dependencies. When you run pip install ., pip reads pyproject.toml and installs the package along with its dependencies. For development, you can use pip install -e . to install in editable mode.
One key advantage is that pyproject.toml is supported by multiple build backends (setuptools, flit, poetry, etc.), making it portable. Additionally, tools like uv can parse pyproject.toml directly, avoiding the need for requirements.txt entirely.
To publish your package to PyPI, you can use python -m build (which reads pyproject.toml) to create distribution files, then twine upload dist/*.
Building and Distributing Wheels
Wheels are the standard distribution format for Python packages, offering faster installation compared to source distributions (sdist). A wheel is a pre-built archive that contains compiled code and metadata, allowing pip to install it without running a build step. This is especially beneficial in containers, where build dependencies may be missing or slow to install.
To build a wheel for your package, first ensure you have a pyproject.toml or setup.py configured. Then install the build tool:
pip install build
Run the build command in your project root:
python -m build
This creates two files in the dist/ directory: a .tar.gz source distribution and a .whl wheel file. The wheel file is what pip prefers when installing.
You can also build only the wheel with:
python -m build --wheel
To install a wheel directly, use:
pip install dist/my_package-0.1.0-py3-none-any.whl
Wheels are platform-specific if they contain C extensions. For pure Python packages, the wheel is platform-independent (py3-none-any). When distributing to PyPI, you should upload both sdist and wheel to support different installation scenarios.
In containerized environments, pre-building wheels can significantly reduce build time. For example, you can build wheels for your dependencies and copy them into the container, avoiding repeated compilation of native extensions.
python -m build to generate both. In CI/CD, cache the dist/ directory to avoid rebuilding wheels unnecessarily.pip install -e .: Editable Installs for Development
Editable installs, also known as development mode, allow you to install a package in such a way that changes to its source code are immediately reflected without reinstalling. This is achieved by creating a link (or .pth file) in the site-packages directory that points to your project's source directory.
To install a package in editable mode, navigate to the project root (where pyproject.toml or setup.py is located) and run:
``bash pip install -e . ``
This installs the package with a symlink to your source code. Any modifications to the source files are instantly available when you import the package in Python. This is ideal for development workflows where you are actively editing code and testing changes.
Editable installs work with both pyproject.toml and setup.py. For pyproject.toml, ensure the build backend supports it (setuptools does). You can also specify extras:
``bash pip install -e .[dev] ``
This installs the package in editable mode along with optional dependencies defined under [project.optional-dependencies] dev.
One common pitfall: editable installs can cause issues in containers if the source code is mounted as a volume. The symlink points to a path that may not exist in the container's filesystem. For production containers, always use a regular install (pip install .) to avoid this.
To verify an editable install, check the site-packages directory for a .egg-link or .pth file pointing to your project.
Editable installs are not recommended for production deployments because they tie the installation to a specific directory and can break if the source is moved. Use them only in development environments.
pip install -e . allow instant reflection of code changes, making them essential for development but unsuitable for production.Production Deployment Failed Due to Global pip Install Conflict
python3 -m venv /opt/venv and ENV PATH=/opt/venv/bin:$PATH to the Dockerfile before any pip install command. Used --no-cache-dir flag on pip install to avoid layer cache pollution. Added pip list --format=freeze > installed.txt as a post-install build step for audit logging — this file is archived as a build artifact so the exact installed state can be inspected for any past deployment. Switched to pip-compile from pip-tools for deterministic dependency resolution going forward.- Always use a virtual environment even inside Docker containers — Docker provides process isolation, not package isolation. They solve different problems.
- Docker layer caching can silently pollute dependency resolution by carrying forward packages from base images that pip will factor into its resolution algorithm
- Pin transitive dependencies with pip-compile, not just direct ones — a requirements.txt that only lists your direct dependencies is not a lockfile
- Audit installed packages in CI with pip list --format=freeze and archive the output as a build artifact so you can diagnose exactly what was installed in any past deployment
which python and which pip. If they don't point inside your venv/ directory, you installed to the wrong environment. The package is in a different Python's site-packages, not the one your script is running under. Activate the venv and reinstall.pip list | grep <package> to confirm the installed version. Run pip show <package> to see what required it. Another dependency almost certainly pulled in a conflicting version as a transitive dependency. Use pip check to detect all dependency conflicts in the current environment at once.pip install --dry-run -r requirements.txt to see the full conflict trace. Identify which packages are pulling in incompatible versions of the same dependency. Relax your version constraints or use pip-compile to find a compatible set automatically — pip-compile's output includes comments showing which package requires each transitive dependency.pip freeze in both environments and compare the outputs line by line. Look for version differences on any package, not just the one throwing the error — the failing package may be a transitive dependency of something that differs. Also check Python version: python --version must match across environments. If requirements.txt was generated with pip freeze locally, it may include platform-specific packages that don't exist on the CI platform.which python && which pippip list | grep <missing_package>source venv/bin/activate, then reinstall: pip install <package>. If the package appears in pip list but Python still can't import it, you have multiple Python installations and are running the wrong one.| File | Command / Code | Purpose |
|---|---|---|
| setup_virtual_environment.sh | python3 -m venv venv | Why Virtual Environments Are Non-Negotiable |
| manage_dependencies.sh | pip freeze > requirements.txt | Pinning Dependencies with requirements.txt |
| pyproject.toml | [build-system] | pyproject.toml |
| publish_to_pypi.sh | pip install build twine | Publishing Your Package to PyPI |
| SymlinkAmbush.py | $ which pip | pip vs pip3 vs pip2 |
| GitInstallPinning.py | pip install git+https://github.com/yourorg/internal-auth.git | Installing Packages from GitHub |
| uv_vs_poetry_benchmark.py | start = time.time() | UV vs. Poetry |
| switch_to_uv_in_dockerfile.sh | FROM python:3.11-slim | Can I Use UV With Existing pip requirements.txt Files? |
| build_wheel.sh | pip install build | Building and Distributing Wheels |
| editable_install.sh | cd /path/to/my-package | pip install -e . |
Key takeaways
Interview Questions on This Topic
What's the difference between a wheel (.whl) and a source distribution (.tar.gz), and why does PyPI recommend publishing both?
Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Advanced Python. Mark it forged?
12 min read · try the examples if you haven't