Skip to Content
Course content

95: Context Managers and the with Statement

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've noticed a pattern with developers moving into intermediate Python: they start using the with statement because they're told it's "best practice" for opening files, but they view it as nothing more than a syntactic shortcut for a try...finally block. They think, "I'm just using this so I don't have to remember to call file.close()."

Thinking with is just a shortcut for closing files

If you think of with as just a "file closer," you're missing the actual power of the Context Manager protocol. Let's look at the "manual" way you might handle a resource if you didn't have with:

f = open("data.txt", "w")
try:
    f.write("Hello world")
    # Imagine some complex logic here that might crash
finally:
    f.close()

Now, here is the with version:

with open("data.txt", "w") as f:
    f.write("Hello world")

On the surface, they do the same thing. But the with statement isn't actually "about" files. It's about scope management. It defines a clear boundary where a resource is active and ensures that, no matter how you exit that boundary—whether the code finishes successfully or crashes with a catastrophic error—the cleanup logic is guaranteed to run. This is a fundamental design pattern in Python called a Context Manager.

Seeing the with statement as a resource lifecycle protocol

The real magic happens when you realize you can make any class a context manager by implementing two magic methods: __enter__ and __exit__. This allows you to manage things that have nothing to do with files, like database connections, network sockets, or even timing how long a piece of code takes to run.

I personally love using context managers for things like temporary state changes. Imagine you have a system where you need to temporarily change the precision of a calculation or a global configuration setting, and then revert it back to the original value regardless of whether the calculation succeeded.

class TemporarySettings:
    def __init__(self, key, new_value):
        self.key = key
        self.new_value = new_value
        self.old_value = None

    def __enter__(self):
        # Setup: Save the old state and apply the new one
        print(f"Saving old setting for {self.key}...")
        self.old_value = settings.get(self.key)
        settings[self.key] = self.new_value
        return self # This is what 'as' captures

    def __exit__(self, exc_type, exc_val, exc_tb):
        # Teardown: Restore the old state
        print(f"Restoring setting for {self.key}...")
        settings[self.key] = self.old_value
        # Returning False allows any exceptions to propagate normally
        return False

# Usage:
with TemporarySettings("debug_mode", True):
    print("System is now in debug mode")
    # If an error happens here, __exit__ still runs!
    # do_something_risky() 

print("System is back to normal")

Notice the arguments in __exit__. Python passes in the exception type, value, and traceback if an error occurred inside the with block. This gives you incredible control. You can actually suppress specific errors by returning True from __exit__, though I'd warn you to use that sparingly—swallowing exceptions is a great way to hide bugs from yourself.

When you're designing your own systems, ask yourself: "Do I have a piece of setup that must have a corresponding teardown?" If the answer is yes, don't rely on the user to remember to call a .stop() or .cleanup() method. Wrap it in a context manager. It makes your API cleaner and your code significantly more robust.




📋 Practical Task

Building a Database Transaction Simulator

In this exercise, you will create a context manager that simulates a database transaction. In a real database, a transaction ensures that a series of changes are either all committed (saved) or all rolled back (undone) if an error occurs.

Requirements:

  • Create a class named DatabaseTransaction.
  • The __enter__ method should print "BEGIN TRANSACTION" and return a mock connection object (a simple string like "DB_CONNECTION" will do).
  • The __exit__ method must check if an exception occurred:
    • If no exception occurred, print "COMMIT TRANSACTION".
    • If an exception occurred, print "ROLLBACK TRANSACTION".
  • Ensure that the exception is still raised after the rollback (do not suppress it).

Test your implementation with these two scenarios:

# Scenario 1: Success
with DatabaseTransaction() as conn:
    print(f"Using {conn} to update records...")
    # No error here

# Scenario 2: Failure
try:
    with DatabaseTransaction() as conn:
        print(f"Using {conn} to update records...")
        raise RuntimeError("Database connection lost!")
except RuntimeError:
    print("Caught the error in main loop")
Rating
0 0

There are no comments for now.

to be the first to leave a comment.