Pure Functions and Side Effects in Python

Learn the difference between pure functions and impure functions, why purity matters for testing and reliability, and how to write more pure functions in Python.

7 min read

The article on passing objects to Python functions explained how mutable arguments can be modified inside functions and how those modifications affect the caller. That article touched on the concept of side effects, which are changes a function makes to the world beyond returning a value. Now we explore the broader idea of function purity: what makes a function pure, why purity matters for testing and reliability, and how to design Python programs that separate pure computation from necessary side effects.

A pure function is one of the most reliable constructs in programming. It depends only on its arguments, it produces a return value deterministically, and it changes nothing else. Given the same inputs, a pure function always returns the same output, regardless of when it is called, how many times it has been called before, or what else is happening in the program. This predictability makes pure functions trivial to test, safe to call from anywhere, and easy to reason about in isolation. An impure function, by contrast, reads or writes state outside itself, and understanding its behavior requires understanding the state of the entire program at the moment it is called.

The distinction between pure and impure is not a moral judgment; both kinds of functions are necessary. A program with no side effects cannot read input from a user, write output to a screen, save data to a file, or communicate over a network. Every useful program ultimately exists to produce side effects. The art of good function design is keeping the pure logic separate from the impure interactions so that the complex, state-dependent parts of your program are as small and isolated as possible.

The two rules of function purity

A function is pure if it satisfies two conditions. First, it is deterministic: calling it with the same arguments always produces the same return value, no matter when or how many times it is called. Second, it has no side effects: it does not modify any state that is observable outside the function. It does not change global variables, mutate its arguments, write to files or databases, send network requests, print to the console, or read from any source other than its parameters.

A simple arithmetic function like the following is pure by both rules:

pythonpython
def calculate_total(price, tax_rate):
    return price * (1 + tax_rate)

Given the same price and tax_rate, this function always returns the same number. It does not read from global variables, it does not modify anything, and it does not interact with the outside world. You can call it a thousand times in a loop, in any order, from any thread, and it will never surprise you. This is the ideal that every function should approach, even if not every function can reach it fully.

Common side effects in Python

The most common side effects in Python are printing to the console, reading user input, reading or writing files, making network requests, modifying global variables, and mutating mutable arguments. Each of these makes a function impure because the function's behavior or the program's state depends on factors beyond the function's arguments.

Consider a function that calculates and also prints:

pythonpython
def calculate_and_log(price, tax_rate):
    total = price * (1 + tax_rate)
    print(f"Calculated total: {total}")
    return total

The function returns a value deterministically, which is good, but the print statement is a side effect. If you call this function a thousand times in a test suite, your test output fills with a thousand log lines. If you refactor it out of a performance-critical loop, you lose the logging. The side effect couples the calculation to the display, making both harder to change independently. A purer design separates the concerns: one function calculates and returns, and a different part of the program decides when and how to display the result.

A function that modifies a mutable argument, like a function that appends to a list passed as a parameter, is also impure. The modification is a side effect that changes state visible to the caller. This pattern appeared in the article on passing objects to functions, and while it is sometimes the right design, it makes the function harder to test because you must check both the return value and the modified argument to verify correctness. A purer alternative returns a new list instead of modifying the original, which makes the function's effect fully captured by its return value.

Why purity matters for testing

Testing a pure function requires no setup beyond choosing input values and asserting on the return value. There are no files to create, no databases to seed, no global state to reset between tests, and no network connections to mock. You write a test that calls the function with known arguments and checks that the result matches your expectation:

pythonpython
def test_calculate_total():
    assert calculate_total(100, 0.08) == 108.0
    assert calculate_total(0, 0.08) == 0.0
    assert calculate_total(100, 0) == 100.0

These three assertions verify the function's behavior across normal, zero-price, and zero-tax scenarios, and they all run in milliseconds without any test fixtures or cleanup. Testing an impure function that writes to a file would require creating a temporary directory, writing the file, reading it back to verify the contents, and cleaning up afterward, a process that is slower, more error-prone, and harder to debug when it fails.

The testability of pure functions is not just a convenience for developers. It encourages thorough testing, which leads to more reliable code. When testing a function is easy, you write more tests. When testing is hard, tests get skipped, and bugs that tests could have caught make it into production. Designing functions for purity is designing them for testability, and the two goals reinforce each other.

Designing programs around pure functions

The ideal program structure has a pure core surrounded by an impure shell. The core contains all the business logic, data transformations, calculations, and decision-making, implemented as pure functions that depend only on their arguments. The shell handles input, output, file access, network communication, and all other interactions with the outside world. The shell calls the core, not the other way around.

In a command-line tool that processes a CSV file and prints a summary, the impure shell reads the file and calls a pure function to process the data. The processing function takes a list of rows and returns a summary dictionary. It does not know where the rows came from, whether they were read from a file, received over a network, or hardcoded for testing. The shell then takes the summary and prints it. If the output format changes, only the shell needs updating. If the processing logic changes, only the pure function needs updating. If you want to add a web interface, you write a new shell that calls the same pure processing function.

This architecture is sometimes called functional core, imperative shell, and it scales from small scripts to large applications. The writing reusable Python functions article later in this section explores how to design functions that fit naturally into this pattern. The key insight is that pure functions compose effortlessly: the output of one pure function can become the input of another, building complex data pipelines from simple, testable pieces.

When impurity is necessary

Some functions cannot be pure because their entire purpose is a side effect. A function that sends an email, updates a database record, or reads a sensor is inherently impure because its value comes from interacting with the outside world. These functions should exist, but they should be clearly separated from pure logic and kept as thin as possible.

A function that sends a confirmation email should receive a fully prepared email object as an argument and do nothing but transmit it. The logic that constructs the email body, determines the recipient, and decides whether to send it should live in pure functions that the email-sending function calls, or that are called before the impure function is invoked. This separation means you can test the email construction logic without actually sending emails, and you can test that the sending function interacts with the email service correctly without worrying about the content of the messages.

The return statement, covered in the article on return values in Python functions, is the primary mechanism for getting data out of a pure function. Side effects are the primary mechanism for getting data out of an impure function. Recognizing which mechanism a function uses, and being intentional about that choice, is what separates well-designed Python code from code where computation and interaction are tangled together in ways that make both harder to understand.

Rune AI

Rune AI

Key Insights

  • A pure function always returns the same output for the same input and produces no side effects.
  • Side effects include printing, file operations, network calls, modifying global variables, and mutating arguments.
  • Pure functions are easier to test, debug, and reason about because they depend only on their arguments.
  • Real programs need side effects; the goal is to isolate them, not eliminate them.
  • Separate pure computation from side effects: keep business logic pure and I/O at the edges.
RunePowered by Rune AI

Frequently Asked Questions

What makes a Python function pure?

A pure function satisfies two conditions: it always returns the same result when given the same arguments, and it has no side effects. It does not modify global variables, mutate its arguments, write to files, print to the console, make network requests, or depend on any external state that could change between calls. Pure functions are deterministic and self-contained.

Is the Python print function pure?

No. The print function has a side effect: it writes output to the console. While it returns None predictably, the act of printing changes the state of the program's output stream, which is observable outside the function. Any function that interacts with the outside world through input, output, files, or network is impure by definition.

Should all my Python functions be pure?

No. A program that only uses pure functions cannot interact with the user, read files, or communicate over a network. Programs exist to produce side effects. The goal is to separate pure computation from side effects: keep your business logic pure and push side effects to the edges of your program where they are easy to identify and test in isolation.

Conclusion

Pure functions are the ideal building block for Python programs because they are predictable, testable, and composable. Real programs need side effects to interact with users and the outside world, but keeping those side effects isolated at the edges of your program while keeping the core logic pure is a design principle that pays off in every project, regardless of size.