6 Python Best Practices that Distinguish Senior Developers from Juniors

Author:Murphy  |  View: 25573  |  Time: 2025-03-23 18:53:15
Photo by Desola Lanre-Ologun on Unsplash

In January 2023 I published an article about 5 Python Tricks That Distinguish Senior Developers From Juniors. In this article, rather than looking at ‘tricks', we take a look at 6 best practices in Python that can distinguish experienced developers from beginners. Through various examples, we will explore the differences between code written by a senior developer and that by a junior developer.

By learning these best practices, you can write code that is not only perceived as being created by a senior developer, but it will also actually be of a higher quality as well. Both these qualities will be advantageous when e.g. presenting your code to colleagues or at job interviews.

1. Use the right iterable type

An iterable is any Python object capable of returning its members one at a time, permitting it to be iterated over in a for-loop. (source)

Junior developers tend to use lists every time they need an iterable. However, different types of iterables serve different purposes in Python. To summarize the most essential iterables:

  • lists are for iterables that should be ordered and mutable.
  • sets are for iterables that should only contain unique values and are mutable and unordered. They should be preferred when checking for the presence of an item, in which they are extremely fast. However, they are slower than a list when used to iterate over.
  • tuples are for iterables that should be ordered and immutable. Tuples are faster and more memory-efficient than lists.

Let's first take a look at the difference when using a set versus a list. Imagine the simple task of warning the user when a requested username is already used. For example, you might often encounter code like this:

requested_usernames = ["John123", "Delilah42"]
taken_usernames = []
for username in requested_usernames:
  if username not in taken_usernames:
    taken_usernames.append(username)
  else:
    print(f"Username '{username}' is already taken!")

taken_usernames is a list in the above code. However, all values in taken_usernames only have to occur once, there is no need for duplicate values, as duplicate usernames are not allowed. Also, the use case here for taken_usernames is to check for the presence of a new username in it. Therefore, there is no reason here to use a list. Instead, it is better to use a set, as we read here above that checking for presence is faster when using a set and because there is no need to store the same value more than once.

requested_usernams = ["John123", "Delilah42"] 
taken_usernames = set()
for username in requested_usernames:
  if username not in taken_usernames:
    taken_usernames.add(username)
  else:
    print(f"Username '{username}' is already taken!")

Despite that both code snippets end up with the same result, using a set for checking presence instead of a list shows others that you understand that there are different iterable types for different use cases, rather than using a list each time you need an iterable.

For iterables that won't mutate during execution time and need order, a tuple is the best option; e.g.:

# more junior example
weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]

for day in weekdays:
  ...

--------------------------------------------------------------------------
# more senior example
WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday")

for day in WEEKDAYS:
  ...

Now you have a better understanding of when to use which iterable type! Your code will already seem more senior when instead of a list you use a set for iterables that contain only unique values which don't need order, and a tuple for an ordered iterable whose values should not change.

In the next section, we'll take a look at Python's naming conventions, after which will be clear why for example WEEKDAYS was spelt with capital letters in the more senior example in the snippet here above.

If you want to go deeper into when to use which iterable see for e.g. this article:

15 Examples to Master Python Lists vs Sets vs Tuples

2. Use Python's naming conventions

There are two kinds of ‘rules' for variable names in Python:

  • Enforced rules
  • Naming conventions

Enforced rules prevent invalid variable names, such as variable names which start with a digit or that contain hyphens:

2nd_name = "John"

# output 
SyntaxError: invalid syntax

---------------------------

second-name = "John"

# output 
SyntaxError: invalid syntax

Of course, because these are enforced by Python interpreters, you (hopefully) won't see any of them applied in code. However, there are style guidelines (a.k.a. naming conventions) for variable names which are not enforced, and hence, you can use the wrong style for the wrong object.

These are some of the most important naming conventions in Python:

variables: lower-case only, which underscores to split words, for example:

  • first_name items names_list

functions and methods: same rule as variables, lower-case only, which underscores to split words, for example:

  • get_avg load_data print_each_item

classes: use CamelCasing; start with a capital letter and each new word starts with another capital letter, with no underscores in between:

  • Person TrainStation MarineAnimal

constants: uppercase only, with underscores to split words, for example:

  • WEEKDAYS FORBIDDEN_WORDS

modules: for Python file names, use the same convention as variables, functions and _methods (_lowercase with underscores to split words):

  • calculations.py data_preprocessing.py

Using the proper naming conventions does not only show maturity in Python, but not using the proper naming conventions can lead to significantly more confusing code, as we'll see here below.

Python code that follows PEP-8 naming conventions:

# circle.py

PI = 3.14 # Value won't change, so it's a constant

class Circle:
    def __init__(self, radius: float):
        self.radius = radius

    @property
    def area(self):
        return (self.radius **2) * PI

    @property
    def perimeter(self):
        return 2 * self.radius * PI

small_circle = Circle(1)
big_circle = Circle(5)

print(f"Area of small circle = {small_circle.area}")
print(f"Perimeter of small circle = {small_circle.perimeter}")

print(f"Area of big circle = {big_circle.area}")
print(f"Perimeter of big circle = {big_circle.perimeter}")

Python code that doesn't use naming conventions:

# CIRCLE.py

Pi = 3.14

class CIRCLE:
    def __init__(Self, RADIUS: float):
        Self.Radius = RADIUS

    @property
    def AREA(Self):
        return (Self.Radius **2) * Pi

    @property
    def PERIMETER(Self):
        return 2 * Self.Radius * Pi

SmallCIRCLE = CIRCLE(1)
BigCIRCLE = CIRCLE(5)

print(f"Area of small circle = {SmallCIRCLE.AREA}")
print(f"Perimeter of small circle = {SmallCIRCLE.PERIMETER}")

print(f"Area of big circle = {BigCIRCLE.AREA}")
print(f"Perimeter of big circle = {BigCIRCLE.PERIMETER}")

Most developers will definitely highly doubt your Python skills if you provide them with the code in the second snippet, while when provided with the first snippet, they see quality Python code. Therefore it is very important to make sure you adhere to Python's naming conventions.

For more details on naming conventions in Python see PEP-8.

3. Use the proper comparison statements

Comparison operators (…) compare the values on either side of them and returns a boolean value. They tell whether a statement is True or False according to the condition. (source)

In Python, there are many ways to write (almost) identical comparison statements, but they're not necessarily equally appropriate. Let's take a look at a small example:

def is_even(x):
  return True if x % 2 == 0 else False

x = 10

# different comparison statements which result in the same result:
if is_even(x) == True:
  print(f"{x} is an even number!")

if is_even(x) is True:
  print(f"{x} is an even number!")

if is_even(x):
  print(f"{x} is an even number!")

The last example if is_even(x): works, because without anything to compare to, Python evaluates if is_even(x) evaluates to True. However, it is important to note that almost any Python variable will evaluate to True except for:

  • Empty sequences, such as lists, tuples, strings etc.
  • The number 0 (in both its integer and its float type)
  • None
  • False (obviously)

This means for example that every if : statement will evaluate to True, except if that number is 0. Therefore, an if-statement without any concrete example might seem too global to be used, because the chances seem high that it unwittingly might evaluate to True. Yet, we can make very good use of the fact that empty sequences always evaluate to False, while a sequence with at least one value always evaluates to True. Often in more junior Python code, you'll encounter the following comparison statement: if != [], such as in the snippet below.

def print_each_item(items):
  if items != []:
    for item in items:
      print(item)
  else:
    raise ValueError("'items' is an empty list, nothing to print!")

However, what will happen when someone inserts a different type of iterable? For example, a set. If you want to raise a ValueError for an empty list , you probably would want to raise a ValueError for an empty set as well. In the code above, an empty set will still evaluate to True, because an empty set is not equal to an empty list. One way to prevent such unwanted behaviour is to use if items instead of if items != [], because if items will now raise a ValueError for each iterable that is empty, including the list, set and a tuple from section1.

def print_each_item(items):
  if items:
    for item in items:
      print(item)
  else:
    raise ValueError("No items to print inside 'items'")

If you want to compare a value explicitly to True or False , you should use is True or is False instead of == True and == False . This also applies to None , because they are all singletons. See PEP285. Though the differences in performances are tiny, is True is a bit faster than == True. Above all, it shows that you are known with PEPs (Python Enhancement Proposals), which shows developer skill maturity.

# more junior example
if is_even(number) == True:

# more senior example
if is_even is True:

-------------------

# more junior example
if value == None:

# more senior example
if value is None:

Bonus tip: PEP8 warns about the use of if value to make sure value is not None . To check whether a value is not None , use if value is not None explicitly.

Choosing the most proper comparison statement can occasionally save you or others from having to debug a tricky bug. But above all, senior developers will estimate you more equal to them if you use e.g. if value is True over if value == True.

Of course, rather than just writing a comparison statement for a variable's value it is better to check for the data type first, but how do we raise good exceptions for that? Let's take a look at raising informative exceptions in the section!

4. Raise informative exceptions

Something more junior developers rarely do is ‘manually' raise exceptions with custom messages. Let's consider the following situation: we want to create a function called print_each_item() which prints each item of an iterable type.

The most simple solution would be:

def print_each_item(items):
  for item in items:
    print(item)

Of course, this code works. However, when this function is part of a big code base, the possible absence of print results when running the program might be confusing. Is the function called properly? One way to solve such an issue is to use logging, which we'll discuss in the next section. First, let's look at how to prevent insecurities such as the absence of print results by raising Exceptions.

The function print_each_item() works only on iterable Python objects, so our first check should be whether Python can iterate on the provided argument by using Python's built-in function iter():

def print_each_item(items):

  # check whether items is iterable
  try:
    iter(items)
  except TypeError as error:
    raise (
      TypeError(f"items should be iterable but is of type: {type(items)}")
      .with_traceback(error.__traceback__)  
    )

By trying the iter() function on items we check whether it is possible to iterate over items. Of course, it is also possible to check the type of items through isinstance(items, Iterable), however, some custom Python types might not count as an Iterable while they might be iterable, so iter(items) is more waterproof here. We add the .with_traceback here to the Exceptionto give more context for debugging when the error is raised.

Next, when we've confirmed that items is iterable, we must make sure that items is not an empty iterable, to prevent the absence of print results. We can do this how we learned in the previous section, by using if items:. If if items: is False we want to raise a ValueError, because that means the iterable is empty. Here below is the full-proof print_each_item() function:

def print_each_item(items):

  # check whether items is an Iterable
  try:
    iter(items)
  except TypeError as e:
    raise (
      TypeError(f"'items' should be iterable but is of type: {type(items)}")
      .with_traceback(e.__traceback__)  
    )

  # if items is iterable, check whether it contains items
  else:
    if items:
      for item in items:
        print(item)

    # if items doesn't contain any items, raise a ValueError
    else:
      raise ValueError("'items' should not be empty")

Of course, the most simple print_each_item() is fine for most use cases, however, if you work as a developer at a company, or write open-source code, and the function is often reused, your fellow-Pythoneers might require or at least be much happier when they see the function as in the second example. Being able to understand which exceptions could happen for a function and how to properly handle them and raising informative exceptions is definitely a required skill to become (more) senior.

Yet, your function will still likely be refused when being reviewed by others. That is because it doesn't contain a docstring or any type hinting, which are essential for high-quality Python code.

5. Type hinting and docstrings

Type hinting was introduced in Python 3.5. With type hinting, you can hint which type is expected. A very simplistic example could be:

def add_exclamation_mark(sentence: str) -> str:
  return f"{sentence}!"

By specifying str as the type hint of sentence we know sentence should be a string, and not e.g. a list with words. Through -> str we specify that the function returns an object of type string. Python won't enforce the right types (if won't raise an Exception if an object of a different type is inserted) but often IDEs like Visual Studio Code and PyCharm help you code by making use of the type hints in the code (see screenshot further down this section).

We can apply this too in our print_each_item() through:

from collections.abc import Iterable

def print_each_item(items: Iterable) -> None:
  ...

Docstrings help explain code snippets like functions or classes. We can add the following docstring to print_each_item() to make it absolutely clear for other users and our future self what the function does:

from collections.abc import Iterable

def print_each_item(items: Iterable) -> None:
  """
  Prints each item of an iterable.

  Parameters:
  -----------
  items : Iterable
      An iterable containing items to be printed.

  Raises:
  -------
  TypeError: If the input is not an iterable.
  ValueError: If the input iterable is empty.

  Returns:
  --------
  None

  Examples:
  ---------
  >>> print_each_item([1,2,3])
  1
  2
  3

  >>> print_each_item(items=[])
  ValueError: 'items' should not be empty
  """
  ...

Now, if we are writing code that uses print_each_item we see the following information appear:

(Screenshot by Author)

By adding type hinting and docstrings, we have made our function much more user-friendly!

For more on type hinting click here. For more on docstrings see PEP-257.

Note: it might feel like writing a long docstring for such a simple function is a bit of an overkill, which you could argue it sometimes is. Luckily, when a function isn't secretive, you can easily ask ChatGPT to write a very accurate and elaborate docstring for you!

6. Use logging

There are a few things which make using your code much more pleasant for others, such as type hinting and docstrings. However, one of the most important, underused and underrated features is logging. Though a lot of (junior) developers perceive logging as difficult or unnecessary, running a properly logged program can make a huge difference to anyone using your code.

Only two lines are required to make logging in your code possible:

import logging

logger = logging.getLogger(__name__)

Now, you can easily add logging to help e.g. with debugging:

import logging
from collections.abc import Iterable

logger = logging.getLogger(__name__)

def print_each_item(items: Iterable) -> None:
  """
  
  """
  logger.debug(
    f"Printing each item of an object that contains {len(items)} items."
  )
  ...

It can also really help other developers with debugging by logging the error messages:

import logging
from collections.abc import Iterable

logger = logging.getLogger(__name__)

def print_each_item(items: Iterable) -> None:
  """
  
  """

  logger.debug(
      f"Printing each item of an object that contains {len(items)} items."
    )

  # check whether items is iterable
  try:
    iter(items)
  except TypeError as e:
    error_msg = f"'items' should be iterable but is of type: {type(items)}"
    logger.error(error_msg)
    raise TypeError(error_msg).with_traceback(e.__traceback__)

  # if items is iterable, check whether it contains items
  else:
    if items:
      for item in items:
        print(item)

    # if items doesn't contain any items, raise a ValueError
    else:
      error_msg = "'items' should not be empty"
      logger.error(error_msg)
      raise ValueError(error_msg)

Because logging is such a rare feature to see in more junior developers' code, adding it to your own makes you already (seem) much more experienced in Python!

To conclude

In this article, we've taken a look at 6 Python best practices that can make the difference between appearing to be a junior developer versus a more senior one. Of course, there are many __ more factors that distinguish senior developers from juniors, however, by applying these 6 best practices you'll definitely distinguish yourself (whether at your work, at a coding interview or when contributing to open-source packages) from other junior developers who don't apply these best practices!

To read more about the differences between code of seniors and juniors see:

5 Python Tricks That Distinguish Senior Developers From Juniors

If you want to know more about how to become more senior yourself see:

How to Level Up Your Python Skills by Learning From These Professionals

Resources

Iterables https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPython/Iterables.html https://stackoverflow.com/questions/2831212/python-sets-vs-lists https://towardsdatascience.com/15-examples-to-master-python-lists-vs-sets-vs-tuples-d4ffb291cf07

Naming conventions https://peps.python.org/pep-0008 https://www.techtarget.com/whatis/definition/CamelCase.

Proper comparison statementshttps://peps.python.org/pep-0008 https://peps.python.org/pep-0285/ https://flexiple.com/python/comparison-operators-in-python/

Type hinting and docstringshttps://docs.python.org/3/library/typing.html https://peps.python.org/pep-0257/

Logginghttps://docs.python.org/3/library/logging.html

Memeshttps://www.reddit.com/r/ProgrammerHumor/comments/l9lbm2/code_review_be_like/

Tags: Data Science Programming Python Software Engineering Tips And Tricks

Comment