Learning how to debug Python with f-strings is one of the fastest ways to understand what a program is doing. A debugger is the best tool for complex problems, but a carefully placed print statement is often enough to reveal an unexpected value, a wrong type, or a branch that never runs.
Python f-strings make these temporary diagnostics readable because they can display variable names, values, formatting, and expressions directly inside a string. This guide covers the debug specifier, representations, alignment, numbers, dates, collections, functions, loops, exceptions, and the moment when you should replace print-based debugging with proper logging or an interactive debugger.
Before continuing, review Python data types, functions, and exception handling if these concepts are new to you.
What Is an f-String?
An f-string is a string literal prefixed with f. Expressions placed inside braces are evaluated when the string is created.
name = "Maya"
score = 92
print(f"{name} scored {score} points")The result is easy to read and does not require manual concatenation or calls to str(). The official Python formatted string documentation describes the complete syntax.
Use the Debug Specifier
The most useful f-string feature for debugging is the equals sign after an expression:
quantity = 4
unit_price = 12.5
print(f"{quantity=}")
print(f"{unit_price=}")
print(f"{quantity * unit_price=}")The output includes both the expression and its value:
quantity=4
unit_price=12.5
quantity * unit_price=50.0This removes the need to repeat labels manually and reduces mistakes when several values are inspected at once.
Display Values and Types Together
Many bugs happen because data has the wrong type. A value that looks like a number may actually be text.
age = "25"
print(f"{age=}, type={type(age).__name__}")This immediately shows that age is a string. You can then validate or convert it before performing arithmetic. The data types guide explains common conversions and type-related mistakes.
Use repr for Hidden Characters
Whitespace, tabs, and line breaks can make two strings look equal when they are not. Add !r to request the representation returned by repr().
username = " alex "
print(f"Raw username: {username!r}")
print(f"Clean username: {username.strip()!r}")The quotes and spaces become visible. This technique is especially useful when processing form input, CSV files, or text read from external systems. See the text files guide for safe file handling.
Format Numbers Without Losing Context
Debug output should be precise enough to reveal the problem but readable enough to scan quickly.
revenue = 15342.789
conversion_rate = 0.0847
print(f"{revenue=,.2f}")
print(f"{conversion_rate=:.2%}")The format specification comes after a colon. You can control decimal places, percentages, padding, scientific notation, and thousands separators. For a broader introduction, read formatting numbers with f-strings.
Inspect Conditions Before a Branch
When a conditional block behaves unexpectedly, print the values and the final condition separately.
balance = 80
purchase = 95
is_active = True
can_buy = balance >= purchase and is_active
print(f"{balance=}, {purchase=}, {is_active=}, {can_buy=}")
if can_buy:
print("Purchase approved")
else:
print("Purchase denied")This is clearer than guessing which part of a compound expression returned false. The Python booleans guide explains logical operators and short-circuit evaluation.
Debug Loops with Indexes
Loop bugs often depend on one particular item. Include an index and the current value:
prices = [10, 20, -5, 30]
for index, price in enumerate(prices):
print(f"{index=}, {price=}")
if price < 0:
print(f"Invalid price at position {index}: {price}")enumerate() is safer than maintaining a counter manually. The enumerate guide shows additional patterns.
Inspect Dictionaries and Nested Data
For small dictionaries, printing the complete object may be enough:
user = {
"id": 17,
"name": "Nora",
"active": True,
}
print(f"{user=}")
print(f"{user['id']=}, {user['active']=}")For large nested structures, print only the fields involved in the bug. Too much output can hide the useful signal. When JSON is involved, validate the expected keys before accessing them.
Debug Function Inputs and Results
A useful temporary pattern is to inspect parameters at the beginning and the result before returning it.
def calculate_discount(total: float, rate: float) -> float:
print(f"calculate_discount called with {total=}, {rate=}")
discount = total * rate
print(f"{discount=}")
return discountRemove temporary prints after solving the issue, or replace them with structured logging when the information remains useful in production.
Inspect Exceptions Without Hiding Them
When an operation can fail, include the exception type and message:
value = "twelve"
try:
number = int(value)
except ValueError as error:
print(f"Conversion failed: {value=!r}, error={error!r}")Do not catch every exception merely to print it and continue. Handle only failures you understand. The try and except guide covers specific exceptions, else, and finally.
Add Simple Debug Labels
In larger scripts, a consistent prefix makes temporary output easier to find:
status = "processing"
items = 18
print(f"[DEBUG] {status=}, {items=}")You can also include the current function name:
def process_order(order_id: int) -> None:
print(f"[process_order] {order_id=}")For real applications, the standard logging module is better because messages can include levels, timestamps, files, and configurable handlers.
When Print Debugging Is Not Enough
Use a debugger when you need to pause execution, move one line at a time, inspect changing state, or understand a long call chain. Python includes pdb, and editors such as VS Code provide graphical debugging.
The VS Code debugging guide explains breakpoints, stepping, watches, and conditional breakpoints. For persistent diagnostic information, use the Python logging guide.
Common Mistakes
- Leaving sensitive values such as passwords or tokens in output.
- Printing complete large objects instead of the relevant fields.
- Adding so many messages that the useful information disappears.
- Using
str()whenrepr()is needed to expose whitespace. - Catching broad exceptions and continuing after an invalid state.
- Leaving temporary prints in a library or production service.
- Assuming print statements replace tests.
A Practical Debugging Checklist
- Reproduce the bug with the smallest possible input.
- Print function inputs and their types.
- Inspect the condition that chooses the wrong branch.
- Print values before and after the suspicious operation.
- Use
!rfor strings and external data. - Confirm the function result before returning it.
- Write a test that prevents the bug from returning.
- Remove temporary diagnostics or convert them to logging.
The Pytest beginner guide helps turn a discovered bug into a permanent regression test.
Frequently Asked Questions
What does the equals sign do inside an f-string?
It prints the expression text together with its value, such as f"{total=}".
What is the difference between !s and !r?
!s uses str(), while !r uses repr(). The representation is often more useful for debugging.
Are f-string debug prints safe in production?
They work, but structured logging is normally safer and easier to control. Never print credentials or private personal data.
Do f-strings replace a debugger?
No. They are excellent for quick inspection, while a debugger is better for stepping through complex execution.
Conclusion
F-strings make quick Python debugging clearer by showing expressions, values, types, formatting, and hidden characters with minimal code. Start with the debug specifier, use !r for suspicious strings, and keep messages focused on the state that matters.
Once the problem grows beyond a few values, move to breakpoints or logging. After fixing the bug, add an automated test and remove temporary output so the code remains clean.






