Type Casting and Coercion - How Python Converts Types Internally
Reading time: ~24 minutes | Level: Foundation → Engineering
Before reading further, predict the output of each line:
print(int(True)) # ?
print(bool(0.0)) # ?
print(int("3.14")) # ?
print(float("inf") > 1e308) # ?
print(int(2.9)) # ?
print(round(2.5)) # ?
print(round(3.5)) # ?
If any of these surprised you, this lesson is exactly what you need. The answers are not edge cases or trivia - they are the predictable results of well-defined rules in Python's type system. Engineers who misunderstand them write code that passes tests and silently fails in production.
Output:
1
False
ValueError: invalid literal for int() with base 10: '3.14'
True
2
2
4
Three of the seven lines produce results that contradict what many developers expect. Let us build a complete mental model.
What You Will Learn
- How explicit casting actually works: the dunder protocol that
int(),float(),str(),bool()call internally - Where Python performs implicit coercion and exactly where it refuses to
- Numeric promotion rules: int → float → complex in arithmetic
- How
round()uses banker's rounding and whyround(2.5) == 2 - The
__bool__and__len__truthiness protocol - How
bytesandstrconversion works and where encoding errors originate - Lossy vs lossless conversion and how to detect precision loss
- Six interview questions with engineer-level answers
- Three graded practice challenges
Prerequisites
- Basic Python data types (
int,float,str,bool,bytes,list) - Familiarity with functions and basic class syntax
- Understanding of Python objects and
type()
The Two Mechanisms: Explicit Casting vs Implicit Coercion
Python's type system has two distinct mechanisms for converting values between types. Confusing them is the root of most type-related bugs.
| Explicit Casting | Implicit Coercion |
|---|---|
| You write the conversion in code. | Python converts automatically. |
int(x), float(x), str(x), bool(x) | 5 + 2.0 → 7.0 (int promoted to float) |
| Always visible at the call site. | Invisible at the call site. |
| Raises clear exceptions on failure. | Silently changes the type of a result. |
| You control it. | Python controls it. |
Both mechanisms call the same underlying dunder methods - the difference is who initiates the call.
Explicit Casting - What the Built-ins Actually Do
When you call int(x), Python does not have special built-in magic for every type combination. Instead, it follows a protocol:
This protocol-based design means any class can participate in type conversion by implementing the appropriate dunder method.
int() - Truncation, Not Rounding
# From float: truncates toward zero (not toward negative infinity)
print(int(3.9)) # 3
print(int(-3.9)) # -3 (not -4 - truncation, not floor)
print(int(3.1)) # 3
# From string: only pure integer strings work
print(int("42")) # 42
print(int("-17")) # -17
print(int(" 42 ")) # 42 - leading/trailing whitespace is stripped
int("3.14") raises ValueError because "3.14" is not a valid integer literal. If you have a float-valued string, you must first convert to float:
# WRONG - raises ValueError
value = int("3.14")
# CORRECT
value = int(float("3.14")) # → 3 (truncated)
int() With a Base - Parsing Non-Decimal Numbers
# Parse strings in different bases using the second argument
print(int("0xFF", 16)) # 255 - hexadecimal
print(int("0b1010", 2)) # 10 - binary
print(int("0o17", 8)) # 15 - octal
print(int("FF", 16)) # 255 - prefix optional when base is explicit
print(int("1010", 2)) # 10
print(int("DEADBEEF", 16)) # 3735928559
# This is essential for parsing addresses, protocol headers, configuration files
The base argument only works when the first argument is a string. int(255, 16) raises TypeError - you cannot re-interpret an integer's bit pattern this way. Use hex(), bin(), and oct() for the reverse direction.
float() - Special Values
print(float("3.14")) # 3.14
print(float("inf")) # inf - positive infinity
print(float("-inf")) # -inf - negative infinity
print(float("nan")) # nan - not a number
print(float("1e308")) # 1e+308 - near float max
print(float("1e309")) # inf - overflow is silent, not an exception
import math
x = float("nan")
print(x == x) # False - NaN is not equal to itself
print(math.isnan(x)) # True - correct way to check
| Expression | Result | Meaning |
|---|---|---|
float("inf") | +∞ | Largest representable value exceeded |
float("-inf") | -∞ | Negative infinity |
float("nan") | NaN | Result of undefined operations (e.g. 0/0 in some contexts) |
float("1e309") | inf | Overflow - max float ≈ 1.8 × 10^308 |
NaN has a defining property: NaN != NaN is always True. Use math.isnan() or math.isinf() for correct checks.
str() - Calls __str__, Falls Back to __repr__
print(str(100)) # "100"
print(str(3.14)) # "3.14"
print(str(True)) # "True"
print(str(None)) # "None"
print(str([1, 2, 3])) # "[1, 2, 3]" - calls list.__str__
str([1, 2, 3]) returns the string "[1, 2, 3]" - Python's human-readable representation. This is not the same as json.dumps([1, 2, 3]), which returns "[1, 2, 3]" with JSON-compliant quoting:
import json
data = ["hello", True, None, 3.14]
print(str(data)) # "['hello', True, None, 3.14]" - Python repr
print(json.dumps(data)) # '["hello", true, null, 3.14]' - JSON format
# When serializing for APIs, databases, or message queues, always use json.dumps
# str() produces Python syntax, not JSON syntax
str([1, 2, 3]) produces "[1, 2, 3]" with Python-style formatting (single quotes for strings, True/False/None capitalized). If you need JSON format, use json.dumps(). If you use str() to serialize data for an API, the consumer will receive malformed JSON.
bool() - The Truthiness Protocol
# Falsy values:
print(bool(0)) # False
print(bool(0.0)) # False
print(bool(0j)) # False
print(bool("")) # False
print(bool([])) # False
print(bool({})) # False
print(bool(set())) # False
print(bool(None)) # False
print(bool(False)) # False
# Everything else is truthy:
print(bool(1)) # True
print(bool(-1)) # True
print(bool(0.001)) # True
print(bool("0")) # True - non-empty string!
print(bool([0])) # True - non-empty list!
Internally, bool(x) follows this protocol:
bool(x) calls:
1. x.__bool__() if defined → use its return value (must be True or False)
2. x.__len__() fallback → if 0, return False; if nonzero, return True
3. return True default - objects with neither method are always truthy
This means custom classes with __len__ automatically participate in boolean contexts without needing __bool__:
class Queue:
def __init__(self):
self._items = []
def enqueue(self, item):
self._items.append(item)
def __len__(self):
return len(self._items)
q = Queue()
print(bool(q)) # False - len is 0
q.enqueue("task1")
print(bool(q)) # True - len is 1
# The Queue can be used in conditionals naturally:
if q:
print("Queue has items")
bytes() - Three Different Modes
# Mode 1: from an integer - creates a zero-filled bytes object
b = bytes(5)
print(b) # b'\x00\x00\x00\x00\x00'
# Mode 2: from an iterable of integers 0–255
b = bytes([72, 101, 108, 108, 111])
print(b) # b'Hello'
# Mode 3: from a string - requires an encoding
b = bytes("Hello", "utf-8")
print(b) # b'Hello'
# Equivalent to str.encode():
b = "Hello".encode("utf-8")
print(b) # b'Hello'
Container Conversions - list(), tuple(), set(), dict()
# All container converters consume an iterable
print(list("abc")) # ['a', 'b', 'c']
print(tuple(range(5))) # (0, 1, 2, 3, 4)
print(set([1, 2, 2, 3])) # {1, 2, 3} - duplicates removed
print(set("aabbcc")) # {'a', 'b', 'c'}
# dict() from key-value pairs
print(dict([("a", 1), ("b", 2)])) # {'a': 1, 'b': 2}
print(dict(a=1, b=2)) # {'a': 1, 'b': 2} - keyword args
# dict() from two parallel iterables via zip
keys = ["name", "age", "role"]
vals = ["Alice", 30, "engineer"]
print(dict(zip(keys, vals))) # {'name': 'Alice', 'age': 30, 'role': 'engineer'}
Implicit Coercion - Where Python Converts Automatically
Numeric Promotion in Arithmetic
Python promotes numeric types upward in this hierarchy when mixing types in arithmetic:
int → float → complex
int + int → int
int + float → float (int is promoted to float)
int + complex → complex (int is promoted to complex)
float + complex → complex (float is promoted to complex)
print(type(5 + 2)) # <class 'int'>
print(type(5 + 2.0)) # <class 'float'>
print(type(5 + 2j)) # <class 'complex'>
print(type(5.0 + 2j)) # <class 'complex'>
Division is a special case: / always returns float, even when both operands are int:
print(10 / 2) # 5.0 - always float
print(10 // 2) # 5 - integer (floor) division
print(type(10 / 2)) # <class 'float'>
Where Python Does NOT Coerce
Unlike JavaScript, Python refuses to automatically convert between unrelated types:
# This raises TypeError - Python will not guess:
"5" + 5 # TypeError: can only concatenate str (not "int") to str
"5" * "3" # TypeError: can't multiply sequence by non-int of type 'str'
[1, 2] + 1 # TypeError: can only concatenate list (not "int") to list
Python's refusal to auto-coerce strings to numbers prevents an entire class of silent bugs that are common in dynamically-typed languages like JavaScript ("5" + 3 evaluates to "53" in JS, not 8).
Truthiness Coercion in Conditionals
Every conditional statement in Python implicitly calls bool() on its test expression:
# These are all implicit bool() calls:
if x: # bool(x)
while queue: # bool(queue)
x = a or b # bool(a) evaluated; if False, evaluates bool(b)
x = a and b # bool(a) evaluated; if True, evaluates b
This is where the "non-empty string is truthy" trap lives:
# BUG - common in config/CLI parsing
import os
debug = os.getenv("DEBUG", "False") # Returns string "False" if not set
if debug: # bool("False") == True - non-empty string!
print("Debug mode ON") # This prints even when you want debug=False
# CORRECT
debug = os.getenv("DEBUG", "False").strip().lower() == "true"
if debug:
print("Debug mode ON") # Only prints when DEBUG=true/True/TRUE
Numeric Conversion Deep Dive
float to int - Truncation Toward Zero
Python truncates toward zero, which is different from flooring toward negative infinity:
| Value | int() | math.floor() | math.ceil() | round() |
|---|---|---|---|---|
| 3.9 | 3 | 3 | 4 | 4 |
| 3.1 | 3 | 3 | 4 | 3 |
| -3.1 | -3 | -4 | -3 | -3 |
| -3.9 | -3 | -4 | -3 | -4 |
import math
print(int(3.9)) # 3 - truncate toward zero
print(int(-3.9)) # -3 - truncate toward zero (not -4)
print(math.floor(-3.9)) # -4 - floor (toward negative infinity)
print(math.ceil(-3.1)) # -3 - ceiling (toward positive infinity)
round() - Banker's Rounding
Python's round() uses banker's rounding (also called round-half-to-even), not the "round half up" rule taught in school. When the value is exactly halfway between two integers, it rounds to the nearest even integer:
print(round(0.5)) # 0 - rounds to 0 (even)
print(round(1.5)) # 2 - rounds to 2 (even)
print(round(2.5)) # 2 - rounds to 2 (even)
print(round(3.5)) # 4 - rounds to 4 (even)
print(round(4.5)) # 4 - rounds to 4 (even)
This is not a bug. Banker's rounding is the IEEE 754 standard and reduces systematic bias when rounding many values. If you need "round half up" behavior (as in financial rounding in many jurisdictions), use the Decimal module:
from decimal import Decimal, ROUND_HALF_UP
def round_half_up(value, decimal_places):
quantizer = Decimal(10) ** -decimal_places
return float(Decimal(str(value)).quantize(quantizer, rounding=ROUND_HALF_UP))
print(round_half_up(2.5, 0)) # 3.0
print(round_half_up(3.5, 0)) # 4.0
Financial calculations must never use Python's built-in float type - use decimal.Decimal. Floating-point arithmetic cannot represent most decimal fractions exactly:
print(0.1 + 0.2) # 0.30000000000000004 - not 0.3
print(0.1 + 0.2 == 0.3) # False
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # 0.3
print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3")) # True
Large int to float - Silent Precision Loss
Python integers have arbitrary precision. Python floats have 64-bit IEEE 754 representation with 53 bits of mantissa (about 15–17 significant decimal digits). Converting a large integer to float loses precision silently:
large_int = 2**53 + 1 # 9007199254740993
as_float = float(large_int)
print(large_int) # 9007199254740993
print(as_float) # 9007199254740992.0 - off by 1!
print(large_int == as_float) # False
# Another example:
x = 123456789012345678901234567890
y = float(x)
print(x == int(y)) # False - precision was lost
| Integer | Float conversion | Exact? | Notes |
|---|---|---|---|
2**53 | float(2**53) | Yes | Within safe range |
2**53 + 1 | float(2**53 + 1) | No | Precision loss - consecutive integers not distinguishable |
2**53 + 2 | float(2**53 + 2) | Yes | Happens to align with a representable float |
Safe range: integers up to 2^53 = 9,007,199,254,740,992. Beyond 2^53, float cannot distinguish consecutive integers.
Never store database IDs, financial amounts, or cryptographic values in float. A 64-bit integer ID like 9007199254740993 loses the last digit when cast to float. This is a real bug in systems that pass large integers through JSON (which has no integer type, only numbers) without explicit handling.
The __bool__ and __len__ Protocol in Practice
The truthiness system is a protocol: Python checks for __bool__ first, then __len__, then falls back to True:
class EmptyProtocol:
pass
class WithLen:
def __len__(self):
return 0
class WithBool:
def __bool__(self):
return False
class WithBothBoolDominates:
def __bool__(self):
return True
def __len__(self):
return 0 # ignored - __bool__ takes precedence
print(bool(EmptyProtocol())) # True - no protocol, defaults to True
print(bool(WithLen())) # False - __len__ returns 0
print(bool(WithBool())) # False - __bool__ returns False
print(bool(WithBothBoolDominates())) # True - __bool__ wins over __len__
This matters when you write data structures. A custom collection class should implement __len__, and it will automatically behave correctly in boolean contexts without any additional work.
bool Is a Subclass of int
This is one of the most counterintuitive facts about Python's type hierarchy:
print(issubclass(bool, int)) # True
print(isinstance(True, int)) # True
print(isinstance(False, int)) # True
# True behaves as 1, False behaves as 0
print(True + True) # 2
print(True * 10) # 10
print(False + 5) # 5
print(True == 1) # True
print(False == 0) # True
print(True == 2) # False - True is 1, not just any nonzero
This subclass relationship is intentional. It means booleans participate in arithmetic natively, enabling patterns like:
# Count truthy values without a loop
flags = [True, False, True, True, False]
count = sum(flags) # sum treats True as 1, False as 0
print(count) # 3
# Count elements matching a condition
data = [1, -2, 3, -4, 5]
positive_count = sum(x > 0 for x in data)
print(positive_count) # 3
sum(condition for item in iterable) is a Python idiom that exploits bool being a subclass of int. The generator yields True (1) or False (0) for each item, and sum adds them up as integers. This is idiomatic, readable, and O(n).
bytes ↔ str Conversion
The Boundary Model
Every system has a boundary between the text world (str) and the byte world (bytes). Conversion happens exactly at that boundary:
# str → bytes (encoding)
text = "Hello, 世界"
utf8_bytes = text.encode("utf-8")
utf16_bytes = text.encode("utf-16")
ascii_bytes = text.encode("ascii", errors="replace") # ? for non-ASCII
print(utf8_bytes) # b'Hello, \xe4\xb8\x96\xe7\x95\x8c'
print(len(text)) # 9 code points
print(len(utf8_bytes)) # 13 bytes
# bytes → str (decoding)
decoded = utf8_bytes.decode("utf-8")
print(decoded) # Hello, 世界
print(decoded == text) # True
Common Encodings and When to Use Them
| Encoding | Use case | Note |
|---|---|---|
utf-8 | Default for everything | Variable width, ASCII-compatible |
utf-16 | Windows APIs, some file formats | Fixed 2-byte (BMP) or 4-byte |
latin-1 (iso-8859-1) | Legacy European systems | 256 characters, 1 byte each |
ascii | ASCII-only protocols | 128 characters, 7-bit |
utf-8-sig | Windows CSV files with BOM | Adds/strips UTF-8 BOM |
# Decoding with wrong encoding produces garbage or errors:
text = "Ångström"
utf8_bytes = text.encode("utf-8")
try:
wrong = utf8_bytes.decode("ascii")
except UnicodeDecodeError as e:
print(f"Decoding error: {e}")
# Use 'errors' parameter to handle gracefully:
partial = utf8_bytes.decode("ascii", errors="ignore") # drops non-ASCII
replaced = utf8_bytes.decode("ascii", errors="replace") # replaces with ?
The Dunder Protocol - How Python Decides How to Convert
When you call int(x), Python does not hardcode behavior for every type. It calls x.__int__(). This means you can make any class participate in type conversion:
class Celsius:
def __init__(self, value: float):
self.value = value
def __float__(self) -> float:
return float(self.value)
def __int__(self) -> int:
return int(self.value)
def __str__(self) -> str:
return f"{self.value}°C"
def __bool__(self) -> bool:
return self.value > -273.15 # True if above absolute zero
def __repr__(self) -> str:
return f"Celsius({self.value})"
temp = Celsius(36.6)
print(float(temp)) # 36.6 - calls temp.__float__()
print(int(temp)) # 36 - calls temp.__int__()
print(str(temp)) # 36.6°C - calls temp.__str__()
print(bool(temp)) # True - calls temp.__bool__()
# Now temp participates in arithmetic coercion:
# float(temp) + 10 → 46.6
print(float(temp) + 10)
The full set of conversion dunder methods:
| Dunder method | Called by | Purpose |
|---|---|---|
__int__() | int(x) | Convert to int |
__float__() | float(x) | Convert to float |
__complex__() | complex(x) | Convert to complex |
__str__() | str(x) | Human-readable string |
__repr__() | repr(x) | Developer-readable string (fallback for str) |
__bytes__() | bytes(x) | Convert to bytes |
__bool__() | bool(x), if | Truthiness |
__len__() | len(x), bool(x) | Length; truthiness fallback |
__index__() | bin(x), hex(x) | Lossless int conversion for indexing |
__trunc__() | math.trunc(x) | Truncation (deprecated as fallback for int()) |
__floor__() | math.floor(x) | Floor |
__ceil__() | math.ceil(x) | Ceiling |
__round__() | round(x) | Rounding |
Lossy vs Lossless Conversion
Not all conversions preserve information:
| Lossless (no information lost) | Lossy (information discarded) |
|---|---|
int(42) → float(42.0) - small ints | float(3.7) → int(3) - fraction lost |
bool(True) → int(1) | int(2**53+1) → float - precision lost |
int(5) → complex(5+0j) | str("3.14") → float - may round trip |
str → bytes → str (same encoding) | str("hello") → bytes("ascii", ignore) |
list([1,2,3]) → tuple - structure preserved | set([1,2,2]) → {1,2} - duplicates lost |
When writing APIs or data pipelines, always ask: is this conversion lossless? If not, is the caller aware of what is being discarded?
def safe_int_from_float(x: float) -> int:
"""Convert float to int, raising if the float is not a whole number."""
if x != int(x):
raise ValueError(f"Cannot losslessly convert {x!r} to int (fractional part would be lost)")
return int(x)
print(safe_int_from_float(3.0)) # 3
print(safe_int_from_float(3.7)) # ValueError
Common Pitfalls
Pitfall 1 - int("3.14") Raises ValueError
# This crashes:
value = int("3.14") # ValueError: invalid literal for int() with base 10: '3.14'
# The fix:
value = int(float("3.14")) # → 3 (truncated)
# Or if you need rounding:
value = round(float("3.14")) # → 3
Pitfall 2 - int(True) and int(False) Are Integers
# This can silently produce wrong results:
def count_active(flags: list) -> int:
return int(sum(flags)) # works, but int() is redundant
# The dangerous case:
result = int(True) # 1 - this is correct behavior
result = int(False) # 0
# Where it bites you:
data = [True, False, True]
total = sum(data) # 2 - correct
total_as_int = int(True) # 1 - this is fine but looks confusing
Pitfall 3 - str([1, 2, 3]) vs json.dumps([1, 2, 3])
import json
data = [1, 2, "three", True, None]
python_str = str(data)
json_str = json.dumps(data)
print(python_str) # [1, 2, 'three', True, None] ← Python syntax
print(json_str) # [1, 2, "three", true, null] ← JSON syntax
# If you send python_str to a JSON API consumer, it will fail to parse.
# Always use json.dumps() when the output is consumed by non-Python systems.
Pitfall 4 - float("1e309") Is inf, Not an Error
# Overflow to infinity is silent:
x = float("1e309")
print(x) # inf
print(x > 0) # True
print(x + 1 == x) # True - infinity arithmetic
import math
print(math.isinf(x)) # True - correct way to check
Pitfall 5 - Truthiness of "0" and "False"
# Common configuration bug:
enabled = "0" # string from environment variable or config file
if enabled:
print("Feature enabled") # THIS PRINTS - "0" is a non-empty string!
# The fix for boolean-style string config:
enabled = "0"
is_enabled = enabled.strip() in ("1", "true", "yes", "on")
print(is_enabled) # False - correct
Pitfall 6 - Banker's Rounding Surprises
# If your system expects "round half up":
print(round(0.5)) # 0 - banker's rounding to even
print(round(1.5)) # 2 - banker's rounding to even
print(round(2.5)) # 2 - banker's rounding to even
# For round-half-up (e.g., tax calculations):
from decimal import Decimal, ROUND_HALF_UP
result = Decimal("2.5").quantize(Decimal("1"), rounding=ROUND_HALF_UP)
print(result) # 3
Interview Questions and Answers
Q1. What does int(x) actually do internally? How does Python know how to convert a custom object?
int(x) does not hardcode behavior for every type. It first checks if x's class defines __int__() and calls it if present. If not, it falls back to __index__() (which must return an int losslessly - used for sequence indexing). If neither is defined, TypeError is raised. This protocol-based design means any class can participate in int() conversion by defining __int__. The same protocol applies to float() → __float__(), str() → __str__(), and bool() → __bool__(). Python's type system is open: built-in conversion functions are just protocol dispatchers.
Q2. Why does bool inherit from int, and what practical consequences does this have?
The bool type is a subclass of int for historical and practical reasons. True == 1 and False == 0 in all arithmetic and comparison contexts. This means sum([True, False, True, True]) equals 3 - a common Python idiom for counting truthy values without an explicit loop. The consequence is that isinstance(True, int) is True, which can confuse code that uses isinstance checks without accounting for the subclass. In API validation, type(x) is int is sometimes used instead of isinstance(x, int) when you want to exclude booleans from "is this an integer?" checks.
Q3. Explain banker's rounding. Why does Python use it, and when is it wrong for your use case?
Banker's rounding (round-half-to-even) is the IEEE 754 standard behavior: when the value is exactly halfway between two integers (x.5), Python rounds to whichever integer is even. So round(0.5) == 0, round(1.5) == 2, round(2.5) == 2, round(3.5) == 4. The rationale is statistical: when rounding many values, half-up rounding has a systematic upward bias (you round up half the time), while round-half-to-even distributes rounding evenly, eliminating bias in aggregate. When banker's rounding is wrong: many financial and legal domains specify "round half up" as the required method. Use decimal.Decimal with ROUND_HALF_UP for those cases.
Q4. What is the difference between lossy and lossless conversion? Give three examples of each in Python.
Lossless conversion preserves all information: int(5) → float(5.0) (for integers below 2^53), bool(True) → int(1), bytes → str → bytes with the same encoding. Lossy conversion discards information: float(3.7) → int(3) discards the fractional part; int(2**53 + 1) → float loses the least significant bit (the result is off by 1); set([1, 2, 2, 3]) → {1, 2, 3} discards duplicates. In production systems, lossy conversions should always be intentional and documented. Silent precision loss - particularly large_int → float - is a category of bug that is hard to detect because the result looks plausible.
Q5. Python does not coerce str + int. Why not, and how does this differ from JavaScript?
Python raises TypeError for "5" + 5 because the language designers decided that silent coercion between unrelated types hides bugs rather than enabling convenience. In JavaScript, "5" + 3 produces "53" (string concatenation) and "5" - 3 produces 2 (numeric subtraction) - the same operator behaves differently based on which type Python chose to coerce to. This inconsistency is a source of bugs in JavaScript. Python requires explicit conversion: "5" + str(3) or int("5") + 3. The "explicit is better than implicit" principle from the Zen of Python captures this design philosophy. The only automatic numeric coercions Python performs are within the numeric tower (int → float → complex) where the semantics are unambiguous.
Q6. What is the truthiness protocol? How do __bool__ and __len__ interact, and how do you implement correct boolean behavior for a custom class?
When Python evaluates if x:, it calls bool(x). bool(x) first looks for x.__bool__() on the class - if found, it must return True or False. If __bool__ is not defined, Python falls back to x.__len__() - if that returns 0, the result is False; if nonzero, True. If neither is defined, the object is always truthy. For a custom class: if the class represents a collection, implement __len__ (Python will infer truthiness automatically). If "empty" and "nonzero length" are not the same concept (e.g., a connection object is "truthy" when connected, not when it has pending messages), implement __bool__ explicitly and return a clear semantic value. Never implement __bool__ to return an int - it must return a bool.
Graded Practice Challenges
Level 1 - Predict the Output
print(int(True) + int(False))
print(bool("False"))
print(bool([]))
print(bool([False]))
print(int(float("3.99")))
print(round(2.5) + round(3.5))
print(type(10 / 2))
print(type(10 // 2))
Show Answer
1
True
False
True
3
6
<class 'float'>
<class 'int'>
Explanations:
int(True) + int(False)→1 + 0→1.Trueis1andFalseis0becauseboolis a subclass ofint.bool("False")→True."False"is a non-empty string. Truthiness checks string length, not content.bool([])→False. Empty list has__len__returning 0.bool([False])→True. The list contains one element, so__len__returns 1, which is truthy. The element's value (False) is irrelevant.int(float("3.99"))→int(3.99)→3. String"3.99"must first go throughfloat, theninttruncates toward zero.round(2.5) + round(3.5)→2 + 4→6. Banker's rounding: 2.5 rounds to 2 (even), 3.5 rounds to 4 (even).10 / 2→5.0(float). Division always returns float.10 // 2→5(int). Floor division returns int when both operands are int.
Level 2 - Debug This Code
A data import script is failing intermittently on records from a legacy system. The function below should parse a row from a CSV file and return a normalized dictionary. Find all the type conversion bugs:
def parse_record(row: dict) -> dict:
"""Parse a record from the legacy CSV import."""
user_id = int(row["user_id"])
score = int(row["score"]) # comes in as "98.5"
is_active = bool(row["is_active"]) # comes in as "true" or "false"
tax_rate = float(row["tax_rate"]) # comes in as "0.075"
amount = float(row["amount"]) # comes in as "1234.56"
final_amt = amount * (1 + tax_rate)
return {
"user_id": user_id,
"score": score,
"is_active": is_active,
"final_amt": round(final_amt, 2),
}
# Test with a typical row:
row = {
"user_id": "42",
"score": "98.5",
"is_active": "false",
"tax_rate": "0.075",
"amount": "1234.56",
}
print(parse_record(row))
Show Answer
Bug 1: int(row["score"]) where score is "98.5" - int("98.5") raises ValueError because "98.5" is not a valid integer literal. Fix: round(float(row["score"])) if you want an integer score, or keep it as float.
Bug 2: bool(row["is_active"]) where is_active is "false" - bool("false") is True because "false" is a non-empty string. The string's content is ignored; only its length matters to bool(). Fix: parse explicitly.
Bug 3: float arithmetic for financial amounts - using float for tax and amount calculations risks floating-point precision errors in the result.
from decimal import Decimal, ROUND_HALF_UP
def parse_record(row: dict) -> dict:
"""Parse a record from the legacy CSV import - corrected version."""
# Bug 1 fixed: use float first, then convert if needed
user_id = int(row["user_id"])
score = float(row["score"]) # keep as float, or round() if int required
# Bug 2 fixed: explicit string comparison, not bool()
is_active_str = row["is_active"].strip().lower()
is_active = is_active_str in ("true", "1", "yes", "on")
# Bug 3 fixed: use Decimal for financial arithmetic
tax_rate = Decimal(row["tax_rate"])
amount = Decimal(row["amount"])
final_amt = amount * (1 + tax_rate)
final_amt = float(final_amt.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
return {
"user_id": user_id,
"score": score,
"is_active": is_active,
"final_amt": final_amt,
}
row = {
"user_id": "42",
"score": "98.5",
"is_active": "false",
"tax_rate": "0.075",
"amount": "1234.56",
}
print(parse_record(row))
# {'user_id': 42, 'score': 98.5, 'is_active': False, 'final_amt': 1327.15}
Level 3 - Design Challenge
Design a TypedConfig class for loading application configuration from environment variables. The class must:
- Accept a schema dict mapping config key names to target Python types:
{"PORT": int, "DEBUG": bool, "RATE": float, "NAME": str} - Load values from
os.environ(or a providedenvdict for testing) - Convert each value to the declared type - using safe, explicit conversion (not
bool(value)for booleans) - Raise a descriptive
ValueErrorthat names the key and shows the raw value when conversion fails - Provide a
get(key)method and make the config subscriptable via__getitem__ - Implement
__repr__showing the loaded keys and their types (not their values - config may contain secrets)
Write the class, explain your design decisions, and demonstrate its usage.
Show Answer
import os
from typing import Any
class TypedConfig:
"""
Load and type-convert environment variables according to a schema.
Design decisions:
- Boolean parsing uses explicit string matching, not bool(), because
bool("false") == True (non-empty string is truthy).
- float uses float() which handles "inf", "nan", scientific notation.
- int uses int() which handles leading/trailing whitespace.
- All conversion errors raise ValueError with a helpful message naming
the key, the raw value, and the expected type.
- Values are stored in a private dict; __getitem__ and get() provide access.
- __repr__ shows types but not values, safe for logging even with secrets.
"""
_BOOL_TRUE = frozenset({"1", "true", "yes", "on"})
_BOOL_FALSE = frozenset({"0", "false", "no", "off"})
def __init__(self, schema: dict[str, type], env: dict[str, str] | None = None):
self._schema = schema
self._env = env if env is not None else os.environ
self._values: dict[str, Any] = {}
self._load()
def _load(self) -> None:
for key, target_type in self._schema.items():
raw = self._env.get(key)
if raw is None:
raise KeyError(f"Required config key {key!r} not found in environment")
self._values[key] = self._convert(key, raw, target_type)
def _convert(self, key: str, raw: str, target_type: type) -> Any:
raw = raw.strip()
try:
if target_type is bool:
lower = raw.lower()
if lower in self._BOOL_TRUE:
return True
if lower in self._BOOL_FALSE:
return False
raise ValueError(
f"Expected boolean string (true/false/yes/no/1/0), got {raw!r}"
)
elif target_type is int:
return int(raw)
elif target_type is float:
return float(raw)
elif target_type is str:
return raw
else:
raise TypeError(f"Unsupported schema type {target_type!r} for key {key!r}")
except (ValueError, TypeError) as e:
raise ValueError(
f"Config key {key!r}: cannot convert raw value {raw!r} "
f"to {target_type.__name__}: {e}"
) from e
def get(self, key: str, default: Any = None) -> Any:
return self._values.get(key, default)
def __getitem__(self, key: str) -> Any:
return self._values[key]
def __repr__(self) -> str:
schema_summary = ", ".join(
f"{k}:{t.__name__}" for k, t in self._schema.items()
)
return f"TypedConfig({schema_summary})"
# Demonstration:
env = {
"PORT": "8080",
"DEBUG": "false",
"RATE": "0.95",
"NAME": " production-api ",
}
schema = {
"PORT": int,
"DEBUG": bool,
"RATE": float,
"NAME": str,
}
config = TypedConfig(schema, env=env)
print(repr(config)) # TypedConfig(PORT:int, DEBUG:bool, RATE:float, NAME:str)
print(config["PORT"]) # 8080 - int
print(config["DEBUG"]) # False - bool, not bool("false")
print(config["RATE"]) # 0.95 - float
print(config["NAME"]) # production-api - stripped
print(config.get("MISSING", "default")) # default
# Error case:
bad_env = {"PORT": "not-a-number", "DEBUG": "false", "RATE": "0.9", "NAME": "x"}
try:
bad = TypedConfig(schema, env=bad_env)
except ValueError as e:
print(e)
# Config key 'PORT': cannot convert raw value 'not-a-number' to int: ...
Complexity analysis:
_load(): O(k) where k is the number of schema keys_convert(): O(len(raw)) for string operations; overall O(1) per keyget()/__getitem__(): O(1) dict lookup__repr__(): O(k) to format schema keys
Quick Reference Cheatsheet
| Conversion | Syntax | Result | Pitfall |
|---|---|---|---|
| str → int | int("42") | 42 | int("3.14") raises ValueError |
| str → int (hex) | int("FF", 16) | 255 | Base only works with str input |
| float → int | int(3.9) | 3 | Truncates, does not round |
| str → float | float("3.14") | 3.14 | float("inf") works, silent overflow |
| str → bool | bool("false") | True | Use explicit string comparison |
| int → bool | bool(0) | False | Only 0 is falsy, not 0.0 (separate check) |
| any → str | str(x) | Python repr | Not JSON; use json.dumps() for JSON |
| str → bytes | s.encode("utf-8") | b"..." | Raises UnicodeEncodeError if unmappable |
| bytes → str | b.decode("utf-8") | "..." | Raises UnicodeDecodeError if invalid bytes |
| round() | round(2.5) | 2 | Banker's rounding (half-to-even) |
| large int → float | float(2**53 + 1) | precision loss | Use Decimal for large exact values |
bool as int | True + True | 2 | bool is a subclass of int |
sum of booleans | sum([True, False, True]) | 2 | Idiomatic count-truthy pattern |
__bool__ protocol | if obj: | calls __bool__ then __len__ | No __bool__ or __len__ → always True |
Key Takeaways
int(),float(),str(),bool()are protocol dispatchers - they call__int__,__float__,__str__,__bool__on the argument. Any class can participate by implementing these dunder methods.int("3.14")raisesValueError. Always convert"3.14"throughfloatfirst:int(float("3.14")).bool("false")isTrue. Non-empty strings are always truthy regardless of content. Parse boolean config strings explicitly with string comparison.- Python performs numeric promotion automatically (int → float → complex) in arithmetic, but never coerces between unrelated types like
strandint. This is intentional and prevents a class of silent bugs. round(2.5) == 2- Python uses banker's rounding (round-half-to-even). Usedecimal.DecimalwithROUND_HALF_UPfor financial calculations.boolis a subclass ofint:True == 1andFalse == 0in all arithmetic contexts.sum(condition(x) for x in data)is an idiomatic O(n) count pattern.- Large integers lose precision when cast to float: integers above 2^53 cannot be represented exactly as IEEE 754 doubles. Never store IDs or financial values in
float. - The truthiness protocol:
bool(x)callsx.__bool__()first, then falls back tox.__len__(), then defaults toTrue. Implement__len__on collection classes and__bool__on non-collection classes that have a meaningful "active/empty" notion. str(data)produces Python syntax (True,None, single-quoted strings).json.dumps(data)produces JSON syntax (true,null, double-quoted strings). Usejson.dumpswhen the output is consumed by non-Python systems.- Conversion happens through explicit dunder calls - understanding this lets you design classes that integrate cleanly with Python's type system without special-casing every built-in function.
