I've been refining my Python development environment over the past few months. Here's what I've settled on — not because it's the "best" setup, but because it works for me and keeps me moving fast.
Editor: VS Code
I use VS Code. I know, I know — some people swear by Vim, others by PyCharm. I've tried both. VS Code hits the sweet spot for me: powerful enough to do everything I need, light enough to start fast, and extensible enough to grow with me.
The Python extension from Microsoft is non-negotiable. It handles:
- IntelliSense and autocomplete
- Go to definition
- Debugging integration
- Jupyter notebook support
Key Extensions
Beyond the basics, here's what I've installed:
- Pylance — Microsoft's language server. Much faster and smarter than the default. Type inference is great.
- Ruff — Linting and formatting in one. More on this below.
- Even Better TOML — For
pyproject.tomlediting with autocomplete and validation. - GitLens — Inline git blame and history. Invaluable for understanding code history.
- Error Lens — Shows errors inline, right next to the code. Catches issues before you run anything.
I keep my extensions minimal. Every extension adds load time and potential conflicts.
Linting: Ruff
I used to run flake8, isort, and pyupgrade separately. Now I just use Ruff. It does everything those tools do, but it's written in Rust and runs in milliseconds.
My pyproject.toml config:
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # bugbear (catches common gotchas)
"SIM", # simplify
]
ignore = ["E501"] # I handle line length with formattingIn VS Code, I set Ruff as the linter and it runs on save. Instant feedback.
The killer feature: ruff check --fix. It auto-fixes most issues. Import sorting, removing unused imports, modernizing syntax. I rarely have to manually fix lint errors.
Formatting: Black
Black is the "uncompromising" code formatter. You give up control over formatting decisions, and in return, you never think about formatting again.
[tool.black]
line-length = 100
target-version = ["py311"]I configure VS Code to format on save:
{
"editor.formatOnSave": true,
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
}
}Black + Ruff is a good combination. Black handles the formatting; Ruff handles the linting. They don't conflict.
Some people use Ruff's formatter now instead of Black. I haven't switched yet — Black works fine, and I'm lazy about changing things that work.
Type Checking: mypy
Static typing catches bugs before runtime. mypy is my type checker.
[tool.mypy]
python_version = "3.11"
strict = true
warn_return_any = true
warn_unused_ignores = trueI run mypy in strict mode from day one. Adding types to existing code is painful. Starting with types is easy.
Some tips I've learned:
- Type your function signatures. At minimum, annotate parameters and return types.
def fetch_user(user_id: int) -> User | None:
...- Use
TypedDictfor dictionaries with known keys.
from typing import TypedDict
class UserData(TypedDict):
id: int
name: str
email: str- Ignore only when necessary.
# type: ignoreshould be rare. If you're ignoring lots of errors, your types need work.
VS Code with Pylance gives you inline type feedback. You see type errors as you write, not just when you run mypy.
Testing: pytest
pytest is the standard. I don't use unittest anymore.
Basic test structure:
# tests/test_users.py
from myproject.users import get_user
def test_get_user_returns_user():
user = get_user(1)
assert user.name == "Alice"
def test_get_user_returns_none_for_missing():
user = get_user(99999)
assert user is NoneMy pytest config:
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-v --tb=short"I keep tests next to the code they test, but in a separate tests/ directory. Some people put tests alongside source files. I find that cluttered.
Fixtures I Use Constantly
# tests/conftest.py
import pytest
@pytest.fixture
def sample_user():
return User(id=1, name="Test User", email="test@example.com")
@pytest.fixture
def mock_api_response():
return {"status": "success", "data": []}For mocking external services:
from unittest.mock import patch
def test_api_call_with_mock():
with patch("myproject.api.requests.get") as mock_get:
mock_get.return_value.json.return_value = {"data": "test"}
result = fetch_data()
assert result == {"data": "test"}Virtual Environments
I use Python's built-in venv. Nothing fancy.
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e ".[dev]"I create one venv per project, always named .venv, always in the project root. Consistency helps.
VS Code detects .venv automatically and uses it as the interpreter. No manual configuration.
Why Not Poetry/Pipenv/Conda?
I've tried them. They add complexity I don't need. venv + pip + pyproject.toml does everything I need:
- Isolate dependencies ✓
- Reproducible installs ✓
- Easy to understand ✓
Poetry is nice, but it's another tool to learn. For solo projects, it's overkill.
My Makefile
I have a standard Makefile in every project:
.PHONY: setup test lint typecheck check clean
setup:
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
test:
.venv/bin/pytest tests/ -v
lint:
.venv/bin/ruff check src/ tests/
.venv/bin/ruff format --check src/ tests/
typecheck:
.venv/bin/mypy src/
check: lint typecheck test
clean:
rm -rf .venv __pycache__ .pytest_cache .mypy_cachemake setup to start. make check before every commit. That's my workflow.
Productivity Tips
A few things that keep me fast:
1. Use pyproject.toml for Everything
One file for project metadata, dependencies, and tool config. No more setup.py, setup.cfg, requirements.txt, .flake8, mypy.ini scattered everywhere.
2. Run Checks on Save
VS Code settings:
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
}Instant feedback > running commands manually.
3. Learn Keyboard Shortcuts
The ones I use most:
Cmd+P— Quick open fileCmd+Shift+P— Command paletteF12— Go to definitionShift+F12— Find all referencesCmd+.— Quick fix
4. Use Git Hooks Sparingly
I've seen setups with pre-commit hooks that run all checks. They're slow and annoying. I run make check manually before pushing. Faster iteration.
5. Start With Types
Adding types to existing code is tedious. Starting with types is easy. Just do it from the beginning.
What I'm Still Figuring Out
This setup isn't perfect. Things I'm experimenting with:
- uv — The new Rust-based Python package manager. It's fast. Might replace pip for me.
- Ruff's formatter — Could replace Black and simplify my toolchain.
- pytest-xdist — Parallel test execution for larger test suites.
The goal is simplicity. Every tool I add is a tool I have to maintain. So I add them slowly, only when they clearly help.
What's in your Python setup? I'm always curious how other people configure their environments.