In this tutorial, we’ll study Python context managers and generators, along with code examples of how to use them for optimizing memory.

Python context managers and generators are two powerful features of the language and are used to optimize memory usage and improve performance in Python programs. They also help developers write clean, efficient and maintainable code.

Context managers provide a way to automatically set up and tear down resources, such as files, network connections or database connections, when they are needed and no longer needed, respectively. Generators allow developers to create iterators that can generate a sequence of values on the fly, without having to store them all in memory at once. This can be especially useful when working with large data sets or when processing data in real time.

In this article, we’ll explore how context managers and generators work in Python and how they can be used in practice. We will also cover some important considerations that you should keep in mind while working with Python context managers and generators. Here we go!

Context Managers

Context managers are a powerful tool in Python for managing resources such as files, network connections, and database connections. They allow developers to allocate and deallocate resources automatically, ensuring that resources are properly cleaned up and freed when they are no longer needed. Context managers are especially useful in situations where resources are scarce, such as when working with limited memory or when dealing with multiple users and connections. Additionally, context managers can help prevent resource leaks and simplify error handling, which can be a common source of bugs in complex code.

Syntax of Python Context Managers

The Python with statement implements a context manager. Here is the syntax for implementing a Python context manager.

with <context_manager_expression> as <target>:
     <body>

In the above syntax:

  • <context_manager_expression> is an expression that evaluates to a context manager object. It is responsible for performing any setup operations needed, such as allocating resources or opening a file.

  • <target> is an optional variable that is used to receive the result of the __enter__() method of the context manager. If this variable is not provided, the context manager object is still created and used within the context of the with block, but its result is not assigned to any variable.

  • <body> is a block of code that is executed within the context of the context manager. This code can read and manipulate the resources provided by the context manager as needed.

  • After the <body> block has completed, the with statement calls the __exit__() method of the context manager, which performs any necessary cleanup operations, such as closing a file or freeing resources.


Get Our Python Developer Kit for Free

I put together a Python Developer Kit with over 100 pre-built Python scripts covering data structures, Pandas, NumPy, Seaborn, machine learning, file processing, web scraping and a whole lot more - and I want you to have it for free. Enter your email address below and I'll send a copy your way.

Yes, I'll take a free Python Developer Kit

Examples of Python Context Managers

Let’s see a few examples of how to use Python context managers.

In the following script, the function open('my_file.txt', 'r') returns a context manager object that represents the file handle. Behind the scenes, the with statement calls the __enter__() method of the context manager object, which opens the file and returns a file object. This file object is then assigned to the variable file. The code within the with block can then read data from the file and process it as needed. When the block is exited, the __exit__() method of the context manager is called in the background, which closes the file.

The with statement ensures that the file is properly closed, even if an exception is raised within the block. This can help prevent resource leaks and make code more robust and easier to read.

with open(r'C:\Datasets\my_file.txt', 'r') as file:
    contents = file.read()
    print(contents)

Output:

this is a dummy file
that shows how to use
Python context managers

A real-world example of using Python context managers is when working with databases. Databases typically require a connection to be established and closed after the work is finished. A context manager can be used to manage these resources efficiently and without worrying about the connections being left open.

Let’s consider an example where we use a SQLite database to insert some data into a table. The sqlite3 module in Python provides a context manager that can manage the database connections.

We will first create an SQLlite database, as shown in the following script:

import sqlite3

conn = sqlite3.connect('example.db')
conn.close()

Next, we will create a table inside the example.db database and will insert some data into it using the SQLite context manager. To do so, we will define an insert_student()function, as shown in the following script:

def insert_student(name, id, age):
    with sqlite3.connect('example.db') as conn:
        cursor = conn.cursor()
        cursor.execute('CREATE TABLE IF NOT EXISTS students (name TEXT, id INTEGER, age INTEGER)')
        cursor.execute('INSERT INTO students (name, id, age) VALUES (?, ?, ?)', (name, id, age))
        conn.commit()

insert_student('John Doe', 12345, 20)

In the above script, inside the insert_student() function, the sqlite3.connect() method returns a context manager object that represents the database connection. The with statement calls the __enter__() method of the context manager, which establishes the connection to the database and returns a connection object. This connection object is then assigned to the variable conn.

Within the with block, we create a cursor object by calling the cursor() method on the connection object. The cursor object’s execute() method is used to create a new table called students if it doesn’t already exist, with columns for name (a string), id (an integer), and age (an integer).

The cursor’s execute method is then used again to insert a new record into the students table with the name, id, and age values passed to the function.

Finally, the commit() method is called on the connection object to save the changes to the database, and the with statement automatically closes the database connection.

Using the context manager in this way ensures that the database connection is closed automatically and resources are managed efficiently. Additionally, this approach eliminates the need for the programmer to write boilerplate code to explicitly open and close the database connection.

While context managers can be very useful in managing resources, there are a few important considerations a developer must keep in mind.

  • Context managers can add extra complexity to the code, which can make it harder to read and understand.
  • The use of context managers may not be appropriate for all situations. For example, if you only need to use a resource briefly, it may be simpler to just open and close it manually.
  • If you are working with multiple resources that need to be managed, it can be difficult to ensure that they are all properly managed using a single context manager.
  • It may be harder to test code that uses context managers, as you need to set up the context properly before running the test.

Generators

Generators are a powerful feature of Python that allow you to generate a sequence of values on the fly. Unlike lists, which store all their values in memory, generators generate values as they are needed, making them much more memory-efficient.

Generators are useful in scenarios where you need to generate a large sequence of values, but you don’t want to store them all in memory at once. This is particularly useful when dealing with large datasets or infinite sequences, such as reading a large file or generating an infinite series of numbers. Generators can also be used to lazily load data, which means that data is loaded only when it is needed, rather than all at once.


Get Our Python Developer Kit for Free

I put together a Python Developer Kit with over 100 pre-built Python scripts covering data structures, Pandas, NumPy, Seaborn, machine learning, file processing, web scraping and a whole lot more - and I want you to have it for free. Enter your email address below and I'll send a copy your way.

Yes, I'll take a free Python Developer Kit

Syntax and Examples of Generators

The syntax of a generator function is similar to that of a regular function, but with the addition of the yield keyword.

Here’s an example of a simple generator function that generates a sequence of numbers from 0 to n.

The following Python code defines a generator function called sequence() that yields a sequence of numbers from 0 up to n-1.

The function takes a single argument n, which is the number of items to generate. The function initializes a counter variable i to 0. The while loop in the function runs as long as the value of i is less than n. Inside the loop, the sequence function returns the current value of i using the yield keyword. This means that each time the generator is called, it will return the current value of i and then pause its execution at the yield statement until it is called again.

def sequence(n):
    i = 0
    while i < n:
        yield i
        i += 1

Here’s an example of how to use the sequence generator to generate a sequence of numbers.

for i in sequence(10):
    print(i)

The above Python code above generates a sequence of numbers from 0 to 9. The for loop calls the sequence generator and iterates over the values it yields. Each time the generator is called, it yields the current value of i. The loop continues until the generator has yielded all n values in the sequence. In this case, n is 10, so the loop generates the values 0 to 9, as shown in the following output:

Output:

0
1
2
3
4
5
6
7
8
9

Generators can be used to get a batch of records from a large dataset efficiently, without having to load all the data into memory at once. Here’s an example of how to use a generator to get a batch of records in Python:

def get_records(filename, batch_size):
    EOF = False
    with open(filename) as f:
        while True:
            batch = []
            for i in range(batch_size):
                line = f.readline()
                if not line:
                    # End of file
                    EOF = True
                    break
                batch.append(line)
            if batch != []: yield batch
            if EOF:
              return

In the above script, the get_records() function reads a file filename and yields batches of batch_size records at a time. The function uses a while loop to continue iterating over the file until it reaches the end. Inside the loop, the function reads batch_size lines from the file and adds them to a list named batch. Once the batch is complete, the function yields the batch using the yield keyword. This creates a generator that can be used to iterate over the batches of records one at a time.

You can see from the above script that the generators can also be used with the context managers (using the with statement) to ensure that any resources associated with the generator are properly cleaned up, such as file handles or network connections.

To use the get_records generator, simply call the get_records() function and pass it the file name and the desired batch size as arguments, as shown in the following example where we read the file my_file.txt and yield batches of 3 lines at a time. Since the get_records() function uses a generator to yield the batches of records, only one batch of records will be loaded into memory at a time, making it efficient for processing large datasets.

file_name = 'my_file.txt'
batch_size  = 3

records = get_records(file_name, batch_size)

for batch in records:
    # Process the batch of records
    print(batch)

Output:

['this is a dummy file\n', 'that shows how to use\n', 'Python context managers\n']
['This is a newline 4\n', 'This is a newline 5\n', 'This is a newline 6\n']
['This is a newline 7\n', 'This is a newline 8\n', 'This is a newline 9\n']
['This is a newline 10\n', 'This is a newline 11\n', 'This is a newline 12']

Generators can be a very useful tool in Python programming, but there are a few considerations to keep in mind:

  • If the generated sequence is too large, it can cause memory issues. It’s important to consider the size of the generated sequence and whether it is necessary to generate the entire sequence at once.
  • Once a generator is exhausted, it cannot be used again. If you need to reuse the generator, you will need to create a new instance of it.
  • There are certain operations that may be slower when using a generator instead of a list or other data structure. For example, accessing individual elements of a generator can be slower than accessing elements of a list. Consider the specific use case and performance requirements when choosing whether to use a generator.
  • If an error occurs during the generation of a sequence, the generator will be stopped and cannot be restarted. It may be necessary to catch and handle errors appropriately in order to prevent unexpected behavior.
  • Generators can sometimes make code harder to read and understand. It is important to use clear and concise naming conventions for generator functions and to ensure that the generator is being used appropriately for the specific use case.

Get Our Python Developer Kit for Free

I put together a Python Developer Kit with over 100 pre-built Python scripts covering data structures, Pandas, NumPy, Seaborn, machine learning, file processing, web scraping and a whole lot more - and I want you to have it for free. Enter your email address below and I'll send a copy your way.

Yes, I'll take a free Python Developer Kit