Senior 6 min · March 05, 2026

Python pip — Container ImportError from Global Packages

Container exited with ImportError: cannot import name 'Session' from 'requests' due to global packages.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
Plain-English First

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 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.

setup_virtual_environment.shPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# ── CREATING A VIRTUAL ENVIRONMENT ─────────────────────────────────────────
# The 'venv' module ships with Python 3.3+ — no separate install needed
# Always create inside your project folder so it's self-contained
python3 -m venv venv

# ── ACTIVATION ──────────────────────────────────────────────────────────────
# Mac/Linux: updates PATH to point to venv's Python and pip
source venv/bin/activate

# Windows Command Prompt:
# venv\Scripts\activate

# Windows PowerShell:
# venv\Scripts\Activate.ps1

# Your terminal prompt changes to show (venv) — visual confirmation you're isolated

# ── VERIFY YOU'RE IN THE RIGHT ENVIRONMENT ──────────────────────────────────
# Both should point INSIDE your project folder, not /usr/bin or /usr/local
which python
# Good: /home/user/myproject/venv/bin/python
# Bad:  /usr/bin/python3  (you're still global)

which pip
# Good: /home/user/myproject/venv/bin/pip
# Bad:  /usr/local/bin/pip  (wrong environment)

# ── INSTALL PACKAGES ────────────────────────────────────────────────────────
# Only lands inside this venv — completely invisible to other projects
pip install requests flask pytest

# Verify what's installed and where
pip list
# Shows ONLY packages in this venv, not global packages

# ── DEACTIVATION ────────────────────────────────────────────────────────────
deactivate
# PATH reverts. 'which python' now points to the system Python again.
# The packages you installed are still in venv/lib/ — they're just not active

# ── USING uv (faster alternative, Rust-based) ───────────────────────────────
# pip install uv  (or: curl -LsSf https://astral.sh/uv/install.sh | sh)
uv venv .venv          # creates a venv at .venv/ — significantly faster than python -m venv
source .venv/bin/activate
uv pip install requests  # 5-10x faster than pip for most packages
Output
(venv) user@machine:~/myproject$ which python
/home/user/myproject/venv/bin/python
(venv) user@machine:~/myproject$ which pip
/home/user/myproject/venv/bin/pip
(venv) user@machine:~/myproject$ pip install requests
Collecting requests
Downloading requests-2.31.0-py3-none-any.whl (62 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 62.6/62.6 kB 1.2 MB/s eta 0:00:00
Successfully installed certifi-2024.2.2 charset-normalizer-3.3.2 idna-3.6 requests-2.31.0
(venv) user@machine:~/myproject$ deactivate
user@machine:~/myproject$ which python
/usr/bin/python3 # back to global — requests is not available here
Watch Out: Never Commit Your venv/ Folder
Add 'venv/' and '.venv/' to your .gitignore the moment you create a project. The virtual environment folder contains compiled binary files that are platform-specific — a venv created on macOS will not work on Linux, and vice versa. It can easily reach 500MB for projects with heavy dependencies like PyTorch or TensorFlow. Share requirements.txt or pyproject.toml instead — both let any developer recreate the environment from scratch in under a minute.
Production Insight
Global pip installs cause version conflicts between projects on the same machine and are the root cause of most 'works on my machine' failures.
Docker containers isolate processes, not packages — you still need a virtual environment inside Docker to prevent base image package pollution from silently affecting your resolution.
Rule: activate a venv before touching pip. Make it the first thing you do when you open a terminal in a project directory. After a few weeks it becomes muscle memory.
Key Takeaway
A virtual environment is just a folder with an isolated Python binary and its own site-packages directory. Activating it only changes your PATH — nothing magical.
Create one per project, always — the cost is two commands and the benefit is never fighting dependency conflicts again.
In 2026, uv is a significantly faster alternative to pip + venv for the same workflow.

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.

manage_dependencies.shPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# ── APPROACH 1: pip freeze (simple, good for apps) ─────────────────────────
# Captures everything currently installed at exact versions
# Best for: application deployments where you need byte-for-byte reproducibility

pip freeze > requirements.txt
# Output includes direct AND transitive deps, all pinned:
# certifi==2024.2.2
# charset-normalizer==3.3.2
# idna==3.6
# requests==2.31.0

# ── APPROACH 2: Hand-written with ranges (good for libraries) ──────────────
# Only list your direct dependencies with reasonable version constraints
# Best for: libraries others will install alongside their own packages

cat > requirements.txt << 'EOF'
requests>=2.28,<3
flask>=3.0
pytest>=7.0
EOF

# ── APPROACH 3: pip-tools (best of both worlds, production standard) ────────
pip install pip-tools

# Create requirements.in — your direct dependencies with loose constraints
# This is the human-editable file you commit and maintain
cat > requirements.in << 'EOF'
requests>=2.28
flask>=3.0
pytest>=7.0
EOF

# pip-compile resolves the full dependency graph and generates requirements.txt
# --generate-hashes adds SHA256 hashes for supply chain security (optional but recommended)
pip-compile requirements.in
# or with hashes:
pip-compile --generate-hashes requirements.in

# pip-sync installs EXACTLY what's in requirements.txt AND removes anything not listed
# This guarantees your environment matches the lockfile exactly
pip-sync requirements.txt

# ── UPDATING DEPENDENCIES ───────────────────────────────────────────────────
# To update a specific package to a newer version:
pip-compile --upgrade-package requests requirements.in
# Updates requests and adjusts any transitive deps that change as a result

# To update everything to the latest compatible versions:
pip-compile --upgrade requirements.in

# ── CHECKING FOR SECURITY VULNERABILITIES ───────────────────────────────────
# pip-audit checks all installed packages against the Python Advisory Database
pip install pip-audit
pip-audit
# Reports any known CVEs in your installed dependencies
Output
# Contents of auto-generated requirements.txt after pip-compile:
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
# pip-compile requirements.in
#
blinker==1.7.0
# via flask
brotli==1.1.0
# via werkzeug
certifi==2024.2.2
# via requests
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via flask
flask==3.0.2
# via -r requirements.in
idna==3.6
# via requests
itsdangerous==2.1.2
# via flask
jinja2==3.1.3
# via flask
markupsafe==2.1.5
# via jinja2
plugy==1.4.0
# via pytest
pytest==7.4.4
# via -r requirements.in
requests==2.31.0
# via -r requirements.in
werkzeug==3.0.1
# via flask
pip-sync complete. 14 packages installed, 0 packages removed.
Pro Tip: Use >= Not == for Direct Dependencies in Libraries
If you're building a library others will install, never pin your direct dependencies with '==' in your package definition. Use '>=' with an upper bound on the major version (e.g., 'requests>=2.28,<3'). Pinning with '==' means your library will conflict with any other package in your user's project that needs a different exact version of the same dependency — and they will not be able to install both, which means they cannot use your library at all.
Production Insight
pip freeze pins everything including transitive deps — exactly right for application deployments, wrong for libraries.
Libraries should use loose version ranges in pyproject.toml to avoid conflicting with users' other installed packages.
Rule: apps pin exactly (use pip freeze or pip-compile), libraries pin loosely (use ranges). Treating a library's dependency list like an application lockfile is the most common way to create ResolutionImpossible errors for your users.
Key Takeaway
pip freeze captures all installed packages including transitive deps — great for app deployments, but too restrictive for libraries you publish.
pip-tools (pip-compile) gives you both: a human-editable requirements.in and a machine-pinned requirements.txt with full transitive dependency tracking and comments.
Commit both requirements.in and requirements.txt — the diff between pip-compile runs 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.

pyproject.tomlPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# pyproject.toml — the single source of truth for your Python project
# Replaces setup.py, setup.cfg, and MANIFEST.in entirely

[build-system]
# Which tools are needed to BUILD this package
# pip downloads and uses these in an isolated temporary environment
# before building — no pre-installed tools required
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "weather-fetcher"              # The name users will 'pip install'
version = "1.0.0"                     # Follow semantic versioning: MAJOR.MINOR.PATCH
description = "Fetch weather data from public APIs with a clean interface"
readme = "README.md"                  # Displayed on PyPI project page
license = {file = "LICENSE"}
requires-python = ">=3.9"             # Minimum Python version your code supports
authors = [
    {name = "Your Name", email = "you@example.com"}
]
keywords = ["weather", "api", "cli"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
]

# Direct dependencies — use ranges, never exact pins
# These are installed automatically when someone 'pip install weather-fetcher'
dependencies = [
    "requests>=2.28,<3",
    "python-dotenv>=1.0",
]

[project.urls]
Homepage = "https://github.com/yourname/weather-fetcher"
Documentation = "https://weather-fetcher.readthedocs.io"
Changelog = "https://github.com/yourname/weather-fetcher/blob/main/CHANGELOG.md"
Issues = "https://github.com/yourname/weather-fetcher/issues"

# Optional dependency groups — users install with: pip install weather-fetcher[dev]
# Keep dev tools out of the main dependency list
[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov>=4.0",
    "black>=24.0",
    "mypy>=1.0",
    "ruff>=0.3",
]
docs = [
    "sphinx>=7.0",
    "sphinx-rtd-theme>=2.0",
]

# Entry points: registers a 'weather' command in the user's terminal after install
# Python maps 'weather' to: call the main() function in weather_fetcher/cli.py
[project.scripts]
weather = "weather_fetcher.cli:main"
# After 'pip install .': user can type 'weather London' in any terminal

# ── Project layout that matches this pyproject.toml ─────────────────────────
# weather-fetcher/
# ├── pyproject.toml
# ├── README.md
# ├── LICENSE
# ├── CHANGELOG.md
# └── src/
#     └── weather_fetcher/
#         ├── __init__.py
#         └── cli.py

# The src/ layout is recommended — it prevents accidental imports from the
# project root during development, ensuring you're always testing the installed package

# ── Local development install ────────────────────────────────────────────────
# pip install -e .        (install core only)
# pip install -e .[dev]   (install core + dev tools)
# The -e flag means 'editable' — edit source files and changes are immediately
# reflected without reinstalling the package
Output
# Running: pip install -e .[dev]
Obtaining file:///home/user/projects/weather-fetcher
Installing build dependencies ... done
Checking if build backend supports build_editable ... done
Getting requirements to build editable ... done
Preparing editable install (pyproject.toml) ... done
Requirement already satisfied: requests>=2.28,<3 in ./venv/lib/python3.12/site-packages (2.31.0)
Collecting python-dotenv>=1.0
Downloading python_dotenv-1.0.1-py3-none-any.whl (19 kB)
Collecting pytest>=7.0
Downloading pytest-7.4.4-py3-none-any.whl (325 kB)
Collecting black>=24.0
Downloading black-24.3.0-cp312-cp312-linux_x86_64.whl (1.8 MB)
Collecting ruff>=0.3
Downloading ruff-0.3.4-py3-none-linux_x86_64.whl (8.0 MB)
Successfully installed black-24.3.0 python-dotenv-1.0.1 pytest-7.4.4 ruff-0.3.4 weather-fetcher-1.0.0
# Verifying the entry point works:
$ weather London
Fetching weather for London...
Temperature: 14°C, Partly cloudy
Interview Gold: Why pyproject.toml Replaced setup.py
setup.py was executable Python code, which meant pip had to run arbitrary code just to find out what a package's dependencies were — a security risk, a portability problem, and a reproducibility nightmare. pyproject.toml is static, declarative data. Build tools can read it without executing anything, making builds faster, safer, and more predictable. It also solves the bootstrapping problem: setup.py required setuptools to be pre-installed, while pyproject.toml's [build-system] table tells pip exactly what to install in an isolated build environment before attempting to build. This is the answer most interviewers are looking for when they ask about modern Python packaging.
Production Insight
setup.py required setuptools to be pre-installed — a chicken-and-egg problem that made tooling complicated and bootstrapping fragile.
pyproject.toml declares build system requirements so pip can bootstrap them in an isolated environment, with no pre-installed tools required.
Rule: migrate all projects to pyproject.toml. setup.py is a legacy format and should not be used for any new project started in 2024 or later.
Key Takeaway
pyproject.toml replaced setup.py because it is declarative static data rather than executable Python code — tooling can read it without running anything.
[build-system] declares what builds your package. [project] declares metadata and dependencies. [project.scripts] registers CLI entry points.
The src/ layout is the recommended project structure — it prevents accidental imports from the project root during testing.

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.

publish_to_pypi.shPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# ── PREREQUISITES ────────────────────────────────────────────────────────────
# Create accounts at:
#   https://test.pypi.org   (for testing)
#   https://pypi.org        (for production)
# Enable 2FA on both accounts — PyPI requires it for new publishers

pip install build twine

# ── STEP 1: BUILD ────────────────────────────────────────────────────────────
# Reads pyproject.toml and builds both wheel and source dist
# Creates files in the dist/ folder
python -m build
# Output:
#   dist/weather_fetcher-1.0.0-py3-none-any.whl   (wheel — fast install)
#   dist/weather_fetcher-1.0.0.tar.gz              (sdist — fallback)

# ── STEP 2: VERIFY before uploading ─────────────────────────────────────────
# Catches malformed README, missing metadata, and packaging mistakes
# before they become permanent PyPI entries
twine check dist/*
# Expected output: PASSED for both files

# ── STEP 3: TEST on TestPyPI — always do this first ─────────────────────────
twine upload --repository testpypi dist/*
# You'll be prompted for TestPyPI credentials or API token

# Verify the TestPyPI install works end-to-end:
pip install \
    --index-url https://test.pypi.org/simple/ \
    --extra-index-url https://pypi.org/simple/ \
    weather-fetcher
# --extra-index-url allows pip to find dependencies on real PyPI
# since test.pypi.org doesn't have all packages

# Test the entry point works:
weather London

# ── STEP 4: PUBLISH to real PyPI when satisfied ──────────────────────────────
twine upload dist/*
# After this, the package is live at https://pypi.org/project/weather-fetcher/
# Anyone can now: pip install weather-fetcher

# ── API TOKENS (strongly recommended over passwords) ─────────────────────────
# Go to pypi.org > Account Settings > API Tokens
# Create a token scoped to this specific project (not account-wide)
# Then pass it to twine:
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-AgEIcHlwaS5vcmcA...  # your actual token
twine upload dist/*

# Or store in ~/.pypirc to avoid env vars:
# [pypi]
# username = __token__
# password = pypi-AgEIcHlwaS5vcmcA...

# ── AUTOMATING WITH GITHUB ACTIONS ──────────────────────────────────────────
# .github/workflows/publish.yml
# Triggers on: push of tags matching v*.*.* (e.g., v1.0.0)
# Steps: checkout → build → test on TestPyPI → publish to PyPI
# Use PyPI's Trusted Publishers (OIDC) to skip token management entirely
# See: https://docs.pypi.org/trusted-publishers/
Output
# python -m build output:
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
- hatchling
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
* Creating isolated environment: venv+pip...
* Building wheel...
Successfully built weather_fetcher-1.0.0.tar.gz and weather_fetcher-1.0.0-py3-none-any.whl
# twine check dist/* output:
Checking dist/weather_fetcher-1.0.0-py3-none-any.whl: PASSED
Checking dist/weather_fetcher-1.0.0.tar.gz: PASSED
# twine upload dist/* output (real PyPI):
Uploading distributions to https://upload.pypi.org/legacy/
Uploading weather_fetcher-1.0.0-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 14.2 kB / 14.2 kB
Uploading weather_fetcher-1.0.0.tar.gz
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 9.1 kB / 9.1 kB
View at: https://pypi.org/project/weather-fetcher/1.0.0/
Pro Tip: Use Trusted Publishers to Eliminate PyPI Token Management
PyPI's Trusted Publishers feature uses GitHub Actions' OIDC integration to authenticate your publishing workflow without storing any secrets. You configure a trusted publisher on PyPI (specifying your GitHub repo, workflow file, and environment), and your GitHub Actions workflow can publish without needing a TWINE_PASSWORD or any stored API token. This is more secure than token-based auth because there are no long-lived credentials that can leak — the authentication is ephemeral and tied to a specific workflow run.
Production Insight
PyPI never allows overwriting a published version — a bad release lives there permanently. Plan for 1.0.1 as your correction path, not version deletion.
Always run twine check before uploading — it catches a surprising number of metadata problems that would make your PyPI project page look broken.
Rule: always test the full end-to-end install from TestPyPI before touching real PyPI. The extra ten minutes has saved more releases than any other practice.
Key Takeaway
PyPI publishing is python -m build then twine upload — two commands, but the one-time account and token setup matters.
Always publish both wheel (.whl) and source dist (.tar.gz) — wheel for the 99% case, source distribution for unusual platforms.
You cannot delete or overwrite a published version on real PyPI — always verify on TestPyPI first, and use twine check before every upload.
● Production incidentPOST-MORTEMseverity: high

Production Deployment Failed Due to Global pip Install Conflict

Symptom
Container exited with ImportError: cannot import name 'Session' from 'requests'. The Dockerfile ran pip install -r requirements.txt without a virtual environment, so it picked up the globally cached requests==2.25 instead of the required requests==2.31. The error only appeared at runtime — the build step completed successfully and appeared clean.
Assumption
The team assumed Docker containers are isolated enough that virtual environments are unnecessary. They had read that Docker provides process isolation and concluded that package isolation was included. They installed packages directly into the system Python inside the container for simplicity, reasoning that fewer steps meant fewer things to go wrong.
Root cause
The base Docker image (python:3.11-slim) had pre-installed packages from a previous layer cache. When pip install ran without a venv, it resolved against the cached global packages, silently downgrading requests from 2.31 to 2.25 to satisfy a transitive dependency conflict introduced by another package in the base image. The downgrade happened silently — pip did not raise an error or warning, it simply chose the version that satisfied all constraints including those from packages the team didn't know were pre-installed in the base image.
Fix
Added 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.
Key lesson
  • 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
Production debug guideWhen pip install fails or your project breaks after installing a package4 entries
Symptom · 01
ImportError or ModuleNotFoundError after pip install appeared to succeed
Fix
Check which Python and pip are actually active: run 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.
Symptom · 02
pip install succeeds but the installed package version is not what you requested
Fix
Run 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.
Symptom · 03
ResolutionImpossible error during pip install -r requirements.txt
Fix
Two or more packages have version constraints that cannot be satisfied simultaneously. Run 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.
Symptom · 04
Package works locally but fails in CI or Docker
Fix
Run 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.
★ Python Packaging Debugging Cheat SheetWhen pip and virtual environments misbehave — immediate diagnostic steps
ModuleNotFoundError after installing a package
Immediate action
Verify you are in the correct virtual environment — this is the cause 90% of the time
Commands
which python && which pip
pip list | grep <missing_package>
Fix now
If which python does not point inside your project folder, activate the venv: 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.
Dependency conflict — ResolutionImpossible error+
Immediate action
Identify which packages are pulling in incompatible versions of the same dependency
Commands
pip check
pip install --dry-run -r requirements.txt 2>&1 | grep -i conflict
Fix now
Relax the version constraint on the conflicting package, or use pip-compile to resolve the dependency graph automatically. pip-compile's output shows which of your direct dependencies caused each transitive pin.
Old package version installed despite requesting a newer one+
Immediate action
Check if another dependency is pinning it to an older version as a transitive constraint
Commands
pip show <package> # shows the Required-by field
pip install <package>==<desired_version> --dry-run # shows what would change
Fix now
Identify which package in Required-by is constraining the version. Either update that package, relax your requirements, or use pip-compile to find a consistent resolution across all constraints.
requirements.txt + pip vs pyproject.toml + Build Tools
Aspectrequirements.txt + pippyproject.toml + build tools
Primary use caseInstalling app dependencies reproducibly across machinesDefining package metadata, dependencies, and publishing to PyPI
FormatPlain text, one package per line, optional version specifiersTOML structured config file — machine-readable, human-editable
Version pinningExact pins via pip freeze for lockfiles; loose ranges for flexibilityLoose ranges recommended in [project.dependencies]; lockfiles via pip-compile
Build system declarationNot applicable — not a package definitionRequired via [build-system] table — tells pip how to build before building
CLI entry pointsNot supportedDefined via [project.scripts] — installs terminal commands alongside the package
Optional dependency groupsRequires separate files (requirements-dev.txt, requirements-test.txt)Built-in via [project.optional-dependencies] — pip install package[dev]
Transitive dependency trackingManual (pip freeze) or automated via pip-compile with annotationsHandled by lockfile tools (pip-compile, Poetry's poetry.lock, uv.lock)
Editable installsNot applicablepip install -e . supported natively — changes to source reflect immediately
Who reads itpip at install timepip, build, hatch, poetry, flit, uv, and any PEP 517-compliant tool
ReplacesNothing — it is the standard for dependency installation manifestssetup.py, setup.cfg, and MANIFEST.in — all legacy formats

Key takeaways

1
A virtual environment is just a folder with an isolated Python binary and its own site-packages directory. Activating it changes your PATH
nothing more magical than that. Create one per project, every time, with no exceptions.
2
pip freeze captures all installed packages including transitive deps at exact versions
perfect for application deployments that need byte-for-byte reproducibility. For libraries you publish to PyPI, use loose version ranges in pyproject.toml instead, or you will create ResolutionImpossible errors for your users.
3
pyproject.toml replaced setup.py because it is declarative static data rather than executable Python code
pip can read your build requirements and metadata without running arbitrary code from the internet, making the process faster and safer.
4
Always test a PyPI release on test.pypi.org before publishing to the real PyPI
you cannot delete or overwrite a version once published, ever. A bad release is permanent.
5
pip-compile from pip-tools gives you deterministic builds
a human-editable requirements.in you maintain and a machine-generated requirements.txt with full transitive dependency resolution and annotations. Commit both to version control.
6
Editable installs (pip install -e .) let you develop a package locally without reinstalling after every change
source edits are immediately reflected because pip creates a link to your source folder rather than copying files into site-packages.

Common mistakes to avoid

6 patterns
×

Installing packages globally instead of in a virtual environment

Symptom
ImportError or wrong package version on a different machine or in CI. 'which python' points to /usr/bin/python3 or /usr/local/bin/python3 instead of a path inside your project folder. Two projects on the same machine interfere with each other's dependencies.
Fix
Always run python3 -m venv venv && source venv/bin/activate before touching pip in any project. Verify the active environment with which python — it must point inside your project folder. Consider adding a check to your project's Makefile or scripts that warns if no venv is active.
×

Using pip freeze output as the dependency list for a library (not an application)

Symptom
Users report ResolutionImpossible errors when trying to install your library alongside their other packages. Your pinned transitive dependencies conflict with versions they need for their project.
Fix
For libraries, only list direct dependencies in pyproject.toml with loose version ranges (e.g., 'requests>=2.28,<3'). Reserve exact pinning for application deployments. If you publish a library with '==' pins on transitive dependencies, you will break many of your users' projects.
×

Forgetting to bump the version before publishing to PyPI

Symptom
twine upload fails with '400 File already exists'. PyPI permanently rejects any upload with a filename that matches an existing release — you cannot overwrite version 1.0.0, ever, even with identical content.
Fix
Update the version field in pyproject.toml before every release. Follow semantic versioning: bump PATCH for bug fixes, MINOR for new backwards-compatible features, MAJOR for breaking changes. Use hatch version patch or bump2version to automate version bumping and avoid manual errors.
×

Committing the venv/ or .venv/ folder to Git

Symptom
Repository size balloons by hundreds of megabytes. Build failures on different operating systems because venv binaries are platform-specific. Teammates get mysterious merge conflicts on binary files deep inside the venv folder.
Fix
Add both 'venv/' and '.venv/' to .gitignore before the first commit of any project. Share requirements.txt or pyproject.toml instead. Document the setup process in README.md so any developer can recreate the environment with one or two commands.
×

Not using pip-compile for deterministic builds in CI

Symptom
CI builds produce different results on different days because pip resolves transitive dependencies freshly each time and a package somewhere in the graph released a new version. A build that passed on Monday fails on Wednesday with no code changes.
Fix
Use pip-compile from pip-tools: write requirements.in with loose direct dependencies, run pip-compile to generate a fully-pinned requirements.txt with transitive annotation comments. Commit both files to version control. CI installs from the pinned file, ensuring identical environments across all runs.
×

Skipping TestPyPI and publishing directly to the real PyPI

Symptom
A release with a broken README, missing file, wrong version, or bad metadata goes live permanently. You cannot delete it or overwrite it — you can only publish a new version with the fix, and the broken version remains visible forever.
Fix
Always run twine upload --repository testpypi dist/* and then pip install --index-url https://test.pypi.org/simple/ your-package before touching the real PyPI. The entire end-to-end test takes under five minutes and has caught more bad releases than any other practice.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the difference between a wheel (.whl) and a source distribution (...
Q02JUNIOR
If two of your Python projects need different versions of the same libra...
Q03SENIOR
Why was setup.py considered a security and reproducibility problem, and ...
Q04SENIOR
What is the difference between pip freeze and pip-compile, and when woul...
Q05SENIOR
How would you set up a CI/CD pipeline to publish a Python package to PyP...
Q01 of 05JUNIOR

What's the difference between a wheel (.whl) and a source distribution (.tar.gz), and why does PyPI recommend publishing both?

ANSWER
A wheel (.whl) is a pre-built binary package that installs instantly — pip extracts it directly into site-packages with no compilation step. A source distribution (.tar.gz) contains raw source code that gets built on the user's machine during installation using the build backend declared in pyproject.toml. PyPI recommends publishing both because the wheel handles the vast majority of installations instantly on common platforms, while the source distribution serves as a fallback for unusual platforms or CPU architectures where no matching pre-built wheel exists. For packages with C extensions like numpy, this distinction is especially important — building from source takes minutes and requires a compiler, while installing a wheel is instant.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between pip install and pip install -e?
02
Should I commit my virtual environment folder to Git?
03
What is the difference between install_requires in setup.py and dependencies in pyproject.toml?
04
What is a wheel (.whl) file and why is it faster than installing from source?
05
How do optional dependency groups work in pyproject.toml?
🔥

That's Advanced Python. Mark it forged?

6 min read · try the examples if you haven't

Previous
Virtual Environments in Python
9 / 17 · Advanced Python
Next
GIL — Global Interpreter Lock