As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let's talk about fixing things when they go wrong and making them faster. In the world of writing code, especially with Python, this isn't about magic. It's about using the right tools to see what's happening inside your program. I often think of it as being a detective. Your code is the crime scene, a bug is the mystery, and these techniques are your magnifying glass, fingerprint kit, and logic. They help you ask the right questions: Why is it crashing? Why is it so slow? Where is all the memory going?
Here’s how I approach it.
Sometimes, you need to pause everything and look around. Python’s built-in pdb module is like a time-freeze button for your code. You can hit a breakpoint—a designated stopping point—and the entire program halts. At that moment, you’re in control. You can type commands to see what value every variable holds, step forward line by line, or even jump around. It’s the most direct way to understand why your logic isn’t doing what you expected.
Let's say you have a function that’s supposed to process some data but keeps failing. Instead of staring at the code and guessing, you can drop into the debugger right inside it.
import pdb
def process_items(items):
total = 0
pdb.set_trace() # The program stops here
for item in items:
total += item['price'] * item['quantity']
return total
cart = [
{'price': 10.0, 'quantity': 2},
{'price': 5.0, 'quantity': 1}
]
# When you call this, execution pauses at set_trace()
result = process_items(cart)
Once you type pdb.set_trace() and run your script, you'll find yourself at a (Pdb) prompt. You can now type l to list the code around you, p item to print the current item variable, or n to go to the next line. If you see a variable with a weird value, you've found a clue. You can test fixes on the fly by changing variables with p total = 0 and then continue with c. It’s interactive investigation.
But what if the problem isn’t a crash, but a slowdown? Your program works, but it feels sluggish. This is where you switch from detective to performance engineer. You need data on where the time is going. The cProfile module is perfect for this. It doesn't just tell you how long the whole program took; it counts every single function call and measures how long each one consumed.
I wrap the part of my code I'm suspicious of with a profiler. It gives me a report, often showing that one innocent-looking function is being called thousands of times, or that a single operation is taking 80% of the total runtime.
import cProfile
import pstats
from io import StringIO
def find_expensive_task():
# Simulate a task with a hidden bottleneck
data = list(range(10000))
# This might be slower than you think
result_set = set()
for number in data:
if number % 7 == 0: # An arbitrary condition
result_set.add(number ** 2) # Squaring is cheap, but done many times
# Another potential cost
sorted_list = sorted(result_set)
return sorted_list[:10]
# Profile it
profiler = cProfile.Profile()
profiler.enable()
# Run the function we want to measure
find_expensive_task()
profiler.disable()
# Get the results in a readable format
results_stream = StringIO()
stats = pstats.Stats(profiler, stream=results_stream).sort_stats('cumulative')
stats.print_stats(15) # Show top 15 time-consuming functions
print(results_stream.getvalue())
Running this will print a table. Look for the ncalls column (number of calls) and the tottime column (total time spent in that function). You might discover that the sorted() function is taking a surprising amount of time, or that a utility function you wrote is being called far more often than you intended. This data tells you exactly where to focus your optimization efforts.
Memory is another finite resource. Sometimes a program starts fast but gets slower and slower over time, or it suddenly crashes because your computer runs out of RAM. This is often a "memory leak." Something in your code is creating data and never letting go of it, so it piles up. Python’s tracemalloc module helps you track these allocations.
I use it to take snapshots of memory usage before and after an operation. By comparing them, I can see which lines of code are allocating the most memory. It’s like having a before-and-after photo of your computer’s memory.
import tracemalloc
import time
def create_data_chunks():
"""A function that might hold onto memory longer than needed."""
all_chunks = []
for i in range(100):
# Create a sizable chunk of data (a list of strings)
chunk = [f"data_entry_{i}_{j}" for j in range(10000)]
all_chunks.append(chunk)
time.sleep(0.001) # Small delay
# Problem: we return, but the large 'all_chunks' list lingers if referenced
return len(all_chunks)
# Start tracking memory allocations
tracemalloc.start()
# Snapshot before the operation
snapshot1 = tracemalloc.take_snapshot()
# Run the function
result = create_data_chunks()
print(f"Function result: {result}")
# Snapshot after
snapshot2 = tracemalloc.take_snapshot()
# Compare the two
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
print("\nTop 5 lines where memory was allocated:")
for stat in top_stats[:5]:
print(stat)
The output will show you file names and line numbers, along with how many kilobytes of memory were allocated there. If you see a line inside a loop allocating memory on every iteration, and that memory isn’t freed, you’ve found a candidate for a leak. The next step is to ask: Why is this data not being garbage collected? Is there an unexpected reference to it somewhere?
cProfile tells you which functions are slow. But sometimes the bottleneck is a single line inside a function. Maybe one list comprehension or a specific mathematical operation is the culprit. This is where line-by-line profiling shines. The line_profiler tool (a separate package you install) measures the time each individual line of code takes to execute.
I use it when I’ve already narrowed the problem down to a specific function using cProfile. I decorate that function with @profile and run a special command to get a detailed report. It shows the time hit of every single line, revealing hidden inefficiencies.
# This code is meant to be run with the 'kernprof' command-line tool
# Save this as 'my_script.py'
def process_data_line_intensive(data):
# Let's profile this function line-by-line
result = 0
intermediate_list = []
# Line 1: A mapping operation
for value in data:
intermediate_list.append(value * 2)
# Line 2: A filtering operation
filtered_list = []
for value in intermediate_list:
if value % 3 == 0:
filtered_list.append(value)
# Line 3: A summing operation
for value in filtered_list:
result += value
return result
if __name__ == '__main__':
sample_data = list(range(50000))
print(process_data_line_intensive(sample_data))
You would run this script using the command kernprof -l -v my_script.py. The -l enables line-profiling, and -v prints the results immediately. The output is a table. You’ll see the time spent on each line, the number of times it was executed, and the time per hit. You might discover that the first loop (the mapping operation) takes 70% of the function's time. This tells you that if you want to speed this up, you should focus there—perhaps by using a more efficient method like a NumPy array or a generator expression.
For truly stubborn performance issues, especially those involving multi-threading or C extensions, you need a different view. Sampling profilers like py-spy are incredibly powerful. They don't modify your code at all. Instead, they periodically "sample" the state of your running Python program (like taking many quick snapshots) to see what it’s doing most often. This has almost no performance cost, so you can use it on live, production applications.
I’ve used it to diagnose why a web server was stalling under load. While the server was running, I attached py-spy from the terminal. It showed me a "flame graph," a visualization where wider bars represented functions the CPU was spending more time in. I could see that the program was stuck in a locking mechanism, waiting for a database response. The tool itself is command-line based, so you'd typically run something like:
py-spy top --pid 12345 # Shows a live view of what process 12345 is doing
py-spy record -o profile.svg --pid 12345 # Records a profile and outputs a flame graph
The flame graph gives you an instant, visual understanding of your program's hotspots. It’s often the fastest way to identify the core of a performance problem in a complex system.
Debugging isn't only about commands and tools; it's also about how you write your code to make problems obvious. Strategic logging is your best friend here. Instead of using print() statements haphazardly, I use the logging module to create a structured record of events. I can set different levels (DEBUG, INFO, WARNING, ERROR) and configure the logger to write to a file, the console, or even a monitoring service.
When a bug occurs in production, having a detailed log file is like having the black box from an airplane. You can trace the exact sequence of events that led to the failure.
import logging
# Configure the logging system
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('app_debug.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def complex_transaction(user_id, amount):
logger.info(f"Starting transaction for user {user_id}, amount {amount}")
if amount <= 0:
logger.error(f"Invalid amount {amount} for user {user_id}")
raise ValueError("Amount must be positive")
# Simulate a database check
logger.debug("Checking user balance...")
# ... some code ...
logger.debug(f"User {user_id} balance check passed.")
# Simulate processing
logger.info(f"Processing payment of {amount}.")
# ... some code ...
logger.info(f"Transaction for user {user_id} completed successfully.")
return True
# Simulate a call
try:
complex_transaction("user_123", 50.0)
complex_transaction("user_456", -10.0) # This will cause an error
except ValueError as e:
logger.exception("A transaction failed.") # This logs the full traceback
By running this, you get messages on your screen and in the app_debug.log file. The ERROR and exception logs will include the full stack trace, showing you the exact line where things went wrong. Setting the right log level means you can keep verbose DEBUG logs for development and switch to only INFO and ERROR in production, keeping the logs clean but informative.
Finally, one of the most satisfying techniques is writing tests that help you debug. A test isn't just for checking if code works; it can be a precise tool for reproducing a bug. When you get a bug report, the first thing I do is try to write a small, failing test that recreates the issue. This does two things: it confirms the bug exists, and it gives you a safe, repeatable environment to fix it. The pytest framework makes this especially easy with its clear output and powerful features.
Imagine a function that works most of the time but fails with specific input.
# content of test_bug_hunt.py
import pytest
def buggy_parser(input_string):
"""Supposed to return the first number found in the string."""
# This has a subtle bug
for char in input_string:
if char.isdigit():
# Mistake: only returns the first digit character, not the full number
return int(char)
return None
def test_parser_normal():
assert buggy_parser("abc123def") == 123 # This will FAIL. It returns 1.
def test_parser_edge_case():
assert buggy_parser("price is 99 dollars") == 99 # This will FAIL. It returns 9.
def test_parser_no_number():
assert buggy_parser("hello world") is None # This will PASS.
When you run pytest test_bug_hunt.py -v, it clearly shows you that the first two tests fail. The error message tells you the function returned 1 when you expected 123. Now you have a concrete, automated way to confirm the bug. You can then go fix the buggy_parser function—perhaps by using a regular expression to find the full number—and run the tests again. When they pass, you know the bug is fixed and you have a test to prevent it from coming back in the future.
The goal of all this isn't just to fix one problem. It's to build a deeper understanding of how your code behaves. Each tool offers a different lens: pdb for immediate control, cProfile for timing overview, tracemalloc for memory tracking, line_profiler for granular detail, py-spy for a system-wide view, logging for a historical record, and tests for reproducible verification. By learning when and how to use each one, you stop guessing and start knowing. You move from wondering why your code is broken to systematically identifying and resolving the root cause. That shift is what turns writing code from a frustrating puzzle into a manageable, and even enjoyable, process of construction.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)