Python Type Casting and Coercion Practice Problems & Exercises
Practice: Type Casting and Coercion
← Back to lessonEasy
Predict the output of each conversion, then run to verify. Pay close attention to the types and representations.
print(int("123"))
print(float(3))
print(float("3.14"))
print(bool(1))
print(int(42.999))
print(float(False))Solution
123
3.0
3.14
True
42
0.0
Breakdown:
int("123")— parses the string"123"to integer123.float(3)— converts integer3to3.0. No data loss.float("3.14")— parses the string to floating-point3.14.bool(1)— any non-zero integer is truthy, soTrue.int(42.999)— truncates toward zero, not rounds. The.999is discarded, yielding42.float(False)—Falseis0in numeric context, so0.0.
Key insight: int() on a float always truncates toward zero. It does not round. This is the single most common source of bugs when converting between float and int.
Expected Output
123\n3.0\n3.14\nTrue\n42\n0.0Hints
Hint 1: int() on a string requires the string to represent a valid integer — no decimals.
Hint 2: float() on an int simply adds .0 — no data loss.
Hint 3: str() on any type returns its string representation.
Predict the output of bool() on each value. Think about Python's falsy values before running.
print(bool(0))
print(bool(42))
print(bool(""))
print(bool("0"))
print(bool([]))
print(bool([0]))
print(bool(None))
print(bool(0.001))Solution
False
True
False
True
False
True
False
True
Breakdown:
bool(0)— zero is falsy →False.bool(42)— any non-zero number is truthy →True.bool("")— empty string is falsy →False.bool("0")— the string"0"is non-empty (length 1), so truthy →True. This trips up many beginners.bool([])— empty list is falsy →False.bool([0])— the list contains one element (even though it is0), so it is non-empty →True.bool(None)—Noneis always falsy →False.bool(0.001)— any non-zero float is truthy →True.
Key insight: Truthiness depends on whether a container is empty or a number is zero — not on the truthiness of the contents. [0] is truthy because the list is non-empty, even though 0 itself is falsy.
Expected Output
False\nTrue\nFalse\nTrue\nFalse\nTrue\nFalse\nTrueHints
Hint 1: Empty containers (list, dict, string, set) are falsy.
Hint 2: Zero values (0, 0.0, 0j) are falsy. Everything else is truthy.
Hint 3: None is always falsy.
Write show_truncation(value) that displays all four ways Python converts a float to an integer, revealing the subtle differences.
show_truncation(7.9)
# Value: 7.9 → int: 7, floor: 7, ceil: 8, round: 8
show_truncation(-7.9)
# Value: -7.9 → int: -7, floor: -8, ceil: -7, round: -8
Solution
import math
def show_truncation(value):
print(f"Value: {value} → int: {int(value)}, floor: {math.floor(value)}, ceil: {math.ceil(value)}, round: {round(value)}")
show_truncation(7.9)
show_truncation(-7.9)
show_truncation(3.5)
Critical distinction for negative numbers:
int(-7.9)→-7(truncates toward zero — chops off the decimal)math.floor(-7.9)→-8(rounds toward negative infinity)
For positive numbers, int() and math.floor() happen to agree. For negative numbers, they diverge. This is one of the most common sources of off-by-one bugs in financial and scientific code.
Banker's rounding: round(3.5) returns 4 in Python 3. But round(4.5) returns 4 (rounds to even). This is IEEE 754 "round half to even" — designed to reduce cumulative bias.
import math
def show_truncation(value):
"""Print the original float, int() result, math.floor(),
math.ceil(), and round() — showing how each differs."""
pass
# Test with positive and negative floats
show_truncation(7.9)
show_truncation(-7.9)
show_truncation(3.5)Expected Output
Value: 7.9 → int: 7, floor: 7, ceil: 8, round: 8\nValue: -7.9 → int: -7, floor: -8, ceil: -7, round: -8\nValue: 3.5 → int: 3, floor: 3, ceil: 4, round: 4Hints
Hint 1: int() truncates TOWARD ZERO — not toward negative infinity.
Hint 2: math.floor() always rounds DOWN (toward negative infinity).
Hint 3: For negative numbers, int() and math.floor() give DIFFERENT results.
Medium
Implement the __int__, __float__, and __bool__ dunder methods on the Temperature class so that Python's built-in int(), float(), and bool() work correctly.
Rules:
int(t)— truncate toward zero (same as built-in int-from-float behavior)float(t)— return the exact Celsius valuebool(t)—Falseonly if at or below absolute zero (-273.15)
Solution
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __int__(self):
return int(self.celsius)
def __float__(self):
return float(self.celsius)
def __bool__(self):
return self.celsius > -273.15
def __repr__(self):
return f"Temperature({self.celsius}°C)"
t1 = Temperature(36.6)
print(int(t1)) # 36
print(float(t1)) # 36.6
print(bool(t1)) # True
t2 = Temperature(-300.0)
print(int(t2)) # -300
print(float(t2)) # -300.0
print(bool(t2)) # False
t3 = Temperature(0.0)
print(bool(t3)) # True
How the protocol works:
- When you call
int(obj), Python looks forobj.__int__(). If it exists, that return value is used. - Same for
float(obj)→__float__()andbool(obj)→__bool__(). - If
__bool__is not defined, Python falls back to__len__(truthy if non-zero length). If neither exists, all instances are truthy. __int__must return an actualint,__float__must return an actualfloat,__bool__must return an actualbool. Returning the wrong type raisesTypeError.
class Temperature:
"""Represents a temperature in Celsius.
Supports int(), float(), and bool() conversions.
- int() returns the rounded-down whole degrees
- float() returns the exact Celsius value
- bool() returns False if at or below absolute zero (-273.15°C)
"""
def __init__(self, celsius):
self.celsius = celsius
def __int__(self):
pass
def __float__(self):
pass
def __bool__(self):
pass
def __repr__(self):
return f"Temperature({self.celsius}°C)"
# Tests
t1 = Temperature(36.6)
print(int(t1))
print(float(t1))
print(bool(t1))
t2 = Temperature(-300.0)
print(int(t2))
print(float(t2))
print(bool(t2))
t3 = Temperature(0.0)
print(bool(t3))Expected Output
36\n36.6\nTrue\n-300\n-300.0\nFalse\nTrueHints
Hint 1: import math and use math.floor() for __int__ — or simply use int() on self.celsius (which truncates toward zero).
Hint 2: __float__ should return the raw celsius value as a float.
Hint 3: __bool__ returns True if temperature is above absolute zero (-273.15°C).
Write a safe_convert function that attempts type conversion and returns a default value if it fails. Handle both ValueError and TypeError.
Edge cases to handle:
int("3.14")raisesValueError(can't parse float string as int directly)int(None)raisesTypeErrorstr(None)returns the string"None"— but the user probably wants the default
Solution
def safe_convert(value, target_type, default=None):
if value is None:
return default
try:
return target_type(value)
except (ValueError, TypeError):
return default
print(safe_convert("42", int)) # 42
print(safe_convert("3.14", float)) # 3.14
print(safe_convert("hello", int, default=-1)) # -1
print(safe_convert("", float, default=0.0)) # 0.0
print(safe_convert(None, str, default="N/A")) # None (our default, not "None")
print(safe_convert("3.14", int, default=0)) # 0
print(safe_convert([1, 2], int, default=-1)) # -1
Why the None check matters: Without it, str(None) would return the string "None" instead of the default. This is a common bug in data pipelines — you expect None to mean "missing data" but str() happily converts it to a string.
Why int("3.14") fails: Python's int() can parse integer strings ("42") but not float strings ("3.14"). You would need int(float("3.14")) for a two-step conversion. Our function correctly returns the default instead of silently doing something surprising.
Production pattern: This function is the foundation of every data cleaning pipeline. Pandas' pd.to_numeric(errors='coerce') does essentially the same thing internally.
def safe_convert(value, target_type, default=None):
"""Attempt to convert value to target_type.
Return default if conversion fails.
Args:
value: The value to convert
target_type: The type to convert to (int, float, str, bool)
default: Value to return on failure
Returns:
Converted value, or default if conversion fails
"""
pass
# Tests
print(safe_convert("42", int))
print(safe_convert("3.14", float))
print(safe_convert("hello", int, default=-1))
print(safe_convert("", float, default=0.0))
print(safe_convert(None, str, default="N/A"))
print(safe_convert("3.14", int, default=0))
print(safe_convert([1, 2], int, default=-1))Expected Output
42\n3.14\n-1\n0.0\nNone\n0\n-1Hints
Hint 1: Use try/except to catch ValueError and TypeError — both can occur during conversion.
Hint 2: Note that int("3.14") raises ValueError — you cannot parse a float string directly to int.
Hint 3: str(None) returns "None" — is that what the user wants? Think about the edge case.
Predict the output and type for each expression. Python silently coerces types in mixed arithmetic — trace exactly what happens.
# int + float → ? result1 = 2 + 3.0 print(result1, type(result1)) # bool + float → ? result2 = True + 2.0 print(result2, type(result2)) # int + complex → ? result3 = 1 + (2+2j) print(result3, type(result3)) # bool + bool → ? result4 = True + True print(result4, type(result4)) # bool * int → ? result5 = True * 1 print(result5, type(result5)) # str * int → ? result6 = "Foo" * 3 print(result6, type(result6)) # list * int → ? result7 = [1, 2] * 3 print(result7, type(result7))
Solution
5.0 <class 'float'>
3.0 <class 'float'>
(3+2j) <class 'complex'>
2 <class 'int'>
1 <class 'int'>
FooFooFoo <class 'str'>
[1, 2, 1, 2, 1, 2] <class 'list'>
Python's numeric coercion hierarchy: bool → int → float → complex
When two different numeric types appear in an arithmetic operation, Python promotes the "narrower" type to the "wider" type:
2 + 3.0: int2is promoted to float2.0, result is5.0(float)True + 2.0: boolTrue→ int1→ float1.0, result is3.0(float)1 + (2+2j): int1→ complex(1+0j), result is(3+2j)(complex)True + True: both bools → both ints (1 + 1), result is2(int, not bool!)True * 1: bool → int, result is1(int)
Non-numeric coercion:
"Foo" * 3: string repetition — not arithmetic coercion. Returns a new string.[1, 2] * 3: list repetition — same idea.
Key insight: bool is a subclass of int in Python. True == 1 and False == 0 in all arithmetic contexts. This is by design (PEP 285), not a quirk.
Expected Output
5.0 <class 'float'>\n3.0 <class 'float'>\n(3+2j) <class 'complex'>\n2 <class 'int'>\n1 <class 'int'>\nFooFooFoo <class 'str'>\n[1, 2, 1, 2, 1, 2] <class 'list'>Hints
Hint 1: Python promotes int → float when mixed in arithmetic.
Hint 2: float → complex when mixed with complex numbers.
Hint 3: bool is a subclass of int: True is 1, False is 0 in arithmetic.
Build a conversion_chain function that applies a sequence of type conversions and records each step — showing exactly how data transforms (and potentially loses information) through a chain of casts.
# Watch data loss happen step by step:
conversion_chain(3.7, int, str, float)
# 3.7 (float) → 3 (int) → '3' (str) → 3.0 (float)
# Started at 3.7, ended at 3.0 — the .7 is gone forever
Solution
def conversion_chain(value, *types):
chain = [(value, type(value).__name__)]
for t in types:
value = t(value)
chain.append((value, type(value).__name__))
return chain
chain1 = conversion_chain(3.7, int, str, float)
for val, tname in chain1:
print(f" {val!r:>10} ({tname})")
print()
chain2 = conversion_chain(True, int, float, str, bool)
for val, tname in chain2:
print(f" {val!r:>10} ({tname})")
print()
chain3 = conversion_chain(0, bool, int, str, list)
for val, tname in chain3:
print(f" {val!r:>10} ({tname})")
Data loss in chain 1: 3.7 → 3 → '3' → 3.0. The fractional part .7 is permanently lost at the int() step. Converting back to float gives 3.0, not 3.7. Type conversion chains are not reversible.
Surprising chain 2: True → 1 → 1.0 → '1.0' → True. The value starts as True, becomes 1, then 1.0, then the string '1.0'. Converting '1.0' back to bool gives True because it is a non-empty string — not because of the original boolean value. If it had been False → 0 → 0.0 → '0.0' → True — the final bool would be True (non-empty string), the opposite of where it started.
Chain 3: 0 → False → 0 → '0' → ['0']. list('0') creates a list from the iterable string, giving ['0'] — a list with one character element.
def conversion_chain(value, *types):
"""Apply a chain of type conversions, tracking each step.
Args:
value: Starting value
*types: Sequence of types to convert through
Returns:
List of (value, type_name) tuples for each step
Example:
conversion_chain(3.7, int, str, float)
→ [(3.7, 'float'), (3, 'int'), ('3', 'str'), (3.0, 'float')]
"""
pass
# Tests
chain1 = conversion_chain(3.7, int, str, float)
for val, tname in chain1:
print(f" {val!r:>10} ({tname})")
print()
chain2 = conversion_chain(True, int, float, str, bool)
for val, tname in chain2:
print(f" {val!r:>10} ({tname})")
print()
chain3 = conversion_chain(0, bool, int, str, list)
for val, tname in chain3:
print(f" {val!r:>10} ({tname})")Expected Output
3.7 (float)\n 3 (int)\n '3' (str)\n 3.0 (float)\n\n True (bool)\n 1 (int)\n 1.0 (float)\n '1.0' (str)\n True (bool)\n\n 0 (int)\n False (bool)\n 0 (int)\n '0' (str)\n ['0'] (list)Hints
Hint 1: Start with the initial value and build up the chain list step by step.
Hint 2: Use type(value).__name__ to get a clean type name string.
Hint 3: Apply each conversion in order: value = next_type(value).
Hard
Build a smart_numeric function that parses a string into the narrowest appropriate numeric type. This is the kind of function you would find inside a CSV parser or configuration loader.
Requirements:
"true"/"false"(case-insensitive) →bool- Whole numbers →
int(including hex like"0xFF") - Decimal or scientific notation →
float - Complex numbers →
complex - Anything else → return original string unchanged
Solution
def smart_numeric(value):
stripped = value.strip()
if not stripped:
return value
# Check bool first (case-insensitive)
if stripped.lower() == "true":
return True
if stripped.lower() == "false":
return False
# Try int (including hex, octal, binary)
try:
# Handle 0x, 0o, 0b prefixes
if stripped.lower().startswith(("0x", "0o", "0b")):
return int(stripped, 0)
result = int(stripped)
return result
except ValueError:
pass
# Try float (handles scientific notation like 1e10)
try:
return float(stripped)
except ValueError:
pass
# Try complex (handles '3+2j')
try:
return complex(stripped)
except ValueError:
pass
# Nothing worked — return original string
return value
test_cases = [
"42",
" -17 ",
"3.14",
"true",
"FALSE",
"1e10",
"3+2j",
"0",
"0.0",
"hello",
" ",
"0xFF",
]
for tc in test_cases:
result = smart_numeric(tc)
print(f" {tc!r:>12} → {result!r:>20} ({type(result).__name__})")
Design decisions:
- Bool before int —
TrueandFalseare technicallyintsubclasses (int("true")raises ValueError anyway, but this makes intent clear). - Int before float —
"42"should beint, notfloat. Ifint()succeeds, we use it. Scientific notation like"1e10"failsint()and falls through tofloat(). - Hex support via
int(s, 0)— passing base0tells Python to detect the base from the prefix (0x= hex,0o= octal,0b= binary). - Whitespace preserved on failure — if nothing parses, return the original value with its whitespace intact.
Real-world usage: Libraries like pandas, pyyaml, and configparser all implement variants of this logic internally. YAML's type resolution is almost exactly this hierarchy.
def smart_numeric(value):
"""Convert a string to the most appropriate numeric type.
Priority (use the narrowest type that fits):
1. bool — if value is 'true'/'false' (case-insensitive)
2. int — if value represents a whole number (no decimal point)
3. float — if value represents a decimal number
4. complex — if value represents a complex number (e.g., '3+2j')
5. Return the original string if none apply
Handle leading/trailing whitespace. Handle negative numbers.
Handle scientific notation (e.g., '1e10' → float).
"""
pass
# Tests
test_cases = [
"42",
" -17 ",
"3.14",
"true",
"FALSE",
"1e10",
"3+2j",
"0",
"0.0",
"hello",
" ",
"0xFF",
]
for tc in test_cases:
result = smart_numeric(tc)
print(f" {tc!r:>12} → {result!r:>20} ({type(result).__name__})")Expected Output
'42' → 42 (int)
' -17 ' → -17 (int)
'3.14' → 3.14 (float)
'true' → True (bool)
'FALSE' → False (bool)
'1e10' → 10000000000.0 (float)
'3+2j' → (3+2j) (complex)
'0' → 0 (int)
'0.0' → 0.0 (float)
'hello' → 'hello' (str)
' ' → ' ' (str)
'0xFF' → 255 (int)Hints
Hint 1: Strip whitespace first. Check for bool strings before numeric parsing.
Hint 2: Try int() first (including base-16 for 0x prefixes), then float(), then complex().
Hint 3: Use try/except chains — attempt the narrowest type first and fall through on ValueError.
Hint 4: Scientific notation like "1e10" should be parsed as float, not int.
Build safe_cast — a production-grade type conversion function with a fallback chain. It tries each type in order and returns the first successful conversion.
This pattern appears in:
- API request parsing (try int, then float, then keep as string)
- Database migration scripts (convert old schema types to new ones)
- Configuration file loaders
Solution
def safe_cast(value, *type_chain, default=None):
if value is None:
return default
# Pre-process: strip whitespace from strings
cleaned = value.strip() if isinstance(value, str) else value
# Handle bool specially — bool("anything non-empty") is always True
# so we need to check for actual boolean string values
for target_type in type_chain:
try:
if target_type is bool:
if isinstance(cleaned, str):
if cleaned.lower() in ("true", "1", "yes"):
return True
elif cleaned.lower() in ("false", "0", "no"):
return False
else:
continue
else:
return bool(cleaned)
return target_type(cleaned)
except (ValueError, TypeError):
continue
return default
print(safe_cast("42", int, float)) # 42
print(safe_cast("3.14", int, float)) # 3.14
print(safe_cast("hello", int, float, default="N/A")) # N/A
print(safe_cast("true", bool, int, float)) # True
print(safe_cast("3+2j", int, float, complex)) # (3+2j)
print(safe_cast("", int, float, default=0)) # 0
print(safe_cast(" 99 ", int)) # 99
print(safe_cast(None, int, str, default="missing")) # missing
Critical design decision — why bool needs special handling:
bool("false") returns True in Python because "false" is a non-empty string. If we naively called bool() on a string, every non-empty string would convert to True. Instead, we explicitly check for known boolean string representations.
Why strip whitespace: User input almost always has trailing whitespace. int(" 99 ") works in Python (it strips internally), but this makes the intent explicit and handles types that do not auto-strip.
The fallback chain pattern: This is essentially a simplified version of the "Chain of Responsibility" design pattern. Each type converter is a handler — if it cannot process the value, it passes to the next one.
def safe_cast(value, *type_chain, default=None):
"""Try each type in the chain until one succeeds.
Unlike simple try/except, this tries MULTIPLE types in order
and returns the first successful conversion.
Args:
value: The value to convert
*type_chain: Types to try, in order of preference
default: Value to return if ALL conversions fail
Returns:
First successful conversion result, or default
Example:
safe_cast("3.14", int, float) → 3.14 (int fails, float succeeds)
safe_cast("hello", int, float) → None (both fail)
"""
pass
# Tests
print(safe_cast("42", int, float))
print(safe_cast("3.14", int, float))
print(safe_cast("hello", int, float, default="N/A"))
print(safe_cast("true", bool, int, float))
print(safe_cast("3+2j", int, float, complex))
print(safe_cast("", int, float, default=0))
print(safe_cast(" 99 ", int))
print(safe_cast(None, int, str, default="missing"))Expected Output
42\n3.14\nN/A\nTrue\n(3+2j)\n0\n99\nmissingHints
Hint 1: Loop through type_chain, try each conversion in a try/except block.
Hint 2: Return immediately on the first success — do not try remaining types.
Hint 3: Handle None as a special case — most types cannot convert None.
Hint 4: Consider stripping whitespace from strings before conversion.
Build a data normalizer that takes a messy, mixed-type list and converts it to a clean, uniform type. This is a real-world data engineering task — every pandas DataFrame column does this internally.
Auto-inference logic: Try the narrowest type first. If every element can be that type, use it. Otherwise, widen.
Forced mode: When How auto-inference works: Production parallel: This is exactly what Edge case — target_type is given, convert what you can and mark failures as None.Solution
[1, "2", 3.0, True, " 4 "] — int(1)=1, int("2")=2, int(3.0)=3, int(True)=1, int(" 4 ")=4. All succeed → use int.int("2.5") fails → fall through to float. float(1)=1.0, float("2.5")=2.5, etc. All succeed → use float.int("hello") fails. Try float: float("hello") fails. Try str: str(1)="1", str("hello")="hello", etc. All succeed → use str.pandas.to_numeric(errors='coerce') does, and what pd.read_csv() does when inferring column dtypes. The "try narrow, widen on failure" strategy minimizes information loss while ensuring type consistency.True as int: int(True) returns 1, which is correct for data normalization. In a statistics pipeline, you want boolean flags as 0/1 integers. This is intentional behavior, not a bug.
def normalize_list(data, target_type=None):
"""Normalize a mixed-type list to a consistent type.
If target_type is given, convert all elements to that type.
If target_type is None, infer the best common type:
1. If ALL values can be int → int
2. Else if ALL can be float → float
3. Else if ALL can be str → str
4. Otherwise keep original types
Elements that fail conversion are replaced with None.
Args:
data: list of mixed-type values
target_type: optional target type to force
Returns:
tuple of (normalized_list, type_name, conversion_stats)
where conversion_stats = {'success': N, 'failed': N}
"""
pass
# Test 1: Auto-infer int
result, tname, stats = normalize_list([1, "2", 3.0, True, " 4 "])
print(f"Result: {result}")
print(f"Type: {tname}, Stats: {stats}")
print()
# Test 2: Auto-infer float (some values aren't clean ints)
result, tname, stats = normalize_list([1, "2.5", 3, "4.0"])
print(f"Result: {result}")
print(f"Type: {tname}, Stats: {stats}")
print()
# Test 3: Forced type with failures
result, tname, stats = normalize_list(["10", "hello", "30", None, "50"], target_type=int)
print(f"Result: {result}")
print(f"Type: {tname}, Stats: {stats}")
print()
# Test 4: Mixed types that can only be strings
result, tname, stats = normalize_list([1, "hello", 3.14, True])
print(f"Result: {result}")
print(f"Type: {tname}, Stats: {stats}")Expected Output
Result: [1, 2, 3, 1, 4]\nType: int, Stats: {'success': 5, 'failed': 0}\n\nResult: [1.0, 2.5, 3.0, 4.0]\nType: float, Stats: {'success': 4, 'failed': 0}\n\nResult: [10, None, 30, None, 50]\nType: int, Stats: {'success': 3, 'failed': 2}\n\nResult: ['1', 'hello', '3.14', 'True']\nType: str, Stats: {'success': 4, 'failed': 0}Hints
Hint 1: For auto-inference, try converting ALL elements to int first. If any fail, try float. If any fail, try str.
Hint 2: Use a helper function that attempts conversion and returns (success, value).
Hint 3: True/False as int gives 1/0 — this is correct behavior for data normalization.
Hint 4: None values should be counted as failed conversions when a target type is specified.
