Chapter 14 of 24 · Python for Beginners — Complete Free Tutorial

Virtual Environments and Project Layout

The previous chapter installed packages with python3 -m pip install — into whatever interpreter happened to be active. That is fine for one experiment and dangerous for everything else. Chapter 14 of the Python for Beginners 24-chapter series gives each project a private interpreter, records its dependencies so anyone can rebuild it, and lays out a folder that scales from first script to small application.

Why global installs break projects

Python historically had one shared site-packages folder per machine, and every project piled into it. Two projects needing different versions of the same library forced a choice: upgrade and break one of them, or refuse and block the other. Rebuild the laptop six months later and the only record of what was installed is memory; test a pre-release dependency and every project on the machine inherits it. JavaScript developers will recognise the answer — npm installs locally into each project’s node_modules rather than into a global folder. A virtual environment is Python’s equivalent: a private directory holding a copy of the interpreter, its own site-packages, and scripts that switch your shell over to it.

Creating the environment

The venv module is part of the standard library, so there is nothing to install. The leading dot is convention: it keeps the folder out of listings and tooling.

mkdir book-stats
cd book-stats
python3 -m venv .venv
ls .venv

Expected output: the venv command prints nothing — silence means success — and the listing is bin include lib pyvenv.cfg (Python 3.13+ also adds a hidden .gitignore inside the folder). bin is called Scripts on Windows and holds python, pip and the activation scripts; lib/python3.x/site-packages is where future installs go; pyvenv.cfg records which base interpreter the environment was cloned from.

Activating it — and proving it worked

Activation is a shell script that edits PATH so python and pip resolve to the environment’s copies. The command differs per platform:

ShellCommand (run from the project root)
macOS / Linux (bash or zsh)source .venv/bin/activate
Windows Command Prompt.venv\Scripts\activate
Windows PowerShell.venv\Scripts\Activate.ps1

The prompt gains a (.venv) prefix, which is easy to miss. Ask the interpreter instead: inside an environment sys.prefix differs from sys.base_prefix.

source .venv/bin/activate
python -c "import sys; print(sys.base_prefix != sys.prefix)"
which python

Expected output:

True
/Users/ada/book-stats/.venv/bin/python

True means active, False means you are back on the system interpreter — the check worth remembering. The second line shows your own absolute path; what matters is that it ends in .venv/bin/python (where python does the same on Windows). deactivate restores the previous state, and environments are per-shell: a new terminal tab starts outside one until you activate again.

Recording what you installed

With the environment active, install as before — now the package lands inside .venv and nowhere else. Then freeze the result, and rebuilds become one command.

python -m pip install requests
python -m pip freeze > requirements.txt
cat requirements.txt

Expected output:

Successfully installed certifi-2026.7.22 charset-normalizer-3.5.2 idna-3.20 requests-2.34.2 urllib3-2.8.0
certifi==2026.7.22
charset-normalizer==3.5.2
idna==3.20
requests==2.34.2
urllib3==2.8.0

A clean machine then rebuilds it with three commands: create the environment, activate it, and install from the file. Pip confirms each pinned version as it resolves it (download lines trimmed here), ending with the same Successfully installed line:

python -m pip install -r requirements.txt

Expected output:

Collecting requests==2.34.2 (from -r requirements.txt (line 1))
Collecting charset_normalizer<4,>=2 (from requests==2.34.2->-r requirements.txt (line 1))
Successfully installed certifi-2026.7.22 charset_normalizer-3.5.2 idna-3.20 requests-2.34.2 urllib3-2.8.0

pyproject.toml: one file of metadata

requirements.txt answers “what does this run with?” — pyproject.toml answers “what is this project?”: name, version, supported Python, dependencies and build settings, in one TOML file every modern tool reads.

# pyproject.toml
[project]
name = "book-stats"
version = "0.1.0"
description = "Turn a CSV reading log into a JSON summary"
requires-python = ">=3.12"
dependencies = ["requests>=2.32"]

[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.6"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

Three lines matter most now: requires-python declares the versions you support, dependencies is what a normal install pulls, and dev is tooling needed only while developing. Keep both this file and requirements.txt in version control.

A layout worth copying

Code under src/, tests beside it, data beside that. The structure keeps imports honest and stops scripts being confused with packages.

book-stats/
├── .gitignore
├── README.md
├── pyproject.toml
├── requirements.txt
├── .venv/            created by python3 -m venv, never committed
├── data/books.csv
├── src/bookstats/
│   ├── __init__.py
│   └── cli.py
└── tests/test_cli.py

The .gitignore that keeps the repository clean — committing .venv/ would upload thousands of machine-specific files, and __pycache__/ is pure build output:

.venv/
__pycache__/
*.py[cod]
.env
dist/
build/
*.egg-info/
.pytest_cache/

Running your code inside the environment

The layout only pays off if commands run from the project root with the environment active. Here is the program the tree refers to:

# src/bookstats/cli.py
from pathlib import Path

BOOKS = [("The Hobbit", 310), ("Dune", 412), ("Solaris", 204)]

def main():
    total = sum(pages for _, pages in BOOKS)
    print(f"{len(BOOKS)} books, {total} pages, {total // len(BOOKS)} average")
    print("data folder exists:", Path("data").exists())

if __name__ == "__main__":
    main()
source .venv/bin/activate
python src/bookstats/cli.py

Expected output:

3 books, 926 pages, 308 average
data folder exists: True

Two habits save hours later: run python rather than double-clicking files, so relative paths resolve from the project root; and re-activate in every new terminal, because environments are per-shell, never permanent.

Common mistakes

  • Activating in one terminal and running code in another — a new tab starts back on the system interpreter; re-run the activate command.
  • Installing without activating — packages land in the global interpreter and vanish for everyone else; check with python -c "import sys; print(sys.base_prefix != sys.prefix)" first.
  • Committing .venv/ — thousands of generated, machine-specific files; ignore the folder (Python 3.13+ adds a .gitignore inside it for you).
  • Forgetting to record dependencies — without requirements.txt or a populated pyproject.toml nobody, including future you, can rebuild the environment.
  • Running from the wrong folder — relative paths such as data/ resolve against the current directory, so run commands from the project root.

Practice checklist

  1. Create .venv in a fresh folder and prove it is active with python -c "import sys; print(sys.base_prefix != sys.prefix)".
  2. Install requests, freeze to requirements.txt, then delete the environment and rebuild it from that one file.
  3. Break it deliberately: activate in one shell, run pip instead of python -m pip in another, and compare which pip.
  4. Write a pyproject.toml for a toy project with one runtime dependency and a dev extra, and read back every line you typed.
  5. Add a README.md recording the exact commands needed to set the project up from a fresh clone, then follow them yourself.

What's next?

Your projects are now reproducible. Next, move beyond printing to the screen: read and write real data files the modern way in Working with Files and Paths.

Quiz: Virtual Environments and Project Layout

5 questions · 70% to pass

Frequently Asked Questions

Python is a general-purpose language first released in 1991, designed around one idea: code is read far more often than it is written. English keywords and indentation instead of braces make it readable, the standard library covers files, JSON, dates and testing, and pip adds thousands more packages. The same language handles scripts, web APIs, data work and machine learning.

None at all. Python is widely considered the friendliest first language: no declaration keywords, no braces, no build step, and error messages that name the problem. This series begins at your first print() call. If you already read JavaScript or TypeScript you will adjust quickly, but no previous language is assumed anywhere in these 24 chapters.

Python 3 only, any release from 3.12 onwards. Python 2 reached end of life in 2021 and its print "hi" statements and xrange() no longer run. One historical quirk remains: installers ship the interpreter as python3 on macOS and Linux, while Windows usually offers python or the py launcher, and every command in this series works with that one-word swap.

More than you might expect. Everyday automation such as renaming files, cleaning spreadsheets or scraping pages; web backends with Django, Flask or FastAPI; data analysis and machine learning with pandas, NumPy and PyTorch; DevOps tooling, desktop apps and games. Python is also the classic glue language, calling C, Rust or Fortran libraries from short readable scripts.

Pure Python can run 10 to 100 times slower than C, yet that rarely blocks you: heavy numeric work lives in C extensions such as NumPy, which Python coordinates. Dynamic typing does push errors to runtime, surfacing as a TypeError on the line that finally uses the bad value. Type hints checked by mypy move those mistakes back into your editor.

Only the interpreter. Install CPython from python.org, version 3.12 or newer, confirm it with python3 --version, and open a plain text editor; VS Code with the Python extension is the recommendation here. The early chapters use no third-party packages, so there is nothing else to set up until virtual environments and pip arrive.

A dict is a real runtime mapping: keys may be any hashable value rather than only strings, insertion order has been guaranteed since Python 3.7, and a missing key raises KeyError unless you ask with get(). JavaScript objects coerce every key to a string and carry a prototype chain. A TypeScript interface is erased at compile time and describes shape only; TypedDict is Python's checked equivalent.

Reach for a list when items will be added, removed or reordered: it is ordered, mutable and rich with methods. Pick a tuple for fixed records and function returns, because it is immutable and hashable, so it can be a dictionary key. Choose a set for unique values and fast membership checks; it has no order and de-duplicates on creation.

Yes, gradually. Annotate public function signatures first; the interpreter ignores hints at runtime, so a checker such as mypy is what gives them teeth. Add tests the same way: pytest collects any test_*.py file and plain assert statements, and a suite that finishes in a tenth of a second is one you will run after every change. Both habits cost little now and much more later.

JavaScript and TypeScript keep the browser, so anything rendered to the user stays theirs. Python takes the server: Django for full-featured applications, Flask for small ones, FastAPI for typed async APIs where your annotations become automatic documentation. Your TypeScript habits transfer directly, and asyncio mirrors the event loop you already know, just with different keywords.