Beginner

You have been there: you add a print("here") to track a variable, run the code, see a number with no context, add another print to show the label, run again, realize the label is wrong, fix it, run a third time. By the time you find the bug, your file is littered with debug prints you have to remember to remove. Python’s built-in print() works, but it makes you write the variable name twice every single time — once in the label string and once as the argument.

icecream fixes this. The ic() function from the icecream library automatically inspects the expression you pass to it and includes the source code in the output. ic(user_id) prints ic| user_id: 42 without you writing the label. It also shows the filename and line number when called without arguments, making it a drop-in replacement for print-based debugging that gives you more information with less code. Install it with pip install icecream.

This article covers the full icecream toolkit: basic usage, inspecting expressions and function calls, customizing output format, using icecream as a call tracer, disabling it for production, and a real debugging session walkthrough. By the end, you will have a debugging workflow that is faster and produces more useful output than raw print statements.

Quick Example: ic() vs print()

Here is an immediate side-by-side comparison showing why developers switch to icecream. Run this after installing with pip install icecream.

# quick_ic.py
from icecream import ic

user = {"name": "alice", "score": 98, "active": True}
scores = [85, 92, 78, 96]

# With print() -- you write the label manually every time
print("user:", user)
print("scores:", scores)
print("max score:", max(scores))

# With ic() -- label is automatic, source expression included
ic(user)
ic(scores)
ic(max(scores))

Output:

user: {'name': 'alice', 'score': 98, 'active': True}
scores: [85, 92, 78, 96]
max score: 96
ic| user: {'name': 'alice', 'score': 98, 'active': True}
ic| scores: [85, 92, 78, 96]
ic| max(scores): 96

Notice that ic(max(scores)) shows the full expression max(scores), not just the result. You can read the output and immediately know which variable or expression produced each value — without writing a single string label. icecream also returns the value it receives, so you can wrap expressions without changing how your code works.

The sections below cover every key feature, from customizing the output format to disabling icecream cleanly in production.

What Is icecream and How Does It Work?

icecream is a lightweight debugging library that wraps Python’s introspection capabilities to automatically discover the source code of the argument you pass to ic(). When you call ic(some_expr), the library uses the executing package to read the actual source of the calling line, extracts the argument text, evaluates it, and prints both the expression and its value together.

This works because Python keeps source code accessible at runtime (via the inspect module and frame inspection). icecream uses this to read the literal text of your code — not just the value — which is why it can show max(scores) instead of just 96.

ApproachWhat You WriteWhat You See
print()print(“score:”, score)score: 98
ic()ic(score)ic| score: 98
ic() (expression)ic(score * 2)ic| score * 2: 196
ic() (no args)ic()ic| myfile.py:14 in process()
Disabled ic()ic.disable()(silent — no output)

The biggest quality-of-life improvement over print is that icecream is designed to be temporary — you add ic() calls, debug, then remove them or disable the library globally when moving to production. Because the output format is consistent and includes the ic| prefix, you can also grep your logs for debug output quickly.

Basic Usage: Variables, Expressions, and Function Returns

The most common use is wrapping a variable or expression you want to inspect. ic() always returns the value it received, so you can insert it into existing code without breaking anything.

# ic_basic.py
from icecream import ic

def calculate_discount(price, percent):
    discount = price * (percent / 100)
    final = ic(price - discount)   # ic() returns the value
    return final

result = calculate_discount(200, 15)
print("Final price:", result)

Output:

ic| price - discount: 170.0
Final price: 170.0

Because ic() returned the value of price - discount, the assignment to final still works correctly. You can wrap any expression in ic() to observe it without refactoring the surrounding code. Remove the wrapper when you are done debugging and the code is identical to how it started.

Tracing Execution with ic()

Calling ic() with no arguments prints the filename, line number, and enclosing function name. This is useful for confirming that a branch was reached — the equivalent of print("GOT HERE") but with actual location information.

# ic_trace.py
from icecream import ic

def process_item(item):
    ic()  # confirm this function was called
    if item.get("active"):
        ic()  # confirm the active branch was taken
        return item["value"] * 2
    ic()   # confirm the inactive branch was taken
    return 0

items = [{"active": True, "value": 10}, {"active": False, "value": 5}]
for item in items:
    result = process_item(item)
    ic(result)

Output:

ic| ic_trace.py:5 in process_item()
ic| ic_trace.py:7 in process_item()
ic| result: 20
ic| ic_trace.py:5 in process_item()
ic| ic_trace.py:10 in process_item()
ic| result: 0

You can now read the exact execution path — line 7 (active branch) was taken for the first item, line 10 (inactive branch) for the second. No guessing, no manually formatting location strings.

Customizing icecream Output

You can configure icecream’s output format, prefix, and output destination using the ic.configureOutput() method. This is useful for adding timestamps, routing output to a log file, or integrating icecream into an existing logging system.

# ic_config.py
from icecream import ic
from datetime import datetime

# Add a timestamp to every ic() output
def timestamped_prefix():
    return f"[{datetime.now().strftime('%H:%M:%S')}] ic| "

ic.configureOutput(prefix=timestamped_prefix)

x = 42
data = {"a": 1, "b": 2}
ic(x)
ic(data)

Output:

[09:15:42] ic| x: 42
[09:15:42] ic| data: {'a': 1, 'b': 2}

You can also route output to a file or any callable that accepts a string by passing an outputFunction argument. For example, ic.configureOutput(outputFunction=logger.debug) would route all icecream output through your Python logger instead of printing to the terminal.

Disabling icecream for Production

The cleanest way to use icecream is to disable it with a single line at the top of your entry point when not in debug mode. This makes all ic() calls silent without removing them from your codebase.

# app_entry.py
import os
from icecream import ic

# Disable ic() in production -- all ic() calls become no-ops
if os.getenv("DEBUG") != "1":
    ic.disable()

def compute(n):
    result = n * n
    ic(result)   # silent in production, visible in debug mode
    return result

# Run with DEBUG=1 python app_entry.py to enable ic() output
print(compute(7))
print(compute(12))

Output (without DEBUG=1):

49
144

Output (with DEBUG=1):

ic| result: 49
49
ic| result: 144
144

This pattern means you never have to sweep your codebase to remove debug prints before shipping. Set DEBUG=1 in your local environment and never set it in production. The ic() calls remain in the source as documentation of what you were debugging — future developers (including you) will know exactly where the data was inspected.

Real-Life Example: Debugging a Data Processing Pipeline

Here is a realistic debugging session using icecream to trace through a multi-step data transformation. You will see how ic() makes it easy to find exactly where unexpected data enters the pipeline.

# pipeline_debug.py
from icecream import ic

def parse_record(raw):
    """Parse a raw string record into a dict."""
    ic()  # trace entry
    parts = raw.strip().split(",")
    ic(len(parts))
    if len(parts) != 3:
        ic("malformed record", raw)
        return None
    name, score_str, active_str = parts
    score = int(score_str.strip())
    active = active_str.strip().lower() == "true"
    return {"name": name.strip(), "score": score, "active": active}

def filter_active(records):
    """Keep only active records with score above threshold."""
    threshold = 80
    ic(threshold)
    result = [r for r in records if r and ic(r["active"]) and r["score"] > threshold]
    return result

def summarize(records):
    total = sum(r["score"] for r in records)
    avg = total / len(records) if records else 0
    ic(total, avg)
    return {"count": len(records), "total": total, "average": round(avg, 1)}

raw_data = [
    "alice, 95, true",
    "bob, 72, true",
    "carol, 88, false",
    "dave, 91, true",
    "eve, 65",          # malformed
]

parsed = [parse_record(r) for r in raw_data]
active = filter_active(parsed)
summary = summarize(active)

print("\n=== Summary ===")
print(summary)

Output:

ic| pipeline_debug.py:5 in parse_record()
ic| len(parts): 3
ic| pipeline_debug.py:5 in parse_record()
ic| len(parts): 3
ic| pipeline_debug.py:5 in parse_record()
ic| len(parts): 3
ic| pipeline_debug.py:5 in parse_record()
ic| len(parts): 3
ic| pipeline_debug.py:5 in parse_record()
ic| len(parts): 2
ic| ('malformed record', "eve, 65")
ic| threshold: 80
ic| r['active']: True
ic| r['active']: True
ic| r['active']: False
ic| r['active']: True
ic| total: 186, avg: 93.0

=== Summary ===
{'count': 2, 'average': 93.0, 'total': 186}

You can trace every step: the malformed record was caught at parse time, the filter correctly excluded carol (inactive) and bob (score below 80), and only alice and dave made it through. The entire pipeline was debugged by reading the ic| output — no added complexity, no log file configuration, no framework required.

Frequently Asked Questions

Does ic() have any performance impact in production?

When not disabled, each ic() call does source inspection which is slightly slower than a bare print()`. For production code, always call ic.disable() or use the environment variable pattern shown above. A disabled ic() call has near-zero overhead -- it just checks a flag and returns the argument immediately. There is no performance reason to remove ic() calls from production code as long as you disable the library.

Can ic() take multiple arguments?

Yes. ic(a, b, c) prints all three on the same line, separated by commas: ic| a: 1, b: 2, c: 3. This is convenient for inspecting several related variables at once. When you pass multiple arguments, ic() returns them as a tuple, so a, b = ic(a, b) works correctly.

How do I find and remove all ic() calls before shipping?

The recommended approach is not to remove them -- use ic.disable() or the environment variable pattern instead. If you do want to remove them, grep -rn "ic(" *.py will find every call quickly. Alternatively, some teams use iceream's install hook to automatically disable in non-development environments. The key point is that leaving disabled ic() calls in code is not harmful and preserves debugging knowledge for future maintainers.

Does icecream work in Jupyter notebooks?

Yes, ic() works in Jupyter notebooks exactly as in scripts. The output appears in the cell output area with the same format. One useful notebook pattern is to put ic.configureOutput(prefix="") at the top of the notebook to remove the ic| prefix if you prefer cleaner output in a notebook context.

Can I redirect ic() output to Python logging?

Ye{, use ic.configureOutput(outputFunction=logging.debug). This routes all icecream output through your existing logging infrastructure, so it respects your log level settings and handlers. This is useful in applications where you want icecream's convenience during development but want its output integrated with your production log format when needed.

How does icecream compare to Python's pdb debugger?

pdb is a full interactive debugger that lets you step through code, set breakpoints, and inspect the full call stack. icecream is a print-based debugging enhancement -- it does not pause execution or let you step. icecream is better for quick, non-interactive debugging during development; pdb is better when you need to explore program state interactively. Many developers use both: icecream for quick checks, pdb for complex bugs that need stepping.

Conclusion

icecream is a small change with an outsized impact on your debugging workflow. You replace print("label:", value) with ic(value) and immediately get automatic labels, expression display, and file/line information in every debug print. The ic.disable() pattern lets you leave debug calls in your code without production impact, and configureOutput() gives you full control over format and destination.

Try replacing your next five debug print statements with ic() calls and notice how much less you need to type and how much more context you get back. For teams, adding if not DEBUG: ic.disable() to your application entry point is a low-effort way to make debug output a first-class part of the development workflow. See the official icecream documentation on GitHub for the full API reference.