Skip to main content

Python Coding Best Practices

This page lists good habits for writing Python code. These habits make code easier to read, test, change, and run safely. They apply to everyone: SDET, SDE, and SRE.

Each practice has:

  • Do โ€“ the good way.
  • Why โ€“ the reason in simple words.
  • An example, where it helps.
How to use this page

Read it once after you know the basics, then use the checklist at the end before every commit. Pick two or three habits to practise each week rather than all at once.


Contentsโ€‹

  1. Readability
  2. Naming
  3. Functions
  4. Classes and Design
  5. Data and Collections
  6. Errors and Exceptions
  7. Logging
  8. Resources: Files, Connections, Locks
  9. Testing
  10. Security
  11. Performance
  12. Configuration
  13. Dependencies and Environments
  14. Project Structure
  15. Comments and Documentation
  16. Git and Code Review
  17. Automation Scripts
  18. Tools That Help
  19. Short Checklist Before You Commit

1. Readabilityโ€‹

Code is read many more times than it is written. Write for the next person who reads it.

1.1 Follow the standard style (PEP 8 โ€” Python's official style guide)โ€‹

Do: Use 4 spaces for indentation. Keep lines short (about 88โ€“100 characters). Put spaces around = and operators. Use a formatter (a tool that rewrites your code's spacing and layout automatically) to do this for you. Why: When all code looks the same, people can read it faster.

1.2 Keep code flatโ€‹

Do: Check for bad cases first and return early. Avoid deep nesting. Why: Deeply nested code is hard to follow.

# Harder to read
def process(user):
if user:
if user.is_active:
if user.email:
send(user.email)

# Easier to read
def process(user):
if not user or not user.is_active or not user.email:
return
send(user.email)

1.3 Clear is better than cleverโ€‹

Do: Choose the simple, clear version, even if it is one line longer. Why: Clever one-liners are hard to understand and hard to fix.

1.4 Do not use "magic numbers"โ€‹

Do: Give important numbers a name. Why: The name explains what the number means, and you change it in one place.

# Unclear
if response_time > 2.5:
alert()

# Clear
MAX_RESPONSE_SECONDS = 2.5
if response_time > MAX_RESPONSE_SECONDS:
alert()

1.5 Use f-strings for textโ€‹

Do: f"User {name} has {count} items" Why: Easier to read than + or % formatting. Exception: in logging calls, pass values as arguments. See Logging.


2. Namingโ€‹

2.1 Use the standard naming stylesโ€‹

WhatStyleExample
Variable, function, methodsnake_caseuser_count, get_user()
ClassPascalCaseUserService
ConstantUPPER_CASEMAX_RETRIES
Module (file)snake_caseuser_service.py
Internal (private) namestarts with __cache

2.2 Names should say what the thing isโ€‹

Do: failed_tests, retry_count, is_active, has_permission. Avoid: x, data2, tmp, flag, do_stuff(). Why: A good name removes the need for a comment.

2.3 Functions are actionsโ€‹

Do: Start function names with a verb: load_config(), send_alert(), calculate_total().

2.4 Booleans are questionsโ€‹

Do: Start true/false names with is_, has_, can_, or should_.

2.5 Use units in namesโ€‹

Do: timeout_seconds, size_bytes, delay_ms. Why: It stops mistakes like mixing seconds and milliseconds.


3. Functionsโ€‹

3.1 One function, one jobโ€‹

Do: Keep each function focused on one task. If you use "and" to describe it, think about splitting it. Why: Small functions are easier to name, test, and reuse.

3.2 Keep functions shortโ€‹

Do: Most functions should fit on one screen.

3.3 Add type hintsโ€‹

Do: Add types to parameters and return values. Why: Editors and tools like mypy find mistakes before you run the code. The types also work as documentation.

def get_user(user_id: int) -> dict | None:
...

3.4 Limit the number of parametersโ€‹

Do: If a function needs many values, group them in a dataclass. Use keyword-only parameters (after *) for options. Why: Long parameter lists are easy to call in the wrong order.

def deploy(service: str, version: str, *, dry_run: bool = False, timeout_seconds: int = 300):
...

deploy("api", "1.4.2", dry_run=True) # options must be named

3.5 Use None as the default for lists and dictsโ€‹

Do: Use None as the default and create the list inside the function. Why: A default value is created only once. A default list would be shared by every call.

def add_tag(tag: str, tags: list[str] | None = None) -> list[str]:
tags = [] if tags is None else tags
tags.append(tag)
return tags

3.6 Avoid hidden side effectsโ€‹

Do: A function should either return a result or change something, and its name should make clear which one. Why: Surprises cause bugs.

3.7 Return the same type every timeโ€‹

Do: If a function returns a list, return an empty list [] when there is nothing, not None or False. Why: The caller does not need extra checks.


4. Classes and Designโ€‹

4.1 Use a class only when it helpsโ€‹

Do: Use a class when data and behaviour belong together, or when you need several objects with their own state. Use a plain function otherwise.

4.2 Use dataclasses for dataโ€‹

Do: Use @dataclass for objects that mainly hold data. Use frozen=True if the data must not change.

4.3 Prefer composition to deep inheritanceโ€‹

Do: Build objects from other objects ("has a"), instead of long inheritance chains ("is a"). Why: Deep inheritance is hard to follow and hard to change.

4.4 Pass in dependenciesโ€‹

Do: Give a class what it needs (database, client, clock) through its constructor. Why: You can swap in a fake for tests, or a different version later.

class ReportService:
def __init__(self, repository, mailer):
self.repository = repository
self.mailer = mailer

4.5 Separate layersโ€‹

Do: Keep HTTP code, business logic, and database code in different modules. Why: Each part can change without breaking the others, and business logic can be tested without a web server or database.

4.6 Use Enums for fixed choicesโ€‹

Do: class Status(Enum): ACTIVE = "active"; DISABLED = "disabled" Why: Typos in strings are not caught. Wrong Enum names are caught at once.


5. Data and Collectionsโ€‹

5.1 Pick the right collectionโ€‹

NeedUse
Ordered itemslist
Fixed group of valuestuple
Fast "is it there?" check, unique itemsset
Look up by keydict
Add/remove at both endscollections.deque
Count itemscollections.Counter

5.2 Use dict.get() for optional keysโ€‹

Do: timeout = config.get("timeout", 30)

5.3 Use comprehensions for simple changesโ€‹

Do: names = [u.name for u in users if u.is_active] Avoid: Comprehensions with many conditions or nested loops. Use a normal loop then.

5.4 Use enumerate and zipโ€‹

Do: for i, item in enumerate(items): instead of for i in range(len(items)):

5.5 Use generators for large dataโ€‹

Do: Read large files line by line and use generator expressions. Why: Memory use stays small.

5.6 Use Decimal for moneyโ€‹

Do: from decimal import Decimal; price = Decimal("19.99") Why: float cannot store some decimal values exactly.

5.7 Use time zones for datesโ€‹

Do: datetime.now(timezone.utc) Why: Servers in different places must agree on the time.

5.8 Use is for Noneโ€‹

Do: if value is None:

5.9 Copy on purposeโ€‹

Do: Use copy.deepcopy() when you need a fully separate copy of nested data.


6. Errors and Exceptionsโ€‹

6.1 Catch specific exceptionsโ€‹

Do: except FileNotFoundError: or except (ConnectionError, TimeoutError): Avoid: A bare except: or a broad except Exception: in normal code. Why: Broad catches hide real bugs.

6.2 Never hide an error silentlyโ€‹

Do: Handle it, log it, or raise it again. Avoid:

try:
save(data)
except Exception:
pass # the error is lost

6.3 Keep the try block smallโ€‹

Do: Put only the line that can fail inside try. Why: You then know exactly which line caused the error.

6.4 Create your own exception typesโ€‹

Do: Make a base exception for your project and child types for each kind of problem. Why: Callers can handle each case clearly.

6.5 Keep the original errorโ€‹

Do: raise ConfigError("Bad port") from error Why: The full cause stays in the error trace for debugging.

6.6 Fail earlyโ€‹

Do: Check input and configuration at the start. Raise a clear error at once. Why: An early, clear error is easier to fix than a strange failure later.

6.7 Write helpful error messagesโ€‹

Do: Say what failed, which value caused it, and what was expected.

raise ValueError(f"port must be 1-65535, got {port}")

7. Loggingโ€‹

7.1 Use logging, not print, in real programsโ€‹

Why: Logs have time, level, and source, and can be sent to files or log systems.

7.2 One logger per moduleโ€‹

import logging
logger = logging.getLogger(__name__)

7.3 Pass values as argumentsโ€‹

Do: logger.info("User %s logged in", user_id) Why: The text is only built if the message is really written. It also keeps log lines easy to group in search tools.

7.4 Use the right levelโ€‹

LevelUse for
DEBUGDetails for developers while fixing a problem
INFONormal important events (started, finished, deployed)
WARNINGSomething unusual happened, but the program handled it
ERRORSomething failed
CRITICALThe program cannot continue

7.5 Log the full error traceโ€‹

Do: Inside except, use logger.exception("Could not save order %s", order_id).

7.6 Never log secrets or personal dataโ€‹

Do not log: passwords, tokens, API keys, card numbers, or full personal details.

7.7 Use structured logs in servicesโ€‹

Do: Write logs as JSON with fields like request_id, service, and duration_ms. Why: Log systems can search and filter by field.


8. Resources: Files, Connections, Locksโ€‹

8.1 Always use withโ€‹

Do: with open(path) as f: Why: The file, connection, or lock is always closed, even if there is an error.

8.2 Set the text encodingโ€‹

Do: open(path, encoding="utf-8") Why: The default encoding is different on different computers.

8.3 Use pathlib for pathsโ€‹

Do: Path("data") / "report.csv" Why: It works the same on Windows, Mac, and Linux.

8.4 Always set timeoutsโ€‹

Do: requests.get(url, timeout=10), subprocess.run(cmd, timeout=60) Why: Without a timeout, one stuck call can freeze the whole program.

8.5 Reuse connectionsโ€‹

Do: Use one requests.Session() or one database pool for many calls. Why: Opening a new connection each time is slow.


9. Testingโ€‹

9.1 Write tests for every changeโ€‹

Do: Add or update tests when you add a feature or fix a bug. Why: Tests catch problems before users do, and let you change code without fear.

9.2 One test checks one behaviourโ€‹

Do: Give each test a name that says what it checks: test_login_fails_with_wrong_password.

9.3 Use the Arrange โ€“ Act โ€“ Assert patternโ€‹

def test_discount_is_applied():
cart = Cart(items=[Item(price=100)]) # Arrange: set up
cart.apply_discount(10) # Act: do the action
assert cart.total == 90 # Assert: check the result

9.4 Keep tests independentโ€‹

Do: Each test creates its own data and cleans up after itself. Tests must pass in any order.

9.5 Mock only external thingsโ€‹

Do: Mock networks, payment systems, email, time, and random values. Avoid: Mocking the logic you are testing.

9.6 Keep unit tests fastโ€‹

Do: No real network, database, or sleep in unit tests. Why: Fast tests are run often.

9.7 Test the unhappy pathsโ€‹

Do: Test bad input, empty input, missing data, timeouts, and errors, not only the normal case.

9.8 Replace fixed waits with pollingโ€‹

Do: Wait for a condition with a time limit, instead of time.sleep(5). Why: Fixed waits make tests slow and flaky.

9.9 Use coverage as a guide, not a goalโ€‹

Do: Use coverage reports to find code with no tests. A high number alone does not prove the tests are good.


10. Securityโ€‹

10.1 Never put secrets in codeโ€‹

Do: Read passwords, tokens, and keys from environment variables or a secret manager. Do: Add .env files to .gitignore.

10.2 Check all input from outsideโ€‹

Do: Check type, length, and allowed values for everything that comes from users, files, or other services.

10.3 Use parameters in SQLโ€‹

cursor.execute("SELECT * FROM users WHERE email = ?", (email,))   # safe

Never build SQL by joining strings with user input.

10.4 Run commands safelyโ€‹

Do: subprocess.run(["ls", "-l", folder]) with a list. Avoid: shell=True with any text that comes from outside.

10.5 Use safe loadersโ€‹

Do: yaml.safe_load() and json.loads(). Avoid: pickle.load() and eval() on data you do not fully trust.

10.6 Hash passwordsโ€‹

Do: Use bcrypt, argon2, or hashlib.pbkdf2_hmac with a salt. Never store plain passwords.

10.7 Use the secrets module for tokensโ€‹

Do: secrets.token_urlsafe(32) Avoid: random for anything related to security.

10.8 Show little, log muchโ€‹

Do: Show users a short, general error message. Put the details in the logs.

10.9 Keep dependencies up to dateโ€‹

Do: Scan with pip-audit and update packages that have known security problems.

10.10 Give the least access neededโ€‹

Do: Scripts and services should run with only the permissions they really need.


11. Performanceโ€‹

11.1 Make it correct first, then fastโ€‹

Do: Write clear, correct code first. Improve speed only where it is needed.

11.2 Measure before you changeโ€‹

Do: Use cProfile, timeit, or real metrics to find the slow part. Why: Guessing often picks the wrong place.

11.3 Use sets and dicts for lookupsโ€‹

Do: if user_id in active_ids: where active_ids is a set. Why: A set finds an item instantly, however big it is. A list checks items one by one, so it gets slower as it grows.

11.4 Avoid work inside loopsโ€‹

Do: Move work that gives the same result every time (like re.compile) outside the loop.

11.5 Join strings with joinโ€‹

Do: ", ".join(names) instead of += in a loop.

11.6 Use batchesโ€‹

Do: Save 1,000 rows in one database call, not 1,000 calls.

11.7 Use caching for repeated slow workโ€‹

Do: @functools.lru_cache for pure functions. Add an expiry time for data that changes.

11.8 Choose the right concurrency toolโ€‹

Work typeUse
Waiting on network or diskThreads or asyncio
Heavy calculationProcesses

12. Configurationโ€‹

12.1 Keep config out of codeโ€‹

Do: Read settings from environment variables or config files. Why: The same code runs in development, test, and production with different settings.

12.2 Load config in one placeโ€‹

Do: Create one settings object at start-up and pass it to the parts that need it.

12.3 Check config at start-upโ€‹

Do: Stop with a clear message if a required setting is missing or wrong.

12.4 Give safe defaultsโ€‹

Do: Default to the safe choice, for example debug=False and dry_run=True for scripts that change things.


13. Dependencies and Environmentsโ€‹

13.1 Use a virtual environment for each projectโ€‹

python -m venv .venv
source .venv/bin/activate

13.2 List and pin your dependenciesโ€‹

Do: Keep dependencies in pyproject.toml or requirements.txt. Pin exact versions for applications (e.g. requests==2.32.3, usually kept in a lock file), so every install is the same.

13.3 Separate development toolsโ€‹

Do: Keep test and lint tools (pytest, ruff, mypy) in a separate dev group.

13.4 Add only what you needโ€‹

Do: Before adding a package, check if the standard library already does the job. Why: Each package is more code to update and to keep secure.

13.5 Use a supported Python versionโ€‹

Do: Use a Python version that still gets security updates, and write it in requires-python.


14. Project Structureโ€‹

14.1 Use a standard layoutโ€‹

project/
โ”œโ”€โ”€ pyproject.toml
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ src/
โ”‚ โ””โ”€โ”€ my_project/
โ”‚ โ”œโ”€โ”€ __init__.py
โ”‚ โ””โ”€โ”€ ...
โ””โ”€โ”€ tests/
โ”œโ”€โ”€ unit/
โ””โ”€โ”€ integration/

14.2 Use the main guard in scriptsโ€‹

if __name__ == "__main__":
main()

Why: The file can be imported (for tests) without running the script.

14.3 Import clearlyโ€‹

Do: Put imports at the top. Group them: standard library, then other packages, then your own code. Avoid: from module import *.

14.4 Avoid global stateโ€‹

Do: Pass values as parameters. Keep module-level variables for constants only. Why: Global state makes code hard to test and hard to understand.


15. Comments and Documentationโ€‹

15.1 Explain "why", not "what"โ€‹

Do: Write comments that explain the reason for the code. Avoid: Comments that repeat what the code already says.

# Not helpful
count += 1 # add one to count

# Helpful
# The API counts from 1, not 0
page = index + 1

15.2 Write docstrings for public functions and classesโ€‹

def retry(times: int = 3):
"""Retry the wrapped function when it raises ConnectionError.

Args:
times: How many times to try in total.
"""

15.3 Keep a READMEโ€‹

Do: Explain what the project does, how to install it, how to run it, and how to run the tests.

15.4 Update docs with the codeโ€‹

Do: When code changes, change its comments and docs in the same commit.


16. Git and Code Reviewโ€‹

16.1 Make small commitsโ€‹

Do: Each commit does one thing and has a clear message: "Add retry to payment client".

16.2 Never commit secrets or generated filesโ€‹

Do: Use .gitignore for .env, .venv/, __pycache__/, and build output.

16.3 Keep pull requests smallโ€‹

Why: Small changes are reviewed faster and more carefully.

16.4 Let tools check styleโ€‹

Do: Run formatters and linters (tools that spot likely bugs and style problems) automatically โ€” with pre-commit hooks (checks that run before each git commit) and CI (a server that tests every push). Spend review time on logic and design, not spaces.

16.5 Review with care and respectโ€‹

Do: Ask questions, explain reasons, and suggest options. Review the code, not the person.


17. Automation Scriptsโ€‹

These practices matter most for SRE and SDET tools, but help everyone.

17.1 Add --dry-runโ€‹

Do: Let the user see what the script will do before it changes anything. For risky scripts, make dry-run the default.

17.2 Make scripts safe to run againโ€‹

Do: Running the script twice should give the same result as running it once (this is called idempotent). For example, "create the folder if it does not exist".

17.3 Return the right exit codeโ€‹

Do: sys.exit(0) for success, sys.exit(1) (or another non-zero number) for failure. Why: Cron, CI, and other tools use the exit code to decide what to do next.

17.4 Do not stop at the first bad itemโ€‹

Do: When working on many hosts or files, record each failure, continue, and report all failures at the end.

17.5 Log every changeโ€‹

Do: Log what was changed, where, and when. Why: During an incident, the team needs to know what the automation did.

17.6 Always clean upโ€‹

Do: Use try/finally or a context manager to undo temporary changes, even if the script fails.

17.7 Ask before destructive actionsโ€‹

Do: For deleting or restarting in production, require an explicit flag such as --confirm or --delete.


18. Tools That Helpโ€‹

ToolWhat it does
ruffFinds common mistakes and style problems (very fast); can also format code
blackFormats code automatically
mypy or pyrightChecks type hints
pytestRuns tests
pytest-covMeasures test coverage
pre-commitRuns checks before each commit
pip-auditFinds packages with known security problems
banditFinds common security problems in code
cProfile, py-spyFind slow code

Example setup in pyproject.toml:

[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "S"]

[tool.mypy]
strict = true

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"

19. Short Checklist Before You Commitโ€‹

  • The code is formatted and the linter shows no problems.
  • Names are clear. There are no magic numbers.
  • Functions are small and have type hints.
  • Errors are handled with specific exceptions and clear messages.
  • Files and connections use with.
  • Network calls and commands have timeouts.
  • There are no secrets in the code or in the logs.
  • Input from outside is checked.
  • SQL uses parameters. Commands use lists, not shell=True.
  • Tests are added or updated, and they all pass.
  • Logs use logging with the right levels.
  • The README and docstrings are up to date.
  • The commit is small and has a clear message.