Skip to main content

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 why round(2.5) == 2
  • The __bool__ and __len__ truthiness protocol
  • How bytes and str conversion 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 CastingImplicit 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
danger

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
note

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
ExpressionResultMeaning
float("inf")+∞Largest representable value exceeded
float("-inf")-∞Negative infinity
float("nan")NaNResult of undefined operations (e.g. 0/0 in some contexts)
float("1e309")infOverflow - 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
warning

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:

Valueint()math.floor()math.ceil()round()
3.93344
3.13343
-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
warning

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
IntegerFloat conversionExact?Notes
2**53float(2**53)YesWithin safe range
2**53 + 1float(2**53 + 1)NoPrecision loss - consecutive integers not distinguishable
2**53 + 2float(2**53 + 2)YesHappens 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.

danger

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
note

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).

bytesstr 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

EncodingUse caseNote
utf-8Default for everythingVariable width, ASCII-compatible
utf-16Windows APIs, some file formatsFixed 2-byte (BMP) or 4-byte
latin-1 (iso-8859-1)Legacy European systems256 characters, 1 byte each
asciiASCII-only protocols128 characters, 7-bit
utf-8-sigWindows CSV files with BOMAdds/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 methodCalled byPurpose
__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), ifTruthiness
__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 intsfloat(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 preservedset([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 + 01. True is 1 and False is 0 because bool is a subclass of int.
  • 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 through float, then int truncates toward zero.
  • round(2.5) + round(3.5)2 + 46. Banker's rounding: 2.5 rounds to 2 (even), 3.5 rounds to 4 (even).
  • 10 / 25.0 (float). Division always returns float.
  • 10 // 25 (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:

  1. Accept a schema dict mapping config key names to target Python types: {"PORT": int, "DEBUG": bool, "RATE": float, "NAME": str}
  2. Load values from os.environ (or a provided env dict for testing)
  3. Convert each value to the declared type - using safe, explicit conversion (not bool(value) for booleans)
  4. Raise a descriptive ValueError that names the key and shows the raw value when conversion fails
  5. Provide a get(key) method and make the config subscriptable via __getitem__
  6. 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 key
  • get() / __getitem__(): O(1) dict lookup
  • __repr__(): O(k) to format schema keys

Quick Reference Cheatsheet

ConversionSyntaxResultPitfall
str → intint("42")42int("3.14") raises ValueError
str → int (hex)int("FF", 16)255Base only works with str input
float → intint(3.9)3Truncates, does not round
str → floatfloat("3.14")3.14float("inf") works, silent overflow
str → boolbool("false")TrueUse explicit string comparison
int → boolbool(0)FalseOnly 0 is falsy, not 0.0 (separate check)
any → strstr(x)Python reprNot JSON; use json.dumps() for JSON
str → bytess.encode("utf-8")b"..."Raises UnicodeEncodeError if unmappable
bytes → strb.decode("utf-8")"..."Raises UnicodeDecodeError if invalid bytes
round()round(2.5)2Banker's rounding (half-to-even)
large int → floatfloat(2**53 + 1)precision lossUse Decimal for large exact values
bool as intTrue + True2bool is a subclass of int
sum of booleanssum([True, False, True])2Idiomatic count-truthy pattern
__bool__ protocolif 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") raises ValueError. Always convert "3.14" through float first: int(float("3.14")).
  • bool("false") is True. 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 str and int. This is intentional and prevents a class of silent bugs.
  • round(2.5) == 2 - Python uses banker's rounding (round-half-to-even). Use decimal.Decimal with ROUND_HALF_UP for financial calculations.
  • bool is a subclass of int: True == 1 and False == 0 in 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) calls x.__bool__() first, then falls back to x.__len__(), then defaults to True. 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). Use json.dumps when 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.
© 2026 EngineersOfAI. All rights reserved.