Home Python Python Packaging and pip Explained — Virtual Envs, requirements.txt and pyproject.toml

Python Packaging and pip Explained — Virtual Envs, requirements.txt and pyproject.toml

In Plain English 🔥
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.
⚡ Quick Answer
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.

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.

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 globally, so one of your projects will always be broken. Virtual environments solve this by creating a completely isolated copy of the Python interpreter and its own package directory for each project. Every project gets its own private spice rack.

Creating one is a single command, but understanding what it actually does changes how you work forever. 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. Nothing magical — just smart path management.

You should create a virtual environment for every single Python project. No exceptions. It costs you two commands and saves you from dependency hell.

setup_virtual_environment.sh · PYTHON
1234567891011121314151617181920212223242526
# Step 1: Create a virtual environment named 'venv' inside your project folder
# The 'venv' module ships with Python 3.3+ — no install needed
python3 -m venv venv

# Step 2: Activate it (Mac/Linux)
source venv/bin/activate

# Step 3: On Windows, activation looks different
# venv\Scripts\activate

# Your terminal prompt now shows (venv) to confirm isolation is active
# (venv) $ python --version

# Step 4: Verify pip points to the ISOLATED version, not the global one
which pip
# Output: /your/project/path/venv/bin/pip  <-- this is what you want

# Step 5: Install a package — it ONLY lives inside this virtual env
pip install requests

# Step 6: Deactivate when you're done with this project
deactivate

# After deactivating, 'which pip' points back to your global Python
# The 'requests' package you installed is GONE from the global scope
# It still lives safely inside the venv/ folder
▶ Output
(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)
Successfully installed certifi-2023.7.22 charset-normalizer-3.2.0 idna-3.4 requests-2.31.0

(venv) user@machine:~/myproject$ deactivate
user@machine:~/myproject$
⚠️
Watch Out: Never Commit Your venv/ FolderAdd 'venv/' to your .gitignore immediately. The virtual environment folder can be hundreds of megabytes and is machine-specific — sharing it via Git will break teammates' setups. Share requirements.txt instead (covered next) so anyone can recreate the environment from scratch in seconds.

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. Think of it as a shopping list — you hand it to pip and it goes and fetches everything on the list at exactly the versions you specified. This is what makes your project reproducible across different machines, CI pipelines, and teammates' laptops.

There's a critical distinction between 'direct dependencies' (packages you explicitly import) and 'transitive dependencies' (packages your dependencies depend on). When you run pip freeze, you get every single installed package including transitive ones, all pinned to exact versions. This is great for applications where you need byte-for-byte reproducibility. But for libraries you publish for others to use, pinning transitive deps too tightly causes conflicts for your users.

The pattern most teams use: one requirements.txt with loose version constraints for direct deps only, and a separate requirements-lock.txt (or they use pip-tools to auto-generate it) with fully pinned transitive deps. This gives you both flexibility and reproducibility.

manage_dependencies.sh · PYTHON
1234567891011121314151617181920212223242526272829303132333435363738
# --- GENERATING requirements.txt ---

# Option A: Capture everything currently installed (exact versions)
# Good for: applications, deployment pipelines
pip freeze > requirements.txt

# Option B: Write it by hand with version ranges
# Good for: libraries, shared team projects with some flexibility
# Contents of requirements.txt would look like:
# requests>=2.28,<3.0
# flask>=2.3
# pytest>=7.0

# --- INSTALLING FROM requirements.txt ---

# On a new machine or fresh virtual environment:
pip install -r requirements.txt
# pip reads every line and installs the matching package version

# --- USING pip-tools FOR THE BEST OF BOTH WORLDS ---
# pip-tools separates your 'what I want' from 'what got installed'

pip install pip-tools

# Create requirements.in — you only list YOUR direct dependencies here
cat > requirements.in << 'EOF'
requests>=2.28
flask>=2.3
pytest>=7.0
EOF

# pip-compile reads requirements.in and resolves ALL transitive deps
# It writes a fully-pinned requirements.txt automatically
pip-compile requirements.in

# Now install from the generated, fully-pinned file
pip-sync requirements.txt
# pip-sync also REMOVES packages not in the file — keeps env clean
▶ Output
# Contents of auto-generated requirements.txt after pip-compile:
#
# DO NOT EDIT — generated by pip-compile
#
blinker==1.6.2
# via flask
certifi==2023.7.22
# via requests
charset-normalizer==3.2.0
# via requests
click==8.1.6
# via flask
flask==2.3.3
# via -r requirements.in
idna==3.4
# via requests
itsdangerous==2.1.2
# via flask
jinja2==3.1.2
# via flask
markupsafe==2.1.3
# via jinja2
requests==2.31.0
# via -r requirements.in
werkzeug==2.3.6
# via flask

pip-sync complete. 11 packages installed.
⚠️
Pro Tip: Use >= Not == for Direct Dependencies in LibrariesIf you're building a library others will install, never pin your direct dependencies with '==' in setup files. 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 patch version of the same dependency.

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 behavior. 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 organizing a monorepo — should use it.

The key insight: pyproject.toml separates 'build system requirements' (tools needed to build your package) from 'project metadata' (what your package is). This lets tools like pip know how to build your package before they've even installed your build tool. Previously, running setup.py required setuptools to already be installed — a chicken-and-egg problem.

The [project] table is where you declare your package name, version, dependencies, and entry points. Entry points are particularly powerful — they let you define command-line scripts that get installed alongside your package, so users can run your-tool directly in their terminal instead of typing python -m your_module every time.

pyproject.toml · PYTHON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
# pyproject.toml — the single source of truth for your Python project

[build-system]
# Declare what tools are needed to BUILD this package
# pip downloads these in an isolated environment before building
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "weather-fetcher"          # The name users will pip install
version = "1.0.0"
description = "Fetch weather data from public APIs with a clean interface"
readme = "README.md"
license = {file = "LICENSE"}
requires-python = ">=3.9"         # Minimum Python version supported

# Direct dependencies — use ranges, not pinned versions
dependencies = [
    "requests>=2.28,<3",
    "python-dotenv>=1.0",
]

# Optional dependency groups — installed with: pip install weather-fetcher[dev]
[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "black>=23.0",
    "mypy>=1.0",
]

# Entry points: creates a 'weather' command in the terminal after install
[project.scripts]
weather = "weather_fetcher.cli:main"
# After `pip install .`, users can type `weather London` in their terminal
# Python maps this to: call the main() function in weather_fetcher/cli.py

# ---- Project structure that goes with this pyproject.toml ----
# weather-fetcher/
# ├── pyproject.toml
# ├── README.md
# ├── LICENSE
# └── src/
#     └── weather_fetcher/
#         ├── __init__.py
#         └── cli.py

# ---- Install locally in editable mode (changes reflect immediately) ----
# pip install -e .
# The -e flag means 'editable' — you can edit source files and test
# without reinstalling the package each time
▶ 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
Installing backend dependencies ... done
Preparing editable install (pyproject.toml) ... done
Requirement already satisfied: requests>=2.28,<3 in ./venv/lib/python3.11/site-packages
Collecting python-dotenv>=1.0
Downloading python_dotenv-1.0.0-py3-none-any.whl (19 kB)
Collecting pytest>=7.0
Downloading pytest-7.4.0-py3-none-any.whl (323 kB)
Collecting black>=23.0
Downloading black-23.7.0-cp311-cp311-linux_x86_64.whl (1.6 MB)
Successfully installed black-23.7.0 python-dotenv-1.0.0 pytest-7.4.0 weather-fetcher-1.0.0

# Now test the entry point works:
$ weather London
Fetching weather for London...
🔥
Interview Gold: Why pyproject.toml Replaced setup.pysetup.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 and a portability nightmare. pyproject.toml is static, declarative data. Build tools can read it without executing anything, making builds faster, safer, and more predictable. This is the answer interviewers love.

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

The build process compiles your source code into two distribution formats: a wheel (.whl) — a pre-built binary that installs instantly — and a source distribution (.tar.gz) that gets built on the user's machine. Always publish both. The wheel handles the 99% case of users on common platforms, and the source distribution is the fallback for unusual environments.

Before publishing to the real PyPI, always test on TestPyPI first. It's a completely separate index at test.pypi.org that behaves identically to the real one. You can upload, install, and verify everything works without polluting the real package index with test releases. Once you're confident, publishing to the real PyPI is the exact same command pointed at a different URL.

publish_to_pypi.sh · PYTHON
123456789101112131415161718192021222324252627282930313233343536
# Install the build and upload tools
pip install build twine

# Step 1: BUILD your package
# This reads pyproject.toml and creates two files in the dist/ folder
python -m build
# Creates:
#   dist/weather_fetcher-1.0.0-py3-none-any.whl  (wheel — fast install)
#   dist/weather_fetcher-1.0.0.tar.gz             (source dist — fallback)

# Step 2: CHECK your package metadata before uploading
# twine check catches common mistakes like malformed README
twine check dist/*

# Step 3: TEST on TestPyPI first — always do this before the real thing
# Create a free account at https://test.pypi.org first
twine upload --repository testpypi dist/*
# You'll be prompted for your TestPyPI username and password (or API token)

# Step 4: Verify it installs correctly from TestPyPI
pip install --index-url https://test.pypi.org/simple/ weather-fetcher

# Step 5: When you're satisfied, publish to the REAL PyPI
# Create a free account at https://pypi.org first
twine upload dist/*

# After this, anyone in the world can install your package with:
# pip install weather-fetcher

# --- USING API TOKENS (recommended over passwords) ---
# 1. Go to pypi.org > Account Settings > API Tokens
# 2. Create a token scoped to your specific project
# 3. Store it in ~/.pypirc or use environment variables:
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-AgEIcHlwaS5vcmcA...  # your actual token
twine upload dist/*
▶ 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% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 12.3 kB
Uploading weather_fetcher-1.0.0.tar.gz
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 8.7 kB
View at: https://pypi.org/project/weather-fetcher/1.0.0/
⚠️
Pro Tip: Automate Releases with GitHub ActionsInstead of manually running twine upload on your laptop, set up a GitHub Actions workflow that triggers on a version tag push (e.g., 'v1.0.0'). Store your PyPI API token as a GitHub Secret. The workflow builds and publishes automatically, ensuring releases always come from a clean, consistent environment — not someone's half-configured dev machine.
Aspectrequirements.txt + pippyproject.toml + build tools
Primary use caseInstalling app dependenciesDefining and publishing packages
FormatPlain text, one package per lineTOML structured config file
Version pinningExact pins via pip freezeRanges recommended for libraries
Build system declarationNot supportedRequired via [build-system] table
CLI entry pointsNot supportedDefined via [project.scripts]
Optional dependency groupsRequires separate filesBuilt-in via [project.optional-dependencies]
Transitive dep trackingManual or via pip-toolsHandled by lock files (pip-compile, poetry.lock)
Editable installsNot applicablepip install -e . supported natively
Who reads itpip at install timepip, build, hatch, poetry, flit, etc.
ReplacesNothing (it's the standard)setup.py, setup.cfg, MANIFEST.in

🎯 Key Takeaways

  • A virtual environment is just a folder with an isolated Python binary and its own site-packages — activating it just changes your PATH. Create one per project, always.
  • pip freeze captures all installed packages including transitive deps — great for app deployments, but too restrictive for libraries. Use loose version ranges in pyproject.toml for libraries you publish.
  • pyproject.toml replaced setup.py because it's static declarative data rather than executable Python code — pip can read your build requirements without running arbitrary code, making it faster and safer.
  • Always test a PyPI release on test.pypi.org first — you cannot delete or overwrite a version once it's published to the real PyPI, so a bad release lives there forever.

⚠ Common Mistakes to Avoid

  • Mistake 1: Installing packages globally instead of in a virtual environment — Symptom: 'import requests works on my machine but fails in production' or mysterious version conflicts between projects — Fix: Always run 'python3 -m venv venv && source venv/bin/activate' before touching pip in any project. Make it a muscle memory habit. Check you're in a venv by running 'which python' — if it doesn't point inside your project folder, you're global.
  • Mistake 2: Committing requirements.txt generated by pip freeze for a library (not an app) — Symptom: Users report 'ResolutionImpossible' errors when installing your library because your pinned transitive deps conflict with their other packages — Fix: For libraries, only list your direct dependencies in pyproject.toml with loose version ranges (e.g., 'requests>=2.28,<3'). Reserve exact pinning via pip freeze for application deployments where you control the entire environment.
  • Mistake 3: Forgetting to bump the version number before running python -m build and twine upload — Symptom: PyPI rejects the upload with '400 File already exists' because you cannot overwrite an existing version on PyPI ever — Fix: Update the version field in pyproject.toml before every release. Use semantic versioning (MAJOR.MINOR.PATCH). Consider using a tool like 'bump2version' or 'hatch version patch' to automate this and avoid human error.

Interview Questions on This Topic

  • QWhat's the difference between a wheel (.whl) and a source distribution (.tar.gz), and why does PyPI recommend publishing both?
  • QIf two of your Python projects need different versions of the same library, how do you handle that on a single development machine?
  • QWhy was setup.py considered a security and reproducibility problem, and what does pyproject.toml do differently to solve it?

Frequently Asked Questions

What is the difference between pip install and pip install -e?

Regular 'pip install .' copies your package files into site-packages so changes to your source won't be reflected until you reinstall. 'pip install -e .' (editable mode) creates a link so Python reads directly from your source folder — edit a file and the change is immediately live without reinstalling. Use -e during development, regular install for production deployments.

Should I commit my virtual environment folder to Git?

Never. The venv folder is machine-specific, contains compiled binaries, and can easily exceed 500MB. It will break on other operating systems and waste repository space. Add 'venv/' to your .gitignore and commit requirements.txt or pyproject.toml instead. Any developer can recreate the exact environment with 'python3 -m venv venv && pip install -r requirements.txt'.

What is the difference between install_requires in setup.py and dependencies in pyproject.toml?

They're functionally equivalent — both declare the packages your library needs to work. The difference is purely in the file format and ecosystem. pyproject.toml is the modern standard endorsed by PEP 517/518 and understood by all current build tools (hatch, flit, poetry, setuptools). setup.py's install_requires is the legacy approach and should be migrated to pyproject.toml in any new or actively maintained project.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousVirtual Environments in PythonNext →GIL — Global Interpreter Lock
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged