The traditional approach to debugging python code is print().
While effective, print()
has its shortcomings, including cluttered output, time spent labeling and difficulty pinpointing exactly where the debug messages came from.
An alternate to print()
is theic()
function from the IceCream library—a tool designed to elevate your debugging game.
In this guide, you’ll learn:
- How to set up a virtual environment for Python projects.
- The process of replacing
print()
withic()
in existing code. - How
ic()
works, why it’s better thanprint().
Setting Up the Environment
Before diving into the code, create a virtual environment to keep the project dependencies isolated.
On Windows:
python -m venv venv
venv\Scripts\activate
pip install icecream
On Linux:
python3 -m venv venv venv
source venv/bin/activate
pip install icecream
Code Example: Debugging with print()
vs. ic()
Create a file named math_operations.py
in your project directory. Write the following code:
math_operations.py
def divide_numbers(a, b):
print("Inputs - a:", a, "b:", b) # Debugging with print
result = a / b
print("Result:", result) # Debugging with print
return result
if __name__ == "__main__":
num1 = 10
num2 = 0 # Intentional error: Division by zero
divide_numbers(num1, num2)
Runing the above code will result in something similar to this:
(venv) PS C:\icecream> & C:/icecream/venv/Scripts/python.exe c:/icecream/math_operations.py
Inputs - a: 10 b: 0
Traceback (most recent call last):
File "c:\icecream\math_operations.py", line 10, in <module>
divide_numbers(num1, num2)
File "c:\icecream\math_operations.py", line 3, in divide_numbers
result = a / b
~~^~~
ZeroDivisionError: division by zero
Replacing print()
with ic()
Modify the code to use ic()
:
Updated math_operations.py
from icecream import ic
def divide_numbers(a, b):
ic(a, b) # Debugging with ic
result = a / b
ic(result) # Debugging with ic
return result
if __name__ == "__main__":
num1 = 10
num2 = 0 # Intentional error: Division by zero
divide_numbers(num1, num2)
Runing the above code will result in something similar to this:
(venv) PS C:\icecream> & C:/icecream/venv/Scripts/python.exe c:/icecream/math_operations.py
ic| a: 10, b: 0
Traceback (most recent call last):
File "c:\icecream\math_operations.py", line 12, in <module>
divide_numbers(num1, num2)
File "c:\icecream\math_operations.py", line 5, in divide_numbers
result = a / b
~~^~~
ZeroDivisionError: division by zero
Doesn’t appear to be much difference, but there is. Let’s examine and compare the print and ic commands in depth.
1. ic() Performed Automatic Variable Labeling
With print()
, you have to explicitly label variables:
print("Inputs - a:", a, "b:", b)
Imagine doing this over hundreds of lines of code where you need to debug more variables, it becomes tedious to update these labels. With ic()
, you simply pass the variables, and it automatically labels them:
ic(a, b)
These small time savings scale when debugging multiple variables across multiple functions.
2. File and Line Number Information
Let’s enable full context.
from icecream import ic
# Enable full context for debugging (includes file name and line number)
ic.configureOutput(includeContext=True)
def divide_numbers(a, b):
ic(a, b)
result = a / b
ic(result)
return result
if __name__ == "__main__":
num1 = 10
num2 = 0 # Intentional error: Division by zero
divide_numbers(num1, num2)
Runing the above code will result in something similar to this:
ic| math_operations.py:7 in divide_numbers()- a: 10, b: 0
Traceback (most recent call last):
File "c:\icecream\math_operations.py", line 15, in <module>
divide_numbers(num1, num2)
File "c:\icecream\math_operations.py", line 8, in divide_numbers
result = a / b
~~^~~
ZeroDivisionError: division by zero
The ic()
statement is paired with the file name (math_operations.py
) and line number (7) where the debugging call occurred. This is particularly useful when:
- Debugging a large codebase with multiple files.
- Tracking down issues in functions that are called from different parts of the program.
To replicate this with print()
, you’d need to manually add context:
print("File: math_operations.py, Line: 7, Variables - a:", a, "b:", b)
3. Debugging Chains and Intermediate Results
Let’s modify the function to make it more complex and include debugging with full context:
from icecream import ic
# Enable full debugging context
ic.configureOutput(includeContext=True)
def divide_numbers(a, b):
c = a * 2
ic(a, b, c) # Debugging intermediate calculations
result = c / b
ic(result)
return result
if __name__ == "__main__":
num1 = 10
num2 = 0 # Intentional error: Division by zero
divide_numbers(num1, num2)
Result:
ic| math_operations.py:8 in divide_numbers()- a: 10, b: 0, c: 20
The ic()
output shows the state of a
, b
, and c
at the point of failure, alongside the file name and line number.
Why it’s better: With print()
, you’d need to manually add this context:
print("File: math_operations.py, Line: 8, Intermediate calculations - a:", a, "b:", b, "c:", c)
4. Consistency Across the Codebase
Debugging large projects can be challenging with print()
due to its lack of context. With ic()
, debugging remains consistent and traceable.
By enabling context in development and disabling it in production, you ensure that your debugging output is effective during development but doesn’t clutter production logs. Replacing all print()
statements with ic()
ensures maintainability and consistency.
5. Output Formatting and Readability
Here’s an example of debugging chains of functions with ic()
and full context enabled:
from icecream import ic
# Enable full debugging context
ic.configureOutput(includeContext=True)
def add_numbers(x, y):
return x + y
def multiply_numbers(x, y):
return x * y
def compute(a, b):
ic(a, b) # Debugging input values
total = add_numbers(a, b)
ic(total) # Debugging intermediate result
product = multiply_numbers(total, a)
ic(product) # Debugging final result
return product
if __name__ == "__main__":
compute(10, 0)
Result:
(venv) PS C:\icecream> & C:/icecream/venv/Scripts/python.exe c:/icecream/math_operations.py
ic| math_operations.py:13 in compute()- a: 10, b: 0
ic| math_operations.py:15 in compute()- total: 10
ic| math_operations.py:17 in compute()- product: 100
Key Advantages:
- Each debug message is labeled with the function and line number.
- The output provides a clear breadcrumb trail showing how values evolve across function calls.
With print()
, you’d have to format and label every debug statement yourself:
print("File: math_operations.py, Function: compute, Line: 13, Variables - a:", a, "b:", b)
6. Ease of Use for Quick Debugging
When testing new logic, you can focus on what matters — your code — without worrying about verbose formatting. Here’s a quick example:
from icecream import ic
# Enable full debugging context
ic.configureOutput(includeContext=True)
def quick_test(x):
y = x + 1
z = y * 2
ic(x, y, z)
return z
if __name__ == "__main__":
quick_test(5)
Result:
(venv) PS C:\icecream> & C:/icecream/venv/Scripts/python.exe c:/icecream/math_operations.py
ic| math_operations.py:9 in quick_test()- x: 5, y: 6, z: 12
Why it’s better:
- All variable states are printed in one line with context.
- You don’t have to explicitly label variables or trace where the message came from.
The Power of a Debug Log with ic()
Configuration
By configuring ic()
to redirect its output to a debug log, you unlock the full potential of detailed, persistent, and context-rich debugging. Here’s how configuring ic()
for logging makes your debugging process significantly more powerful.
In the same math_operations.py
replace the existing code with this:
import time
from icecream import ic
# Custom output function to ensure each debug entry is on a new line
def custom_log_function(output):
debug_log_file.write(output + '\n') # Add a newline after each entry
# Open the log file for writing
debug_log_file = open("debug.log", "w")
# Configure IceCream for logging with a custom output function
ic.configureOutput(prefix='DEBUG -> ', outputFunction=custom_log_function)
def calculate_sum(a, b):
ic(a, b)
result = a + b
ic(result)
return result
def calculate_division(a, b):
try:
ic(a, b)
result = a / b
ic(result)
return result
except ZeroDivisionError as e:
ic("Caught an error:", e)
return None
def pause_execution():
ic("Pausing execution for 2 seconds...")
time.sleep(2)
ic("Resuming execution...")
if __name__ == "__main__":
ic("Starting debugging demonstration...")
# Show variable values and a calculation
x, y = 10, 5
ic(x, y)
sum_result = calculate_sum(x, y)
# Demonstrate handling an error
zero_division_result = calculate_division(x, 0)
# Pause execution
pause_execution()
# Demonstrate successful division
valid_division_result = calculate_division(x, y)
ic("Debugging demonstration complete.")
# Clean up: Close the debug log file
debug_log_file.close()
Now, the output in debug.log
will be properly formatted with each entry on its own line:
DEBUG -> 'Starting debugging demonstration...'
DEBUG -> x: 10, y: 5
DEBUG -> a: 10, b: 5
DEBUG -> result: 15
DEBUG -> a: 10, b: 0
DEBUG -> 'Caught an error:', e: ZeroDivisionError('division by zero')
DEBUG -> 'Pausing execution for 2 seconds...'
DEBUG -> 'Resuming execution...'
DEBUG -> a: 10, b: 5
DEBUG -> result: 2.0
DEBUG -> 'Debugging demonstration complete.'
Expanded list of ic() configuraitons:
from icecream import ic
import sys
# Common Configuration Settings for ic()
# 1. Enable Full Debugging Context
# Adds file name, line number, and function name to all debug output.
ic.configureOutput(includeContext=True)
# 2. Change Output Prefix (Optional)
# Useful when you want a custom prefix for all debugging output.
ic.configureOutput(prefix='DEBUG -> ')
# 3. Change Output Destination (e.g., Write to a File)
# By default, IceCream outputs to stderr. You can redirect to a file.
debug_log_file = open("debug.log", "w") # Logs will be saved here
ic.configureOutput(outputFunction=debug_log_file.write)
# 4. Disable ic() Globally (For Production)
# Call `ic.disable()` in production to suppress all debugging output.
# ic.disable()
# 5. Re-enable ic() (For Development)
# Call `ic.enable()` to re-enable debugging output if it was disabled.
# ic.enable()
# 6. Add a Timestamp to Debug Output
# Logs the time each debug statement is executed.
from datetime import datetime
def custom_log_with_time(output):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return f"[{timestamp}] {output}\n"
ic.configureOutput(outputFunction=lambda output: debug_log_file.write(custom_log_with_time(output)))
# 7. Filter Specific Debug Messages
# Only logs messages that meet certain criteria (e.g., containing "ERROR").
def filter_errors(output):
if "ERROR" in output:
debug_log_file.write(output + '\n') # Only write errors to the log file.
ic.configureOutput(outputFunction=filter_errors)
# 8. Highlight Variables in Output
# Add emphasis (e.g., with uppercase or formatting) to debug variables.
def emphasize_variables(output):
return output.upper() # Convert debug output to uppercase for emphasis.
ic.configureOutput(outputFunction=lambda output: debug_log_file.write(emphasize_variables(output) + '\n'))
# 9. Combine IceCream with Real-Time Monitoring
# Monitor the log file in real time using a command like `tail -f debug.log` on Unix-based systems.
# Example: Use real-time logging for applications that run continuously.
# 10. Use IceCream with Contextual Metadata
# Add extra metadata to logs, such as environment info or application state.
def contextual_logging(output):
metadata = "[ENV: DEVELOPMENT]"
return f"{metadata} {output}\n"
ic.configureOutput(outputFunction=lambda output: debug_log_file.write(contextual_logging(output)))
Thank you for reading this article. I hope you found it helpful and informative. If you have any questions, or if you would like to suggest new Python code examples or topics for future tutorials, please feel free to reach out. Your feedback and suggestions are always welcome!
Happy coding!
C. C. Python Programming
You can also find this article at Medium.com