Click here for the first post, which contains the context of this series.
Item #21: Understand how closures interact with variable scope.
Suppose that you have a list L of numbers, have a list G of important numbers, want to sort L while giving priority to the numbers of L that are in G, and want to know if a number in L is in G:
def my_sort(L, G):
    flag = False
    def helper(x):
        if x in G:
            flag = True
            return (0, x)
        return (1, x)
    L.sort(key=helper)
    return flag
This sorts L as expected but returns False: the problem is that flag = True in helper is a new variable due to how scoping works in Python. The fix is to use the keyword nonlocal:
def my_sort(L, G):
    flag = False
    def helper(x):
        nonlocal flag
        if x in G:
            flag = True
            return (0, x)
        return (1, x)
    L.sort(key=helper)
    return flag
However, it is better practice to wrap states in classes:
class MySort:
    def __init__(self, G):
        self.G = G
        self.flag = False
    def __call__(self, x):
        if x in self.G:
            self.flag = True
            return (0, x)
        return (1, x)
def my_sort(L, G):
    my_sort = MySort(G)
    L.sort(key=my_sort)
    return my_sort.flag
Item #22: Reduce visual noise with variable positional arguments.
Suppose that you have the following function:
def my_logger(message, items):
    return f'{message}{", ".join([str(x) for x in items])}'
numbers = [1, 2, 3]
print(my_logger('I like these numbers: ', numbers))
print(my_logger('I like no numbers.', []))
Passing an empty list is noisy. Instead, use positional arguments:
def my_logger(message, *items):
    return f'{message}{", ".join([str(x) for x in items])}'
numbers = [1, 2, 3]
print(my_logger('I like these numbers: ', *numbers))
print(my_logger('I like no numbers.'))
Note that *numbers converts numbers into a tuple, which means that if numbers were a massive generator, this would be resource-intensive.
Moreover, if you were to update the signature of my_logger to something like def my_logger(date, message, *items):, then not updating all of the calls to my_logger would introduce bugs that are hard to detect.
Item #23: Provide optional behavior with keyword arguments.
Note the use of **:
def flow_rate(weight_diff, time_diff, period=1, units_per_kg=1):
    return weight_diff * units_per_kg * period / time_diff
kwargs = {
    'weight_diff': 0.5,
    'time_diff': 3,
    'period': 3600,
    'units_per_kg': 2.2
}
print(flow_rate(**kwargs))
With optional arguments, do not do this: flow_rate(0.5, 3, 3600, 2.2). Instead, do this: flow_rate(0.5, 3, period=3600, units_per_kg=2.2).
Item #24: Use None and Docstring to specify dynamic default arguments.
Suppose that you run the following script:
def append_zero(x=[]):
    x.append(0)
    return x
a = append_zero()
b = append_zero()
It turns out that a and b are the same list, so both look like [0, 0]. This is because lists are dynamic and not static, like strings.
Initialize keyword arguments that have dynamic values with None, and document this in the docstring:
def append_zero(x=None):
    '''Append a zero to a list.
    Args:
        x: list. Defaults to an empty list.
    '''
    if not x:
        x = []
    x.append(0)
    return x
Item #25: Enforce clarity with keyword-only and positional-only arguments.
Suppose that you have the following division function:
def safe_division(number, divisor, ignore_overflow, ignore_zero_division):
    try:
        return number / divisor
    except OverflowError:
        if ignore_overflow:
            return 0
        else:
            raise
    except ZeroDivisionError:
        if ignore_zero_division:
            return float('inf')
        else:
            raise
safe_division(12, 3, True, False)
This is noisy. An improvement would be to change the signature to
def safe_division(number, divisor, ignore_overflow=False, ignore_zero_division=False):
    # ...
safe_division(12, 3, ignore_overflow=True)
The problem is that this is still possible:
safe_division(12, 3, True, False)
Keyword-only arguments cannot be passed by position:
def safe_division(number, divisor, *, ignore_overflow=False, ignore_zero_division=False):
    # ...
safe_division(12, 3, True, False) # This gives an error.
Now, suppose that we change the signature to
def safe_division(numerator, denominator, *, ignore_overflow=False, ignore_zero_division=False):
    # ...
Then this name change could break multiple existing calls to the function.
Positional-only arguments cannot be passed by keyword:
def safe_division(numerator, denominator, /, *, ignore_overflow=False, ignore_zero_division=False):
    # ...
safe_division(numerator=10, denominator=2) # This gives an error.