Writing a Python script is 10 minutes of work. Writing one that is debuggable six months later, runnable on someone else's machine, and extendable without a full rewrite — that takes knowing the patterns. Python 3.12+ and the 2026 tooling ecosystem make this easier than ever, but only if you start with the right structure.
What changed in 2026
uv is the default Python toolchain. Astral's uv replaces pip + venv + pyenv for most use-cases — faster, deterministic, and a single binary. pip install still works but new projects start with uv.
pyproject.toml is universal. setup.py, setup.cfg, and bare requirements.txt are legacy. Even simple scripts use pyproject.toml if they have dependencies.
- Python 3.12 pattern matching is widely used.
match/case is no longer a novelty — expect to read it in colleagues' code.
ruff replaced flake8, black, and isort — one tool, 100x faster, default in nearly every new Python project.
Script structure template
my-script/
├── pyproject.toml
├── script.py # or src/mypackage/main.py for larger scripts
└── README.md
# pyproject.toml
[project]
name = "my-script"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.27",
"typer>=0.12",
]
[tool.ruff]
line-length = 100
The minimal well-structured script
#!/usr/bin/env python3
"""
fetch_data.py — Download and summarize data from a remote API.
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
import httpx
import typer
logger = logging.getLogger(__name__)
app = typer.Typer()
def fetch(url: str) -> dict:
"""Fetch JSON from url; raise on non-2xx."""
response = httpx.get(url, timeout=10)
response.raise_for_status()
return response.json()
def process(data: dict) -> str:
"""Transform raw data into a summary string."""
count = len(data.get("items", []))
return f"Found {count} items."
@app.command()
def main(
url: str = typer.Argument(..., help="API endpoint to fetch"),
output: Path = typer.Option(Path("output.txt"), help="Output file"),
) -> None:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger.info("Fetching %s", url)
data = fetch(url)
summary = process(data)
output.write_text(summary)
logger.info("Written to %s", output)
if __name__ == "__main__":
app()
Setting up with uv
# Install uv (macOS / Linux)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create a new project
uv init my-script
cd my-script
# Add dependencies
uv add httpx typer
# Run the script
uv run script.py https://api.example.com/data
# Lock dependencies (auto-generated by uv)
# uv.lock is committed to git
Argument parsing: argparse vs Typer vs Click
| Library |
Best for |
2026 status |
argparse (stdlib) |
Simple scripts, zero deps |
Always available |
Typer |
Type-annotated CLIs, fast to write |
Most popular in 2026 |
Click |
Complex CLIs, plugins |
Mature, large ecosystem |
docopt |
Doc-string-defined interface |
Niche use |
For most scripts: use argparse for single-file no-dependency scripts, Typer when you are building a real CLI tool.
Logging vs print
import logging
# WRONG for production — no levels, no timestamps, no easy suppression
print("Starting task...")
print(f"Error: {e}")
# CORRECT — structured, configurable, works with log aggregators
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
)
logger = logging.getLogger(__name__)
logger.info("Starting task...")
logger.error("Error: %s", e, exc_info=True) # includes traceback
Error handling in scripts
# Handle specific exceptions; let unexpected ones propagate
try:
data = fetch(url)
except httpx.HTTPStatusError as e:
logger.error("HTTP %d from %s", e.response.status_code, url)
sys.exit(1)
except httpx.RequestError as e:
logger.error("Network error: %s", e)
sys.exit(1)
Exit codes matter: 0 for success, non-zero for failure. Cron jobs and CI pipelines depend on them.
How to pick the right structure
- Under 50 lines and single-use? A flat script with
if __name__ == "__main__" is fine.
- Needs CLI arguments? Add
argparse or Typer regardless of length.
- Has dependencies? Add
pyproject.toml and uv from day one.
- Will be run by others or in CI? Add logging, exit codes, and handle errors explicitly.
- Growing into a real tool? Move to
src/ layout and add tests with pytest.
Common mistakes
No if __name__ == "__main__" guard. Without it, all module-level code runs on import — your script becomes untestable and dangerous to use as a library.
Hardcoded paths. Use Path(__file__).parent / "data" for paths relative to the script, or CLI arguments for user-supplied paths. Never hardcode /Users/yourname/....
except Exception: pass. Silent error swallowing. At minimum, log the exception. Usually, re-raise or exit with a non-zero code.
print in production scripts. Use logging. It gives you severity levels, timestamps, and the ability to silence debug output without touching the code.
No requirements.txt or pyproject.toml. Your script works on your machine. Someone else runs it, gets import errors, and gives up. Pin dependencies.
What to skip
setup.py and setuptools for new scripts — use pyproject.toml; setup.py is legacy.
- Global
pip install — always use a virtual environment (uv handles this automatically).
os.system() for shell commands — use subprocess.run() with check=True for correct error handling.
FAQ
What Python version should I use in 2026?
Python 3.12 is the sweet spot — widely supported, fast (15–60% faster than 3.10 on benchmarks), and has all modern features. Python 3.11 is fine too. Avoid 3.9 for new projects.
Should I use uv or conda?
uv for most Python scripting and development. conda for scientific/ML stacks that need non-Python binary dependencies (CUDA, BLAS, R packages) where conda's environment model helps.
How do I make my script runnable as a command?
Add a [project.scripts] entry in pyproject.toml: my-tool = "mypackage.main:app". After uv install, my-tool becomes a command.
How do I test a script?
Structure it so functions are importable, then write pytest tests that call those functions directly. The if __name__ == "__main__" guard makes the module importable without running the CLI.
Where to go next